Skip to content

[GSoC 2026] Kafka Streams runner: Flatten support#39273

Open
junaiddshaukat wants to merge 3 commits into
apache:feat/18479-kafka-streams-runner-skeletonfrom
junaiddshaukat:feat/ks-flatten
Open

[GSoC 2026] Kafka Streams runner: Flatten support#39273
junaiddshaukat wants to merge 3 commits into
apache:feat/18479-kafka-streams-runner-skeletonfrom
junaiddshaukat:feat/ks-flatten

Conversation

@junaiddshaukat

@junaiddshaukat junaiddshaukat commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a translator for Beam's Flatten primitive (beam:transform:flatten:v1) -- the union of N input PCollections into one. Part of #18479; the last primitive needed before the first PAssert-based @ValidatesRunner test (PAssert's
GroupGlobally uses GBK + Flatten, no side inputs).

What's here:

  • FlattenProcessor -- forwards data through, and owns its output watermark the
    way GroupByKey does: a WatermarkManager over the input branches, emitting its
    own single-source (0 of 1) watermark only when the min() advances. This
    holds the watermark back until every branch has drained.
  • Global producer numbering for branch identity. Each Flatten-input PCollection
    gets one stable global producer id; its producing transform stamps that id on
    its watermark (always as a single source, 1 of 1), because Kafka Streams does
    not tell a processor which parent forwarded a record. Each Flatten then holds
    using its own input count. A PCollection that feeds two Flattens reports one id
    and each Flatten still waits only for its own branches -- so input2 in
    l1 = Flatten(input1, input2) / l2 = Flatten(input2, input3) works (thanks
    @je-ik for catching that per-Flatten numbering could not express this). It also
    means a single-input consumer always sees 1 of 1, so there is no fan-out
    hazard.
  • FlattenTranslator wires the N parents to one node; FLATTEN registered in the
    translator map.
  • A self-flatten (Flatten.of(pc, pc)) is rejected with a clear
    UnsupportedOperationException -- its single producer cannot be two branches,
    and Kafka Streams cannot wire the same parent to a child twice. Proper support
    is a follow-up.

Scope: only ExecutableStage producers stamp the producer id so far, which covers PAssert's GroupGlobally (its Flatten inputs are stages). A Read / Impulse / GBK output feeding a Flatten directly still reports id 0 -- that
needs the same stamp wiring and is a follow-up when those tests are enabled.

Tests: FlattenTest unions two Create -> ParDo branches and asserts all four elements arrive (which only holds because the watermark is held until both branches drain), verifies a PCollection feeding two Flattens produces both
unions, and asserts a self-flatten is rejected.

Add a translator for Beam's Flatten primitive (beam:transform:flatten:v1), the
union of N input PCollections into one.

- FlattenProcessor forwards data straight through and owns its output watermark
  the way GroupByKey does: it runs a WatermarkManager over its input branches and
  emits its own single-source (0 of 1) watermark only when the min() across them
  advances, holding until every branch has drained so a downstream GroupByKey
  does not fire early.
- The branch identity (i of N) the WatermarkManager needs is stamped upstream by
  the producing ExecutableStage when its output feeds a Flatten -- a translation
  pre-pass records which PCollections are Flatten inputs -- because Kafka Streams
  does not tell a processor which parent forwarded a record. Producers whose
  output does not feed a Flatten keep reporting as the single source (0 of 1).

Only ExecutableStage producers stamp the branch identity so far, which covers the
PAssert GroupGlobally shape the ValidatesRunner tests use. A Read/Impulse/GBK
output feeding a Flatten directly is a follow-up.

Test: FlattenTest unions two Create -> ParDo branches and asserts a downstream
ParDo sees every element from both.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request adds support for the Beam Flatten primitive to the Kafka Streams runner. This is a critical step toward enabling @ValidatesRunner tests that rely on PAssert. The implementation ensures that watermarks are correctly managed across multiple input branches by stamping branch identities at the producer level, preventing premature watermark advancement and data loss.

