Skip to content

[GSoC 2026] Kafka Streams runner: CombineTest coverage and two review follow-ups - #39610

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

[GSoC 2026] Kafka Streams runner: CombineTest coverage and two review follow-ups#39610
je-ik merged 2 commits into
apache:feat/18479-kafka-streams-runner-skeletonfrom
junaiddshaukat:feat/ks-combine-vr

Conversation

@junaiddshaukat

Copy link
Copy Markdown
Contributor

Summary

Part of #18479. Enables CombineTest in the ValidatesRunner suite, and picks up two review follow-ups from #39546 and #39578.

Combine coverage

Combine has always been expected to work here without a translator of its own: the fuser expands Combine.perKey into a GroupByKey with the combining logic running as ordinary ParDos in the SDK harness, all of which the runner already executes. That was an assumption though — nothing exercised it. Enabling CombineTest takes the suite from 49 to 59 tests and makes it a tested claim.

Suite Tests
CombineTest$BasicTests 8
CombineTest$WindowingTests 2

BasicTests passes in full, including hot-key fanout and the accumulation-mode variant; WindowingTests contributes the fixed-window and empty-window cases. The rest falls out on category excludes the task already declares — CombineWithContextTests and AccumulationTests need side inputs, and most of WindowingTests needs side inputs, triggers or TestStream.

CombineTest$WindowingTests.testSessionsCombine is sickbayed. Session windows are merging windows, which the first windowing pass did not implement, so it joins the existing testGroupByKeyMergingWindows entry under a comment now worded to cover Combine too. That is the only Combine failure, and it is the known windowing gap rather than anything specific to Combine.

Flatten: what the Math.max is actually doing

The review question on #39546 was whether the Math.max over the inputs' partition counts is redundant, since the comment above it asserted the inputs are co-partitioned.

Neither half of that was quite right, so both are now fixed. The max is not a no-op in principle: Kafka Streams merges the subtopologies of every parent a processor is wired to and gives the merged subtopology as many tasks as its largest source topic has partitions, so the max is what that task count comes to. But the mismatched case does not reach this translator at all. A Flatten whose branches would disagree — one through a GroupByKey, one straight from a source — is folded into the SDK harness stage by the fuser rather than becoming a node here, and the runner Flattens that do arrive come from the fuser deduplicating partial outputs of a single PCollection.

I tried to build the mismatched shape to see what the runner does with it, and could not: it never becomes a runner Flatten. FlattenParallelismTest records that, so if a change ever lets that shape through, it starts failing and the partition-count handling gets revisited. The comment now describes the situation instead of asserting an invariant nothing enforces.

Worth stating why this is not just tidying. If such a Flatten ever did reach the translator, the branch with fewer partitions would only produce on that many of the merged subtopology's tasks, and the remaining Flatten instances would wait forever for a watermark report from it. A stalled pipeline is a bad failure mode to leave undetected, which is what the test is guarding.

Partitioner: guard the null key

GroupByKeyBroadcastPartitioner.partition() guarded against a null record key, but partitions() — the method Kafka Streams actually calls when it is present — hashed it unguarded. Nothing reaches that today, because data arriving at a repartition sink has been re-keyed by ShuffleByKeyProcessor first, but the guard belongs on the method that runs.

That is the groundwork for the other half of the review point: that a stateless stage should carry a null key rather than the empty-array placeholder Impulse and Read emit. That change is more invasive than it looks — every processor is declared Processor<byte[], …, byte[], …>, so emitting a null key means moving all of them, plus the payload serde and the partitioner generics, to byte @Nullable [], or adding nullness suppressions in several places. It is worth doing, but as its own change rather than folded in here.

Testing

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

… follow-ups

Enables CombineTest in the ValidatesRunner suite, taking it from 49 to 59
tests. Combine was expected to work without a translator of its own, since
the fuser expands Combine.perKey into a GroupByKey with the combining logic
running as ordinary ParDos in the SDK harness, but nothing exercised that.
BasicTests passes in full, including hot-key fanout and the accumulation-mode
variant, and WindowingTests contributes the fixed-window and empty-window
cases. The remainder falls out on category excludes the task already
declares. testSessionsCombine is sickbayed alongside the existing merging
windows entry, and it is the only Combine failure.

Corrects the Flatten partition-count comment, which asserted that the inputs
are co-partitioned and so implied the Math.max over them was redundant.
Neither half held. The max is not a no-op in principle: Kafka Streams merges
the subtopologies of every parent a processor is wired to and gives the
result as many tasks as its largest source topic has partitions. But the
mismatched shape does not reach this translator, because the fuser folds
such a Flatten into the harness stage, and the runner Flattens that do
arrive come from the fuser deduplicating partial outputs of one PCollection.
FlattenParallelismTest records that, so a change letting the mismatched shape
through starts failing there rather than producing a pipeline that stalls
waiting for a watermark report that never comes.

Guards the null record key in GroupByKeyBroadcastPartitioner.partitions().
partition() already guarded it, but partitions() is the method Kafka Streams
calls and it hashed the key unguarded.
@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.

@je-ik
je-ik self-requested a review August 4, 2026 09:32

@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.

Good to see the combine tests pass, I have left a few questions.

KafkaStreamsTranslationContext context =
KafkaStreamsTestRunner.translate(mixedParallelismFlatten(4));

assertThat(context.getTopology().describe().subtopologies().isEmpty(), is(false));

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.

Should we test how the topology looks-like, not only that it is non-empty?

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.

Agreed, isEmpty() == false would have passed no matter what the fuser did, so it wasn't testing the thing the file exists for. Both now assert the shape: that no processor node stands for the Flatten, and that the branches stay in three separate subtopologies instead of being merged into one. That is the actual claim, since a Flatten node over branches of differing parallelism is exactly the case where the smaller branch can't reach all instances and the rest stall.

The second test asserts the same shape deliberately, because whether the Flatten is fused is a property of the fused graph rather than of the parallelism — which is what makes raising the parallelism safe. I printed the translated topology to write these, and there's no Flatten node in either case; both branches just end.

KafkaStreamsTranslationContext context =
KafkaStreamsTestRunner.translate(mixedParallelismFlatten(1));

assertThat(context.getTopology().describe().subtopologies().isEmpty(), is(false));

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.

Same here?

A null record key was being sent to partition 0, which is a fixed partition
rather than no choice at all: keyless records would have piled onto one
partition of the topic. Returning an empty Optional instead tells Kafka
Streams that no explicit partition was chosen, and the producer's default
partitioner spreads them, which is what a record with no key should get.

The Flatten tests asserted only that the topology was non-empty, which would
have passed whatever the fuser did with the Flatten. They now assert the
property they exist for: that no processor node stands for the Flatten, and
that the branches remain in three separate subtopologies rather than being
merged into one. Were a Flatten node to appear over branches of differing
parallelism, the branch with fewer partitions could not reach all of its
instances and the rest would wait forever for a watermark report. Both the
mismatched and the single-parallelism case assert the same shape, since
whether the Flatten is fused is a property of the fused graph and not of the
parallelism.
@je-ik
je-ik merged commit fc36301 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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants