Skip to content

[GSoC 2026] Kafka Streams runner: read unbounded sources - #39611

Merged
je-ik merged 4 commits into
apache:feat/18479-kafka-streams-runner-skeletonfrom
junaiddshaukat:feat/ks-unbounded-source
Aug 4, 2026
Merged

[GSoC 2026] Kafka Streams runner: read unbounded sources#39611
je-ik merged 4 commits into
apache:feat/18479-kafka-streams-runner-skeletonfrom
junaiddshaukat:feat/ks-unbounded-source

Conversation

@junaiddshaukat

Copy link
Copy Markdown
Contributor

Summary

Part of #18479.

The runner could only read sources that finish. That is the wrong shape for what it is: a Kafka Streams application is a long-running stream processor, and a pipeline over bounded data has more efficient homes. This adds UnboundedSource support, so the runner can read a source that never ends.

How it differs from the bounded read

Bounded and unbounded reads share the beam:transform:read:v1 URN and are told apart by ReadPayload.getIsBounded(), so ReadTranslator now branches there. What sits behind the branch is genuinely different:

  • The source is polled, not drained. advance() returning false means nothing is available right now, not that the source is finished, so the reader is asked again on each turn of a wall-clock punctuator. A poll takes at most --maxBundleSize elements, so a fast source cannot monopolise the Kafka Streams thread and starve the rest of the topology.
  • The watermark comes from the reader. A bounded read jumps to the end of time once its input runs out. Here UnboundedReader#getWatermark() is forwarded whenever it advances, which is what lets downstream windows close on a stream that never finishes.

Resuming after a restart

UnboundedReader#getCheckpointMark() describes the position the reader has consumed to. It is written to a persistent state store, and the reader is created from the stored mark rather than from scratch, so a task that restarts or moves resumes where it left off instead of re-reading from the beginning.

The mark is written after the elements it covers have been forwarded, so it can never claim more progress than was actually emitted. The store is changelogged and, under exactly-once, its writes commit atomically with the records the processor forwarded.

What this does not do yet

  • finalizeCheckpoint() is not called. A mark should be finalized once it is durably committed, which needs a pre-commit hook the runner does not have — the same gap that stopped the bundle time bound in [GSoC 2026] Kafka Streams runner: bound a bundle by element count #39578. Sources that rely on finalization to acknowledge or release data will not see it. Worth doing as its own change, once that hook exists.
  • No split distribution. The source is read by a single reader, so a source with several splits is consumed by one instance. Spreading splits across instances belongs with the topic-based shuffle work.

Testing

UnboundedReadTest runs a pipeline over CountingSource.unbounded(). Nothing caps the source — capping it with withMaxNumRecords would turn it back into a bounded read and test the wrong path — so the work is bounded by the per-poll element limit and the number of turns the test drives.

It asserts more than a single poll's worth of elements arrive, which is the property that separates this from a bounded read: the source has to be asked again on each turn and carry on from where it was. The elements are also checked to be contiguous from zero, so a poll neither skips nor repeats what the previous one consumed.

./gradlew :runners:kafka-streams:validatesRunner   # 59 tests, 0 failures
./gradlew :runners:kafka-streams:build            # 84 unit tests, spotless + checker + errorprone

The runner could only read sources that finish, which is the wrong shape for
what it is: a Kafka Streams application is a long-running stream processor,
and bounded data has more efficient homes.

Bounded and unbounded reads share a URN and are distinguished by the payload,
so ReadTranslator branches on it. The unbounded processor polls its reader on
a wall-clock punctuator rather than draining it once, since advance()
returning false means nothing is available right now rather than that the
source is finished, and takes at most maxBundleSize elements per turn so a
fast source cannot starve the rest of the topology. Its watermark comes from
UnboundedReader#getWatermark() instead of jumping to the end of time when the
input runs out, which is what lets downstream windows close on a stream that
never finishes.

The reader's checkpoint mark is written to a persistent state store after the
elements it covers have been forwarded, so it can never claim more progress
than was emitted, and the reader is created from the stored mark so a restart
resumes where it left off. finalizeCheckpoint is not called yet: a mark
should only be finalized once durably committed, which needs a pre-commit
hook the runner does not have. The source is also read by a single reader, so
splits are not distributed across instances.

UnboundedReadTest drives a genuinely unbounded CountingSource and asserts
more than one poll's worth of elements arrive, contiguously from zero, which
is what separates a polled source from a drained one.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @chamikaramj added as fallback since no labels match configuration