Highlights

  • Flatten Primitive Support: Implemented the Flatten primitive translator (FlattenTranslator) and processor (FlattenProcessor) to enable merging multiple PCollections into one in the Kafka Streams runner.
  • Watermark Branch Identity: Introduced a producer-stamped branch identity mechanism (i of N) to allow the FlattenProcessor to correctly distinguish between input branches and hold the watermark until all branches have drained.
  • Testing: Added FlattenTest to verify that data from multiple branches is correctly unioned and that watermarks are held appropriately until all branches are processed.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements the Flatten primitive for the Kafka Streams runner by introducing FlattenProcessor and FlattenTranslator, and updating watermark tracking to support multi-branch fan-in via SourceStamp. The reviewer feedback highlights several critical issues: potential non-deterministic behavior and watermark stalls due to duplicate input PCollections in registerFlattenSourceStamps, consistency issues in parent processor resolution, unsupported multi-Flatten consumption of a single PCollection, and a potential NullPointerException when processing tombstone records with null payloads.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +144 to +149
int totalPartitions = transform.getInputsMap().size();
int sourcePartition = 0;
for (String inputPCollectionId : transform.getInputsMap().values()) {
context.registerFlattenSourceStamp(inputPCollectionId, sourcePartition, totalPartitions);
sourcePartition++;
}

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.

critical

There are two issues here:

  1. Non-deterministic iteration order: The iteration order of transform.getInputsMap().values() is not guaranteed to be deterministic across different JVMs or runs. Since the topology is built independently on each worker, this can lead to different workers assigning different sourcePartition indices to the same PCollections.
  2. Duplicate input PCollections: If a PCollection is flattened with itself (e.g., PCollectionList.of(pc).and(pc)), the same PCollection ID will appear multiple times. The current loop will overwrite the SourceStamp mapping, resulting in only one partition being registered and stamped. Consequently, the WatermarkManager in FlattenProcessor will wait forever for the other partition, causing the watermark to get stuck.

Deduplicating and sorting the input PCollection IDs resolves both issues.

      java.util.List<String> uniqueInputs =
          new java.util.ArrayList<>(new java.util.HashSet<>(transform.getInputsMap().values()));
      java.util.Collections.sort(uniqueInputs);
      int totalPartitions = uniqueInputs.size();
      int sourcePartition = 0;
      for (String inputPCollectionId : uniqueInputs) {
        context.registerFlattenSourceStamp(inputPCollectionId, sourcePartition, totalPartitions);
        sourcePartition++;
      }

Comment on lines +49 to +52
List<String> parentProcessors = new ArrayList<>();
for (String inputPCollectionId : transform.getInputsMap().values()) {
parentProcessors.add(context.getProcessorNameForPCollection(inputPCollectionId));
}

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.

high

To match the deduplicated and sorted input PCollections used for watermark tracking, we should also deduplicate and sort the inputs when building the parent processors list. This ensures consistency and avoids passing duplicate parent names to Topology.addProcessor.

Suggested change
List<String> parentProcessors = new ArrayList<>();
for (String inputPCollectionId : transform.getInputsMap().values()) {
parentProcessors.add(context.getProcessorNameForPCollection(inputPCollectionId));
}
java.util.List<String> uniqueInputs =
new java.util.ArrayList<>(new java.util.HashSet<>(transform.getInputsMap().values()));
java.util.Collections.sort(uniqueInputs);
List<String> parentProcessors = new ArrayList<>();
for (String inputPCollectionId : uniqueInputs) {
parentProcessors.add(context.getProcessorNameForPCollection(inputPCollectionId));
}

Comment on lines +141 to +142
pCollectionIdToSourceStamp.put(
pCollectionId, new SourceStamp(sourcePartition, totalPartitions));

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.

high

If a PCollection is consumed by multiple different Flatten transforms, the SourceStamp mapping will be overwritten, leading to incorrect watermark tracking. We should explicitly detect this conflict and throw an UnsupportedOperationException to prevent silent correctness bugs.

    SourceStamp existing =
        pCollectionIdToSourceStamp.put(
            pCollectionId, new SourceStamp(sourcePartition, totalPartitions));
    if (existing != null) {
      throw new UnsupportedOperationException(
          "PCollection "
              + pCollectionId
              + " is consumed by multiple Flattens, which is not supported yet due to watermark tracking limitations.");
    }

