Skip to content

Commit ff323eb

Browse files
[GSoC 2026] Kafka Streams runner #39273: Kafka Streams runner: Flatten support
1 parent 27c4522 commit ff323eb

19 files changed

Lines changed: 873 additions & 76 deletions

runners/kafka-streams/proto/src/main/proto/kafka_streams_payload.proto

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,22 @@ option java_outer_classname = "KafkaStreamsPayloadProtos";
2727
// topic boundary (e.g. the GroupByKey repartition topic and the watermark fan-out). Protobuf is
2828
// used for compatible schema evolution and compact varint encoding.
2929
message KafkaStreamsPayload {
30-
// A watermark report: the watermark plus the in-band coordination fields the downstream
31-
// WatermarkManager needs.
30+
// A watermark report: the watermark plus the in-band coordination fields a downstream
31+
// watermark aggregator needs to reconstruct its input watermark.
3232
message WatermarkPayload {
3333
// Event-time watermark in milliseconds. Signed (sint64, zigzag-encoded) because Beam event
3434
// times can be negative, e.g. BoundedWindow.TIMESTAMP_MIN_VALUE.
3535
sint64 millis = 1;
36-
// The source partition this report is for.
36+
// Which partition (physical instance) of the producing transform this report is for, in
37+
// [0, total_partitions).
3738
uint32 source_partition = 2;
38-
// The total number of source partitions feeding the downstream stage.
39+
// How many partitions (physical instances) the producing transform has in total.
3940
uint32 total_partitions = 3;
41+
// Globally unique id of the transform that produced this report. A producer stamps its own id
42+
// without regard to who consumes the report; a consumer with several upstream transforms
43+
// (e.g. Flatten) aggregates per producing transform, holding its output watermark until every
44+
// partition of every upstream transform it expects has reported.
45+
string transform_id = 4;
4046
}
4147

4248
// A data element: the Beam WindowedValue encoded with the PCollection's windowed-value coder.

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessor.java

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package org.apache.beam.runners.kafka.streams.translation;
1919

2020
import java.util.Queue;
21+
import java.util.Set;
2122
import java.util.concurrent.ConcurrentLinkedQueue;
2223
import org.apache.beam.model.pipeline.v1.RunnerApi;
2324
import org.apache.beam.runners.fnexecution.control.BundleProgressHandler;
@@ -51,12 +52,12 @@
5152
* ProcessorContext#forward} must only be called from the processing thread, so outputs are never
5253
* forwarded directly from a harness callback.
5354
*
54-
* <p>A {@link KStreamsPayload#isWatermark() watermark} payload is a per-source-partition report and
55-
* marks a bundle boundary: the open bundle (if any) is closed (flushing outputs), the report is fed
56-
* to the {@link WatermarkManager}, and the stage's output watermark is forwarded downstream only
57-
* when the {@code min()} across its source partitions actually advances. Until every source
58-
* partition has reported, the watermark is held and nothing is forwarded — but data is still
59-
* processed in the meantime.
55+
* <p>A {@link KStreamsPayload#isWatermark() watermark} payload is a report from one partition of
56+
* one upstream transform and marks a bundle boundary: the open bundle (if any) is closed (flushing
57+
* outputs), the report is fed to the {@link WatermarkAggregator}, and the stage's output watermark
58+
* is forwarded downstream — stamped with this stage's own transform id — only when the aggregate
59+
* across the upstream transform's partitions actually advances. Until every partition has reported,
60+
* the watermark is held and nothing is forwarded — but data is still processed in the meantime.
6061
*
6162
* <p>This is the Kafka Streams analogue of Flink's {@code ExecutableStageDoFnOperator} and Spark's
6263
* {@code SparkExecutableStageFunction}. State, timers, and side inputs are out of scope for this
@@ -70,6 +71,9 @@ class ExecutableStageProcessor
7071

7172
private final RunnerApi.ExecutableStagePayload stagePayload;
7273
private final JobInfo jobInfo;
74+
// This stage's own transform id, stamped on every watermark it forwards so downstream watermark
75+
// aggregators know which transform the report came from — regardless of who consumes it.
76+
private final String transformId;
7377

7478
// pendingOutputs is enqueued by SDK harness threads (inside the OutputReceiverFactory callback)
7579
// and drained by the Kafka Streams processing thread on bundle close; needs to be thread-safe.
@@ -79,9 +83,9 @@ class ExecutableStageProcessor
7983
// only safe because the Impulse output coder happens to be ByteArrayCoder.
8084
private final Queue<WindowedValue<?>> pendingOutputs = new ConcurrentLinkedQueue<>();
8185

82-
// Computes this stage's output watermark as min() over its source partitions' reported
83-
// watermarks, holding until every source partition has reported (see WatermarkManager).
84-
private final WatermarkManager watermarkManager = new WatermarkManager();
86+
// Computes this stage's input watermark from its upstream transform's reports, holding until
87+
// every partition of the upstream transform has reported (see WatermarkAggregator).
88+
private final WatermarkAggregator watermarkAggregator;
8589
// The last watermark actually forwarded downstream, so we only forward when it advances.
8690
private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
8791

@@ -90,9 +94,20 @@ class ExecutableStageProcessor
9094
private @Nullable StageBundleFactory stageBundleFactory;
9195
private @Nullable RemoteBundle currentBundle;
9296

93-
ExecutableStageProcessor(RunnerApi.ExecutableStagePayload stagePayload, JobInfo jobInfo) {
97+
/**
98+
* @param transformId this stage's own transform id, stamped on the watermarks it emits
99+
* @param upstreamTransformIds the transform ids feeding this stage (known from the pipeline
100+
* graph), whose reports the {@link WatermarkAggregator} waits for
101+
*/
102+
ExecutableStageProcessor(
103+
RunnerApi.ExecutableStagePayload stagePayload,
104+
JobInfo jobInfo,
105+
String transformId,
106+
Set<String> upstreamTransformIds) {
94107
this.stagePayload = stagePayload;
95108
this.jobInfo = jobInfo;
109+
this.transformId = transformId;
110+
this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
96111
}
97112

98113
@Override
@@ -116,18 +131,21 @@ private void ensureStageBundleFactory() {
116131
@Override
117132
public void process(Record<byte[], KStreamsPayload<?>> record) {
118133
KStreamsPayload<?> payload = record.value();
134+
if (payload == null) {
135+
// A topic feeding the runner can always be written to from outside (or carry a tombstone),
136+
// so recover from the obvious error instead of crashing the task: warn and drop.
137+
LOG.warn(
138+
"Stage {} dropping record with null payload (external write or tombstone)", transformId);
139+
return;
140+
}
119141
if (payload.isWatermark()) {
120142
// Emit any buffered outputs before the watermark. Data is processed regardless of watermark
121143
// readiness; only the watermark itself is held until every source partition has reported.
122144
closeBundleAndFlush(record);
123-
// Feed the report into the WatermarkManager and forward the stage's output watermark only
124-
// when min() across the source partitions actually advances, not on every received watermark.
125-
WatermarkPayload report = payload.asWatermark();
126-
watermarkManager.observe(
127-
report.getSourcePartition(),
128-
new Instant(report.getWatermarkMillis()),
129-
report.getTotalSourcePartitions());
130-
Instant advanced = watermarkManager.advance();
145+
// Feed the report into the aggregator and forward the stage's output watermark only when the
146+
// aggregate across the upstream transform's partitions actually advances.
147+
watermarkAggregator.observe(payload.asWatermark());
148+
Instant advanced = watermarkAggregator.advance();
131149
if (advanced.isAfter(lastForwardedWatermark)) {
132150
lastForwardedWatermark = advanced;
133151
forwardWatermark(record, advanced.getMillis());
@@ -203,14 +221,16 @@ private void closeBundleAndFlush(Record<byte[], KStreamsPayload<?>> record) {
203221
}
204222

205223
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> record, long watermarkMillis) {
206-
// This stage is a single instance for now, so it forwards its watermark as the only source
207-
// partition (0 of 1). Fanning the watermark out to every downstream partition — and producing
208-
// it atomically with the offset commit so it is durable — lands with the topic-based shuffle
209-
// work, when there are real source partitions to track (#18479).
224+
// Stamped with this stage's own transform id; this stage is a single instance for now, so the
225+
// report is for its only partition (0 of 1). Fanning the watermark out to every downstream
226+
// partition — and producing it atomically with the offset commit so it is durable — lands with
227+
// the topic-based shuffle work (#18479).
210228
ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
211229
ctx.forward(
212230
new Record<byte[], KStreamsPayload<?>>(
213-
record.key(), KStreamsPayload.watermark(watermarkMillis, 0, 1), record.timestamp()));
231+
record.key(),
232+
KStreamsPayload.watermark(watermarkMillis, transformId, 0, 1),
233+
record.timestamp()));
214234
}
215235

216236
@Override

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageTranslator.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import java.io.IOException;
2121
import org.apache.beam.model.pipeline.v1.RunnerApi;
22+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
2223
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
2324
import org.apache.kafka.streams.Topology;
2425

@@ -83,9 +84,14 @@ public void translate(
8384
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
8485

8586
Topology topology = context.getTopology();
87+
// The stage stamps its own transform id on the watermarks it emits, and aggregates its input
88+
// watermark from the reports of its single upstream transform (the producer of its input
89+
// PCollection, whose node name is the upstream transform id).
8690
topology.addProcessor(
8791
transformId,
88-
() -> new ExecutableStageProcessor(stagePayload, context.getJobInfo()),
92+
() ->
93+
new ExecutableStageProcessor(
94+
stagePayload, context.getJobInfo(), transformId, ImmutableSet.of(parentProcessor)),
8995
parentProcessor);
9096

9197
if (!transform.getOutputsMap().isEmpty()) {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams.translation;
19+
20+
import java.util.Set;
21+
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
22+
import org.apache.kafka.streams.processor.api.Processor;
23+
import org.apache.kafka.streams.processor.api.ProcessorContext;
24+
import org.apache.kafka.streams.processor.api.Record;
25+
import org.checkerframework.checker.nullness.qual.Nullable;
26+
import org.joda.time.Instant;
27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
29+
30+
/**
31+
* Kafka Streams {@link Processor} implementing Beam's {@code Flatten} primitive ({@code
32+
* beam:transform:flatten:v1}): the union of N input PCollections into one output PCollection.
33+
*
34+
* <p><b>Data</b> records are forwarded straight through unchanged — the merge of the N parents'
35+
* data streams <em>is</em> the flatten.
36+
*
37+
* <p><b>Watermark</b> reports are where Flatten does real work, and it owns its output watermark
38+
* the same way GroupByKey does: it runs a {@link WatermarkAggregator} over its inputs, forwards its
39+
* own watermark only when the {@code min()} across them advances, and stamps that as a single
40+
* source ({@code 0 of 1}) to its downstream. This holds the output watermark back until
41+
* <em>every</em> input branch has reported, so a downstream GroupByKey does not fire before all
42+
* flattened branches are drained.
43+
*
44+
* <p>The {@link WatermarkAggregator} tells the input branches apart by the transform id each
45+
* branch's producer stamps on its watermark (Kafka Streams does not tell a processor which parent
46+
* forwarded a record). Each producer stamps its own identity regardless of who consumes it, so a
47+
* PCollection feeding several Flattens reports one identity and every Flatten still waits only for
48+
* the upstream transforms it expects — the set handed to it at construction from the pipeline
49+
* graph.
50+
*/
51+
class FlattenProcessor
52+
implements Processor<byte[], KStreamsPayload<?>, byte[], KStreamsPayload<?>> {
53+
54+
private static final Logger LOG = LoggerFactory.getLogger(FlattenProcessor.class);
55+
56+
// This transform's own id, stamped on every watermark it forwards downstream.
57+
private final String transformId;
58+
// Computes the output watermark as min() over the upstream transforms' reports, holding until
59+
// every partition of every expected upstream transform has reported (see WatermarkAggregator).
60+
private final WatermarkAggregator watermarkAggregator;
61+
// The last watermark actually forwarded downstream, so we only forward when it advances.
62+
private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
63+
64+
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
65+
66+
/**
67+
* @param transformId this Flatten's own transform id, stamped on the watermarks it emits
68+
* @param upstreamTransformIds the producers of this Flatten's input PCollections (known from the
69+
* pipeline graph), whose reports the {@link WatermarkAggregator} waits for
70+
*/
71+
FlattenProcessor(String transformId, Set<String> upstreamTransformIds) {
72+
this.transformId = transformId;
73+
this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
74+
}
75+
76+
@Override
77+
public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
78+
this.context = context;
79+
}
80+
81+
@Override
82+
public void process(Record<byte[], KStreamsPayload<?>> record) {
83+
KStreamsPayload<?> payload = record.value();
84+
if (payload == null) {
85+
// A topic feeding the runner can always be written to from outside (or carry a tombstone),
86+
// so recover from the obvious error instead of crashing the task: warn and drop.
87+
LOG.warn(
88+
"Flatten {} dropping record with null payload (external write or tombstone)",
89+
transformId);
90+
return;
91+
}
92+
ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
93+
if (!payload.isWatermark()) {
94+
// Data: the union of the parents' data streams is the flatten — forward unchanged.
95+
ctx.forward(record);
96+
return;
97+
}
98+
watermarkAggregator.observe(payload.asWatermark());
99+
Instant advanced = watermarkAggregator.advance();
100+
if (advanced.isAfter(lastForwardedWatermark)) {
101+
lastForwardedWatermark = advanced;
102+
// Stamped with this Flatten's own transform id; Flatten is a single instance for now, so the
103+
// report is for its only partition (0 of 1).
104+
ctx.forward(
105+
new Record<byte[], KStreamsPayload<?>>(
106+
record.key(),
107+
KStreamsPayload.watermark(advanced.getMillis(), transformId, 0, 1),
108+
record.timestamp()));
109+
}
110+
}
111+
112+
private static ProcessorContext<byte[], KStreamsPayload<?>> checkInitialized(
113+
@Nullable ProcessorContext<byte[], KStreamsPayload<?>> context) {
114+
if (context == null) {
115+
throw new IllegalStateException("FlattenProcessor used before init()");
116+
}
117+
return context;
118+
}
119+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams.translation;
19+
20+
import java.util.ArrayList;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Set;
24+
import org.apache.beam.model.pipeline.v1.RunnerApi;
25+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
26+
import org.apache.kafka.streams.Topology;
27+
28+
/**
29+
* Translates Beam's {@code Flatten} primitive ({@code beam:transform:flatten:v1}): the union of N
30+
* input PCollections into one output PCollection.
31+
*
32+
* <p>Wires a single {@link FlattenProcessor} node to the producer of every input PCollection (Kafka
33+
* Streams lets a processor have many parents), so the parents' data streams merge into it, and
34+
* registers it as the producer of the flattened output so downstream translators wire to it. The
35+
* processor forwards data through and owns its output watermark via a {@link WatermarkAggregator},
36+
* which is handed the producers of the input PCollections — the upstream transform ids whose
37+
* watermark reports the Flatten must hear from. Producers stamp their own transform id on the
38+
* reports they emit, without regard to who consumes them, so an input shared with another Flatten
39+
* needs no special handling.
40+
*
41+
* <p>A user-written self-flatten never reaches this translator: the fuser folds the Flatten into
42+
* the consuming SDK-harness stage, which performs the duplication itself. The duplicate-input check
43+
* below is defensive — if a runner-executed Flatten ever did receive the same PCollection twice,
44+
* Kafka Streams could not wire the same parent to a child twice and the duplicate copy would be
45+
* silently dropped, so failing fast is safer.
46+
*/
47+
class FlattenTranslator implements PTransformTranslator {
48+
49+
@Override
50+
public void translate(
51+
String transformId, RunnerApi.Pipeline pipeline, KafkaStreamsTranslationContext context) {
52+
RunnerApi.PTransform transform = pipeline.getComponents().getTransformsOrThrow(transformId);
53+
// Flatten produces exactly one output PCollection, fed by all of its input PCollections.
54+
String outputPCollectionId = Iterables.getOnlyElement(transform.getOutputsMap().values());
55+
56+
Set<String> seenInputs = new HashSet<>();
57+
List<String> parentProcessors = new ArrayList<>();
58+
Set<String> upstreamTransformIds = new HashSet<>();
59+
for (String inputPCollectionId : transform.getInputsMap().values()) {
60+
if (!seenInputs.add(inputPCollectionId)) {
61+
throw new UnsupportedOperationException(
62+
"Flatten "
63+
+ transform.getUniqueName()
64+
+ " has PCollection "
65+
+ inputPCollectionId
66+
+ " as an input more than once; a self-flatten is not yet supported by the Kafka"
67+
+ " Streams runner.");
68+
}
69+
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
70+
parentProcessors.add(parentProcessor);
71+
upstreamTransformIds.add(parentProcessor);
72+
}
73+
74+
Topology topology = context.getTopology();
75+
topology.addProcessor(
76+
transformId,
77+
() -> new FlattenProcessor(transformId, upstreamTransformIds),
78+
parentProcessors.toArray(new String[0]));
79+
80+
context.registerPCollectionProducer(outputPCollectionId, transformId);
81+
}
82+
}

0 commit comments

Comments
 (0)