[GSoC 2026] Kafka Streams runner: windowed GroupByKey via ReduceFnRunner#39494
Conversation
Replaces the global-window-only GroupByKey with a windowed one that drives Beam's ReduceFnRunner on the runner side, the way the Flink and Spark portable runners do, backed by Kafka Streams state and timers. WindowedGroupByKeyProcessor builds a ReduceFnRunner per key (like GroupAlsoByWindowViaWindowSetNewDoFn) over two new backends: KafkaStreamsStateInternals, which stores each Beam state cell as one entry in a KeyValueStore under a composite key of key + namespace + tag (modelled on SparkStateInternals), and KafkaStreamsTimerInternals, which persists timers keyed by identity and is fired by the processor scanning for due event-time timers on each input-watermark advance. GroupByKeyTranslator hydrates the input windowing strategy from the pipeline proto and wires the state and timer stores. Windowing, the default trigger, panes, allowed lateness and timestamp combiners all come from ReduceFnRunner. Also drops KafkaStreamsTestRunner.roundTripInternalTopics: TopologyTestDriver loops an internal repartition topic from sink back to source on its own, so the manual round-trip was delivering every GroupByKey record twice. The old global-window GroupByKey masked this by firing each key once and latching; a correctly windowed GroupByKey emits a late pane for the duplicate, which surfaced the bug. Advancing the wall clock now drives the pipeline to completion. validatesRunner goes from 44 to 49 tests: GroupByKeyTest.BasicTests 7 -> 9 (timestamp combiners un-sickbayed) and GroupByKeyTest.WindowTests 0 -> 3. testGroupByKeyMergingWindows stays sickbayed; session (merging) windows land in a follow-up. Adds FixedWindowGroupByKeyTest.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
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.
Overall this is a huge step forward. The biggest issue is that we absolutely need an efficient lookup of timers by fire instant to avoid full scans which will be expensive.
| } | ||
|
|
||
| /** The composite store key for one cell: {@code len|key len|namespace len|tagId}. */ | ||
| private byte[] compositeKey(StateNamespace namespace, String id) { |
There was a problem hiding this comment.
This method will be a hotspot (it is called for every state access), can we optimize it a little by avoiding length-prefixing and instead just copy bytes in memory, e.g. (pseudocode I did'nt check correctness, this is just an idea)
byte[] namespaceBytes = namespae.stringKey().getBytes(UTF_8);
byte[] idBytes = id.getBytes(UTF_8);
int len = encodedKey.length + namespaceBytes.length + idBytes.length + 2 /* add a separator, e.g. / between parts to avoid any possible collision */;
byte[] res = new byte[len];
System.arraycopy(encodedKey -> res);
System.arraycopy(namespaceByes -> res + encodedKey.length + 1);
System.arraycopy(idBytes -> res + encodedKey.length + namespaceBytes.length + 2);
res[encodedKey.length] = "/";
res[encodedKey.length + namespaceBytes.length + 1] = "/';This might be more efficient due to pre-allocating the whole array and then just memcpy data.
It might also be worth caching the encodedKey / namespace pair, because it will be likely called several times with identical values in sequence (e.g. use code can read state 1, state 2, timer 1, write state 2, write timer 1 - all will have same encodedKey + namespace)
There was a problem hiding this comment.
Done, it now builds into one exactly-sized array with System.arraycopy, and caches the key | namespace prefix, which is reused across the several tags one ReduceFnRunner turn touches. That removes the stream growth and the final copy.
I kept the length prefixes rather than a separator, though: an encoded key is arbitrary coder output and a namespace stringKey can contain any byte too, so key="a/b", ns="c" and key="a", ns="b/c" would produce the same bytes with any separator. The prefixes are also what lets the timer scan read the encoded key back out (your comment below). I moved the byte helpers into StoreKeys and wrote the reasoning there.
|
|
||
| @Override | ||
| public @Nullable Instant currentOutputWatermarkTime() { | ||
| return null; |
There was a problem hiding this comment.
Why we return null here? Can we add comment explaining if this is going to be fixed in future and what it depends on? Same for synchronized processing time.
There was a problem hiding this comment.
Added. currentOutputWatermarkTime no longer returns null — it now returns the watermark the GroupByKey has actually published, which trails the input watermark by the pending holds (the watermark-hold comment below).
currentSynchronizedProcessingTime still returns null, and I documented why: it's the slowest processing time across the job's workers, which needs cross-instance coordination our watermark reports only carry for event time. TimerInternals allows null, and nothing on the paths supported today reads it — it's for processing-time triggers, which land with processing-time timer support in the follow-up, the same work that would supply it.
| TimeDomain domain, | ||
| StateNamespace namespace) { | ||
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | ||
| writeSegment(out, encodedKey); |
There was a problem hiding this comment.
Because we need to be able to efficiently parse key from the stored byte[] it seems this approach is a spot on here.
There was a problem hiding this comment.
Thanks, kept it exactly for that reason. The scan reads the encoded key back out of the key, so the prefixes have to stay here. I wrote that down in StoreKeys so it doesn't get "optimized" away later.
| // ReduceFnRunner turn. Whole-store scan per advance is O(timers) — see the class doc. | ||
| Map<String, DueTimers> dueByKey = new LinkedHashMap<>(); | ||
| List<byte[]> firedStoreKeys = new ArrayList<>(); | ||
| try (KeyValueIterator<byte[], byte[]> it = timers.all()) { |
There was a problem hiding this comment.
This looks expensive. And we call it for each watermark move, we need a way to efficiently select only timers that should expire, not iterate all timers, because there will be a timer for each key and that cen be a long list.
There was a problem hiding this comment.
Fixed, this was the right call. Timers are now in two stores: the identity store (key/domain/family/id/namespace → index key) for overwrite and delete, and a new index store keyed domain | fireTimestamp | identity whose value is the TimerData.
Firing is now one range() over (-inf, watermark] on the index, so the cost is proportional to the timers actually due rather than to the number of keys holding a timer, and no decode is needed to decide whether a timer is due. Timestamps are written sign-flipped big-endian so the unsigned byte order Kafka Streams compares keys by matches numeric order — negative timestamps included.
KafkaStreamsTimerInternalsTest covers the range scan, negative timestamps, replacing a timer (the old index entry must go, or it would still fire at the original time), deletion, two keys sharing the index, and processing-time timers being excluded from the event-time scan.
| encodedKey, KStreamsPayload.data(output), trigger.timestamp())); | ||
| } | ||
|
|
||
| private void forwardWatermark(Record<byte[], KStreamsPayload<?>> trigger, long watermarkMillis) { |
There was a problem hiding this comment.
It seems we must apply watermark holds here, if I'm not missing something.
There was a problem hiding this comment.
You're right, this was a real bug. ReduceFnRunner takes a hold for a buffered pane that hasn't been emitted, and we were publishing the raw input watermark, so downstream was told nothing earlier was coming while panes were still buffered those elements would then arrive late against a watermark we'd already published.
WatermarkHoldState now mirrors each hold into a hold-time-ordered index, and the GroupByKey publishes min(inputWatermark, minimum hold). Reading the minimum is the first entry of that index rather than a scan, same idea as the timer index. Holds are read after firing, since firing emits the panes and releases them.
Adds a fire-time index so due timers are found by a range scan instead of a scan of every timer of every key, which was the main review concern. Timers now live in two stores: the identity store, keyed by key/domain/family/id/ namespace, which is how a timer is overwritten or deleted and whose value is the index key; and the index store, keyed by domain, fire timestamp and identity, which the processor range-scans over (-inf, watermark] to find exactly the timers that are due. Timestamps are written sign-flipped big-endian so the unsigned byte order Kafka Streams compares keys by matches numeric order, including for negative timestamps. Applies watermark holds when publishing the watermark. ReduceFnRunner takes a hold for a buffered pane that has not been emitted; the GroupByKey now mirrors those holds into a hold-time-ordered index and publishes min(inputWatermark, minimum hold), so downstream is not told that nothing earlier is coming while panes are still buffered. Holds are read after firing, since firing releases them. Builds the composite state key into one exactly-sized array with arraycopy and caches the key/namespace prefix, which a turn of the windowing runner reuses across the several tags it touches. Keeps the length prefixes rather than a separator byte: an encoded key and a namespace string can both contain any byte, so key=a/b,ns=c and key=a,ns=b/c would otherwise collide, and the timer scan reads the encoded key back out of a key. Documents why currentSynchronizedProcessingTime returns null and what would supply it; currentOutputWatermarkTime now returns the watermark actually published instead of null. Adds KafkaStreamsTimerInternalsTest covering the due-timer range scan, negative timestamps, timer replacement and deletion, timers of different keys sharing the index, and processing-time timers being excluded.
Summary
Part of #18479.
Adds real windowing (and the triggering machinery that comes with it) to the Kafka Streams runner's GroupByKey, which until now only handled the global window. The approach follows the Flink and Spark portable runners: run Beam's
ReduceFnRunneron the runner side, backed by Kafka Streams state and timers. Design discussed on the dev@ list and with the mentor beforehand.What changed
The global-window
GroupByKeyProcessoris replaced byWindowedGroupByKeyProcessor, which drivesReduceFnRunnerper key — the same wayGroupAlsoByWindowViaWindowSetNewDoFndoes — over two new backends:KafkaStreamsStateInternals— Beam'sStateInternalsover a Kafka StreamsKeyValueStore. Each state cell (value, bag, combining, watermark-hold) is one store entry under a composite keykey | namespace | tag, so a window's state lives in its own namespace and writes go straight to the changelogged store. Modelled on the Spark runner'sSparkStateInternals.KafkaStreamsTimerInternals— timers persisted in a store keyed by timer identity. On each input-watermark advance the processor scans for event-time timers whose fire time has passed and replays them throughReduceFnRunner.onTimers, which is what makes windows emit their panes.GroupByKeyTranslatorhydrates the input PCollection'sWindowingStrategyfrom the pipeline proto and wires the state and timer stores. Windowing, the default trigger, panes, allowed lateness and timestamp combiners all come fromReduceFnRunner.Test harness: dropped the manual repartition round-trip
While testing this I found that
KafkaStreamsTestRunner.roundTripInternalTopicswas delivering every record through the GroupByKey repartition topic twice.TopologyTestDriver(Kafka Streams 3.9) already loops an internal repartition topic from its sink back to its source within a driver step, so the manual round-trip was redundant and double-fed the topic. The previous global-window GroupByKey masked this because it fired every key once at the terminal watermark and then latched; a correctly windowed GroupByKey emits a late pane for the duplicate delivery, which surfaced the bug.Removed the round-trip machinery. Advancing the wall clock now drives the whole pipeline to completion, and the full runner suite confirms
TopologyTestDriverloops internal topics on its own.Results
validatesRunnergoes from 44 to 49 tests:GroupByKeyTest$BasicTestsGroupByKeyTest$WindowTestsWindowTests.testGroupByKeyMergingWindowsstays sickbayed: session (merging) windows needReduceFnRunner's merging window set to move per-window state as windows merge, which this first pass does not implement. Fixed and sliding windows, the default trigger and timestamp combiners all work. Merging windows and richer triggers land in a follow-up.Also adds a focused runner unit test (
FixedWindowGroupByKeyTest) that groups the same key across two fixed windows, and folds the#39452reference into theLifecycleTestssickbay comment.Testing