Comment on lines +67 to +69
ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
if (!payload.isWatermark()) {
// Data: the union of the parents' data streams is the flatten — forward unchanged.

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.

medium

In Kafka Streams, tombstone records can have a null value. Accessing payload.isWatermark() without a null check will throw a NullPointerException. Adding a defensive null check to forward tombstone records downstream is recommended.

    KStreamsPayload<?> payload = record.value();
    ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
    if (payload == null) {
      ctx.forward(record);
      return;
    }
    if (!payload.isWatermark()) {

…n once

A PCollection flattened with itself (Flatten.of(pc, pc)) or consumed by two
Flattens would need a distinct watermark source per branch, but its single
producer can only stamp one identity, so the branch watermark would get stuck.
registerFlattenSourceStamp now throws UnsupportedOperationException on the second
registration rather than silently overwriting. Deduping is not an option: a
self-flatten must emit its input twice (bag union), so dropping the copy would
lose data.

Also sort the Flatten input PCollection ids so each branch index is assigned
deterministically. Adds a test that a self-flatten is rejected.
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @tvalentyn 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.

Reading through the code I realized we overlooked something.

Suppose the following case:

 Pipeline p = Pipeline.create();
 PCollection<..> input1 = p.apply(...)
 PCollection<...> input2 = p.apply(...)
 PCollection<...> input3 = p.apply(...)

 PCollectionList l1 = PCollectionList.of(input1).and(input2);
 l1.apply(Flatten.pCollections());
 PCollectionList l2 = PCollectionList.of(input2).and(input3);
 l2.apply(Flatten.pCollection());

input2 is "1 of 2" for l1, but "0 of 2" for l2.

So the static numbering does not work here. What we actually need is a static numbering of PTransforms:
input1 = 1
input2 = 2
input3 = 3

and information that flatten l1 should wait for watermark from 1 and 2, while flatten l2 for watemark from 2 and 3. This way, the watermark can be generated without worrying about WHO and HOW consumes it.

// source to the next stage; but when this stage's output feeds a Flatten it is that branch's
// (i of N), so the Flatten's WatermarkManager can tell the branches apart. See FlattenProcessor.
private final int sourcePartition;
private final int totalPartitions;

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 use a distinct naming convetion for there "partitions", because we already have Kafka partitions and this creates a confusion.

record.key(),
KStreamsPayload.watermark(advanced.getMillis(), 0, 1),
record.timestamp()));
}

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.

This logic seems like it is a general watermark propagation (will be the same for GBK, Combine, ExecutableStage), we could make this part of the WatermarkManager.

// about and reproduce. registerFlattenSourceStamp fails fast on a duplicate (self-flatten).
List<String> inputPCollectionIds = new ArrayList<>(transform.getInputsMap().values());
Collections.sort(inputPCollectionIds);
int totalPartitions = inputPCollectionIds.size();

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.

Ah! Here is the confusion! There are Kafka partitions and this "partitions" here is the number of inputs.

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.

Good catch, thanks, you're right, per-Flatten (i of N) can't express a PCollection that feeds two Flattens. Reworked it the way you described: each Flatten-input PCollection gets one stable global producer id, its producer stamps that id (always as a single source, 1 of 1), and each Flatten holds its watermark using its own input count. So input2 stamps one id, l1 waits for {1,2}, l2 for {2,3}, and the shared input just works. Nice side effect it also removes the fan-out hazard, since any single-input consumer always sees 1 of 1. Added a test for your exact case (input2 feeding both flattens produces both unions). Self-flatten (Flatten.of(pc,pc)) is still rejected, since one producer can't be two branches.

…wo Flattens

je-ik found that per-Flatten (i of N) branch numbering breaks when a PCollection
feeds two Flattens (input2 is "1 of 2" for one and "0 of 2" for the other) --
its single producer cannot stamp two identities, and the previous guard wrongly
rejected this valid pipeline.

Number producers instead: each Flatten-input PCollection gets one stable global
id, its producer stamps that id (always as a single source, 1 of 1), and each
Flatten holds its watermark using its own input count. A shared input reports one
id and every Flatten still waits only for its own branches. This also removes the
fan-out hazard (a single-input consumer always sees "1 of 1"). Self-flatten
(Flatten.of(pc, pc)) stays rejected -- one producer cannot be two branches.

Adds a test that a PCollection feeding two Flattens produces both unions.
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