[GSoC 2026] Kafka Streams runner: terminate a bounded pipeline when it is drained - #39700
Conversation
…t is drained Kafka Streams runs a topology until something closes the client, so a bounded pipeline produced its output and then ran for ever. Every processor already emits TIMESTAMP_MAX_VALUE once its input is exhausted, so each one now schedules its own termination when it emits that watermark, and the client is closed once they have all reported. Termination is scheduled rather than reported inline so that the work which follows the final watermark still runs. The callback waits for every processor rather than the first, because one instance can own both sides of a repartition topic, and it waits until the topology has finished starting, because processors register as their tasks are initialized. run() now blocks until the pipeline finishes, matching FlinkPipelineRunner: JobInvocation reads the result's state when run() returns, so returning early left a finished job reported as RUNNING. Also namespaces the GroupByKey repartition topic by application id, as the Impulse and Read bootstrap topics already are. Transform ids come from the pipeline's structure, so two jobs running the same pipeline shared a shuffle topic and the second silently inherited the first one's partition count.
|
Assigning reviewers: R: @kennknowles added as fallback since no labels match configuration Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
je-ik
left a comment
There was a problem hiding this comment.
One minor comment, this can be merged.
| } | ||
| callback = takeCallbackIfDone(); | ||
| } | ||
| run(callback); |
There was a problem hiding this comment.
Should we nullify the callback here so that it cannot be run twice?
There was a problem hiding this comment.
Good call. It couldn't actually run twice before — there was a fired flag guarding it — but nulling the callback does the same job, so I've dropped the flag and kept just the one mechanism.
It's a real path rather than a theoretical one: the callback stops the client, which closes every task's processors, and each of those unregisters on the way out and asks the tracker again. Added a test for that, and checked it fails if the callback isn't cleared.
Replaces the separate fired flag: the pipeline finishes once, but processors keep reporting afterwards, since stopping the client closes every task's processors and each unregisters on the way out.
4ff6180
into
apache:feat/18479-kafka-streams-runner-skeleton
Summary
Part of #18479. A bounded pipeline never finished against a real broker: Kafka Streams runs a topology until something closes the client, and nothing did. The pipeline produced the right answer and then sat there, and
waitUntilFinish()blocked for ever.This was invisible to every existing suite. The
@ValidatesRunnertests run onTopologyTestDriver, which is synchronous and reportsDONEunconditionally; the broker integration tests poll a counter and thencancel(), so none of them ever waits for a pipeline to finish on its own.How termination is decided
Every processor already emits
TIMESTAMP_MAX_VALUEonce its input is exhausted, so the runner knows when it is drained — it just did nothing with it. Each processor now schedules its own termination when it emits that watermark, and the client is closed once they have all reported.Scheduling rather than reporting inline is what makes it safe: a punctuator runs after the current record has been handled, so flushing a bundle, forwarding downstream and committing all still happen first. It has to be
WALL_CLOCK_TIME, since no records arrive after the final watermark and stream time would never advance. The interval is 1ms because Kafka Streams rejects anything smaller.Two things that turned out to matter:
It waits for every processor, not the first. One instance can own tasks from both sides of a repartition topic. The upstream side goes terminal as soon as it has written to the topic while the downstream side still has to consume it, so stopping at the first would discard that work and report a successful run.
It waits until the topology has finished starting. Processors register as their task is initialized, so mid-startup the registered set is only part of the pipeline. On a short pipeline the source can drain before the stage downstream of the repartition topic exists, and stopping there reports success having produced nothing.
No coordination between instances is needed. Watermarks crossing a repartition topic are broadcast to every partition, so every task observes the terminal watermark wherever it runs, and each instance reaches the same conclusion on its own.
run()now blocksJobInvocationreads the pipeline result's state once, whenrun()returns. Ours returned right afterstart(), so a job stayedRUNNINGfor ever even after the client had stopped cleanly. It now blocks until the pipeline finishes, which is whatFlinkPipelineRunnerdoes by blocking inexecutor.execute().This is a deliberate contract change: an unbounded pipeline blocks the calling thread until the job is cancelled. That is the same behaviour Flink has, and the job service already runs
run()on its own executor and interrupts it to cancel — which the runner now handles by closing the client when it sees the interrupt.An unrelated bug this turned up
Repartition topics were named
__beam_gbk_<transformId>, with no application id, while the Impulse and Read bootstrap topics already include one. Transform ids come from the pipeline's structure, so two jobs running the same pipeline shuffled through the same topic and read each other's data. Because the topic is only created when it does not already exist, the second job also silently inherited the first one's partition count instead of the one it asked for.It surfaced here because the new integration test runs the same pipeline at a different parallelism to an existing one, which no two tests had done before. It is a small fix and independent of the rest of this PR — happy to split it out if you would rather review it separately.
Note that this renames the topics, so a job restarted after this change shuffles through new ones and leaves its old repartition topics behind. That seems fine for a runner that is on a feature branch and marked experimental, but it is a behaviour change rather than a pure fix.
Testing
TerminationTrackerTestcovers the decision logic, including the two cases above. I checked each fails without the code that makes it pass, rather than trusting that they were green.KafkaStreamsRunnerBrokerIT.aBoundedPipelineTerminatesOnItsOwnis the end-to-end one: two chained GroupByKeys across four partitions, and nothing cancels it, so returning fromrun()at all is the assertion. It also asserts the output is produced exactly once, because termination rides a wall-clock punctuator and that is the same mechanism that duplicates output when it is used to close bundles on time (#39633).The broker suite also got faster — 2m56s to 31s — since the topic fix stopped the tests inheriting each other's partition counts.
Beyond the suites, a Beam Python pipeline now runs and finishes on its own against a real broker:
Create -> Map, terminating in about ten seconds, reportingDONE, with the expected output. Before this it hung indefinitely.