Note: If you would like to opt out of this review, comment assign to next reviewer.

Available commands:

  • stop reviewer notifications - opt out of the automated review tooling
  • remind me after tests pass - tag the comment author after tests pass
  • waiting on author - shift the attention set back to the author (any comment or push by the author will return the attention set to the reviewers)

The PR bot will only process comments in the main thread (not review comments).

@je-ik je-ik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 overall, we should definitely track the deficiency in splitting, but if we already postponed it for bounded sources, we can live with it for the skeleton runner. We must definitely document these features that are core, but not yet imeplemented.

addReadNodes(
transformId,
boundedSource(transform),
boundedSource(payload, transform),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems we can reuse directly ReadTranslation.boundedSourceFromProto().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the wrapper is gone and one try/catch now covers parsing the payload and hydrating either kind of source.

// mark can never claim more progress than was actually emitted.
storeCheckpoint(currentReader);
}
forwardWatermarkIfAdvanced(ctx, currentReader.getWatermark());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably immediately reschedule next poll loop (it emitted > 0), otherwise we will limit throughput.

Also, when currentReader.getWatermark() returns >= TIMESTAMP_MAX_VALUE, we should finish the poll loop entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both done, and the first one caught a bug of mine. I first made the batches loop while the source kept filling them, which hung the test: CountingSource.unbounded() always has data, so every batch came back full and the loop never returned — the Kafka Streams thread would never have got back to committing. It now takes at most readCheckpointNumBundles batches before yielding, so throughput isn't capped at a batch per interval but the thread still comes back.

The terminal watermark now ends it properly: polling stops and the punctuator is cancelled, rather than spinning on a reader that can only return false. There's a test for it.

if (emitted > 0) {
// Record the position only after the elements it covers have been forwarded, so the stored
// mark can never claim more progress than was actually emitted.
storeCheckpoint(currentReader);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracting the checkpoint might be a little expensive. We might not do it that often - how about adding a config option (e.g. readCheckpointNumBundles), which will control how many bundles (non-empty calls to poll() should occur before we do a checkpoint).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added --readCheckpointNumBundles, default 10. It doubles as the batch bound above, which fits — that's the point where the loop yields anyway, so it's a natural place to store the mark. Documented the trade: a larger value replays more after a restart, since the reader resumes from the last mark stored.

…ore reading

Reuses ReadTranslation.boundedSourceFromProto directly rather than wrapping
it, with one try/catch covering parsing and hydration for both kinds of
source.

Polls in batches that run back to back rather than returning after one, since
waiting for the next punctuation after every batch capped throughput at a
batch per interval. The run is bounded all the same: a source that always has
data would otherwise never let the poll return and the Kafka Streams thread
would never get back to committing or to the rest of the topology, so at most
readCheckpointNumBundles batches are taken before yielding. Polling also
stops entirely, and the punctuator is cancelled, once the reader's watermark
reaches the end of time, which is the source saying it will produce nothing
further.

Adds --readCheckpointNumBundles, since taking a checkpoint mark can be costly
and is not worth doing on every batch. The cost of a larger value is that
more elements are replayed after a restart, because the reader resumes from
the last mark stored.

Asks the source to split before creating a reader. A source is not obliged to
be readable in its unsplit form, and split() is where several of them do
their setup, so going through it even for a single reader is the supported
path.

Adds a test that a source reaching the end of time stops being polled and
delivers each element exactly once.
A source is not obliged to be readable in its unsplit form — split() is where
several of them do their setup — so the reader is now created from
source.split(1, options) rather than from the source directly. One split,
because this processor is a single instance; distributing several splits
across instances is tracked separately.

This was described in the previous review reply but was not actually in the
code: the edit did not apply and the claim went out before it was verified.
Splitting was being done inside the processor, which is the wrong place: it
would run once per task instance rather than once for the pipeline, and the
contract does not define splitting a source that has already been split. It
now happens in ReadTranslator, and the processor is handed a source that is
ready to read.

The count passed to split() is only a hint, so what comes back is checked.
Taking the first of several splits and ignoring the rest would quietly drop
whatever those parts would have produced, which is data loss rather than a
missing feature, so translation fails with an explanation instead. Reading
several splits in parallel is still not supported.

UnboundedReadTest covers it with a source that returns two splits whatever it
is asked for.
@je-ik
je-ik merged commit 5861f31 into apache:feat/18479-kafka-streams-runner-skeleton Aug 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants