Skip to content

Commit 4c02a77

Browse files
[GSoC 2026] Kafka Streams runner — Redistribute translator + ExecutableStage type-agnostic edge (#38843)
* Add Redistribute (arbitrarily) translator + type-agnostic ExecutableStage edge * Drain pendingOutputs via poll() loop instead of forEach + clear * Assert ChainedExecutableStageTest pipeline actually has two ExecutableStages
1 parent ff554f7 commit 4c02a77

4 files changed

Lines changed: 288 additions & 25 deletions

File tree

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

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
* receivers.
6060
*/
6161
class ExecutableStageProcessor
62-
implements Processor<byte[], KStreamsPayload<byte[]>, byte[], KStreamsPayload<byte[]>> {
62+
implements Processor<byte[], KStreamsPayload<?>, byte[], KStreamsPayload<?>> {
6363

6464
private static final Logger LOG = LoggerFactory.getLogger(ExecutableStageProcessor.class);
6565

@@ -68,9 +68,13 @@ class ExecutableStageProcessor
6868

6969
// pendingOutputs is enqueued by SDK harness threads (inside the OutputReceiverFactory callback)
7070
// and drained by the Kafka Streams processing thread on bundle close; needs to be thread-safe.
71-
private final Queue<WindowedValue<byte[]>> pendingOutputs = new ConcurrentLinkedQueue<>();
71+
// The element type is intentionally wildcarded: the runner does not need to know the runtime
72+
// value type — the bundle factory handles all coder application at the Fn-API boundary using
73+
// the PCollection coders from the ExecutableStagePayload. Pretending the type was byte[] was
74+
// only safe because the Impulse output coder happens to be ByteArrayCoder.
75+
private final Queue<WindowedValue<?>> pendingOutputs = new ConcurrentLinkedQueue<>();
7276

73-
private @Nullable ProcessorContext<byte[], KStreamsPayload<byte[]>> context;
77+
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
7478
private @Nullable ExecutableStageContext stageContext;
7579
private @Nullable StageBundleFactory stageBundleFactory;
7680
private @Nullable RemoteBundle currentBundle;
@@ -81,16 +85,16 @@ class ExecutableStageProcessor
8185
}
8286

8387
@Override
84-
public void init(ProcessorContext<byte[], KStreamsPayload<byte[]>> context) {
88+
public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
8589
this.context = context;
8690
ExecutableStage executableStage = ExecutableStage.fromPayload(stagePayload);
8791
this.stageContext = KafkaStreamsExecutableStageContextFactory.getInstance().get(jobInfo);
8892
this.stageBundleFactory = stageContext.getStageBundleFactory(executableStage);
8993
}
9094

9195
@Override
92-
public void process(Record<byte[], KStreamsPayload<byte[]>> record) {
93-
KStreamsPayload<byte[]> payload = record.value();
96+
public void process(Record<byte[], KStreamsPayload<?>> record) {
97+
KStreamsPayload<?> payload = record.value();
9498
if (payload.isWatermark()) {
9599
// NOTE: flushing the bundle on every received watermark is provisional. Once the
96100
// WatermarkManager lands, a stage will receive watermarks from multiple parent instances and
@@ -122,7 +126,7 @@ public <OutputT> FnDataReceiver<OutputT> create(String pCollectionId) {
122126
// after the bundle closes.
123127
return receivedElement -> {
124128
if (receivedElement != null) {
125-
pendingOutputs.add((WindowedValue<byte[]>) receivedElement);
129+
pendingOutputs.add((WindowedValue<?>) receivedElement);
126130
}
127131
};
128132
}
@@ -143,7 +147,7 @@ private FnDataReceiver<WindowedValue<?>> mainInputReceiver() {
143147
return receiver;
144148
}
145149

146-
private void closeBundleAndFlush(Record<byte[], KStreamsPayload<byte[]>> record) {
150+
private void closeBundleAndFlush(Record<byte[], KStreamsPayload<?>> record) {
147151
RemoteBundle bundle = currentBundle;
148152
if (bundle == null) {
149153
return;
@@ -157,26 +161,25 @@ private void closeBundleAndFlush(Record<byte[], KStreamsPayload<byte[]>> record)
157161
} finally {
158162
currentBundle = null;
159163
}
160-
ProcessorContext<byte[], KStreamsPayload<byte[]>> ctx = checkInitialized(context);
164+
ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
161165
// The harness has finished the bundle (close() returned) so no further enqueues happen.
162-
// ConcurrentLinkedQueue's weakly-consistent iterator is therefore safe to drain via forEach.
163-
pendingOutputs.forEach(
164-
output ->
165-
ctx.forward(
166-
new Record<byte[], KStreamsPayload<byte[]>>(
167-
record.key(), KStreamsPayload.data(output), record.timestamp())));
168-
pendingOutputs.clear();
166+
// Drain via poll() so each element is removed as it is forwarded.
167+
WindowedValue<?> output;
168+
while ((output = pendingOutputs.poll()) != null) {
169+
ctx.forward(
170+
new Record<byte[], KStreamsPayload<?>>(
171+
record.key(), KStreamsPayload.data(output), record.timestamp()));
172+
}
169173
}
170174

171-
private void forwardWatermark(
172-
Record<byte[], KStreamsPayload<byte[]>> record, long watermarkMillis) {
175+
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> record, long watermarkMillis) {
173176
// TODO(#38743 / WatermarkManager): a watermark must reach every parallel instance of every
174177
// downstream processor, but ctx.forward routes to one downstream partition per Kafka Streams'
175178
// partitioning. The simplest correct approach is to fan the watermark out to all downstream
176179
// partitions; that wiring lands with the WatermarkManager sub-issue (per Jan on PR #38764).
177-
ProcessorContext<byte[], KStreamsPayload<byte[]>> ctx = checkInitialized(context);
180+
ProcessorContext<byte[], KStreamsPayload<?>> ctx = checkInitialized(context);
178181
ctx.forward(
179-
new Record<byte[], KStreamsPayload<byte[]>>(
182+
new Record<byte[], KStreamsPayload<?>>(
180183
record.key(), KStreamsPayload.watermark(watermarkMillis), record.timestamp()));
181184
}
182185

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,21 @@
1717
*/
1818
package org.apache.beam.runners.kafka.streams.translation;
1919

20+
import com.google.auto.service.AutoService;
2021
import java.util.Map;
22+
import java.util.Set;
2123
import org.apache.beam.model.pipeline.v1.RunnerApi;
2224
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
2325
import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
26+
import org.apache.beam.sdk.util.construction.NativeTransforms;
2427
import org.apache.beam.sdk.util.construction.PTransformTranslation;
2528
import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
2629
import org.apache.beam.sdk.util.construction.graph.GreedyPipelineFuser;
2730
import org.apache.beam.sdk.util.construction.graph.PipelineNode;
2831
import org.apache.beam.sdk.util.construction.graph.QueryablePipeline;
32+
import org.apache.beam.sdk.util.construction.graph.TrivialNativeTransformExpander;
2933
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
34+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
3035

3136
/**
3237
* Translates a portable Beam pipeline into a Kafka Streams {@link
@@ -45,6 +50,7 @@ public KafkaStreamsPipelineTranslator() {
4550
this(
4651
ImmutableMap.<String, PTransformTranslator>builder()
4752
.put(PTransformTranslation.IMPULSE_TRANSFORM_URN, new ImpulseTranslator())
53+
.put(PTransformTranslation.REDISTRIBUTE_ARBITRARILY_URN, new RedistributeTranslator())
4854
.put(ExecutableStage.URN, new ExecutableStageTranslator())
4955
.build());
5056
}
@@ -59,11 +65,28 @@ public KafkaStreamsTranslationContext createTranslationContext(
5965
}
6066

6167
/**
62-
* Fuses the pipeline so that stateless user code is grouped into {@code ExecutableStage} nodes.
68+
* Returns the set of URNs this translator handles natively. {@link
69+
* TrivialNativeTransformExpander} uses this set to strip the sub-transforms of runner-native
70+
* composites (e.g. {@code Redistribute.arbitrarily}) before fusion, so they survive into
71+
* translation as leaves instead of being expanded into primitives the runner does not implement
72+
* yet (e.g. GroupByKey).
73+
*/
74+
public Set<String> knownUrns() {
75+
return urnToTranslator.keySet();
76+
}
77+
78+
/**
79+
* Prepares the pipeline for translation:
80+
*
81+
* <ol>
82+
* <li>Trim sub-transforms of runner-native composites listed in {@link #knownUrns()} so the
83+
* fuser leaves them as primitives.
84+
* <li>Fuse remaining stateless user code into {@code ExecutableStage} nodes via {@link
85+
* GreedyPipelineFuser}.
86+
* </ol>
6387
*
64-
* <p>Runner-executed primitives that have their own translator (e.g. Impulse) are left intact;
65-
* everything else is fused. If the pipeline already contains {@code ExecutableStage} transforms
66-
* it is returned unchanged.
88+
* <p>If the pipeline already contains {@code ExecutableStage} transforms it is returned
89+
* unchanged.
6790
*/
6891
public RunnerApi.Pipeline prepareForTranslation(RunnerApi.Pipeline pipeline) {
6992
boolean alreadyFused =
@@ -72,7 +95,8 @@ public RunnerApi.Pipeline prepareForTranslation(RunnerApi.Pipeline pipeline) {
7295
if (alreadyFused) {
7396
return pipeline;
7497
}
75-
return GreedyPipelineFuser.fuse(pipeline).toPipeline();
98+
RunnerApi.Pipeline trimmed = TrivialNativeTransformExpander.forKnownUrns(pipeline, knownUrns());
99+
return GreedyPipelineFuser.fuse(trimmed).toPipeline();
76100
}
77101

78102
/**
@@ -99,4 +123,23 @@ public void translate(KafkaStreamsTranslationContext context, RunnerApi.Pipeline
99123
translator.translate(node.getId(), pipeline, context);
100124
}
101125
}
126+
127+
/**
128+
* Tells the SDK that URNs handled directly by the Kafka Streams runner should be treated as
129+
* primitives by {@link QueryablePipeline}. Mirrors Flink's {@code IsFlinkNativeTransform}
130+
* pattern. Without this, {@link TrivialNativeTransformExpander} strips a composite's
131+
* sub-transforms but {@link QueryablePipeline} still does not recognise the composite itself as a
132+
* producer of its outputs, and pipeline validation fails with "consumed but never produced".
133+
*/
134+
@AutoService(NativeTransforms.IsNativeTransform.class)
135+
public static class IsKafkaStreamsNativeTransform implements NativeTransforms.IsNativeTransform {
136+
private static final Set<String> URNS =
137+
ImmutableSet.of(PTransformTranslation.REDISTRIBUTE_ARBITRARILY_URN);
138+
139+
@Override
140+
public boolean test(RunnerApi.PTransform pTransform) {
141+
String urn = PTransformTranslation.urnForTransformOrNull(pTransform);
142+
return urn != null && URNS.contains(urn);
143+
}
144+
}
102145
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 org.apache.beam.model.pipeline.v1.RunnerApi;
21+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
22+
23+
/**
24+
* Runner-native translator for {@code beam:transform:redistribute_arbitrarily:v1}.
25+
*
26+
* <p>The default Beam expansion of {@code Redistribute.arbitrarily()} goes through {@code
27+
* GroupByKey} for materialization. The Kafka Streams runner does not need to do any actual
28+
* redistribution in the single-instance topology — Kafka Streams is already handling per-task
29+
* processing — so we provide a passthrough: the output PCollection is mapped to the same processor
30+
* that already produces the input PCollection. No topology node is added.
31+
*
32+
* <p>The translator is registered in {@link KafkaStreamsPipelineTranslator#knownUrns()} so {@link
33+
* org.apache.beam.sdk.util.construction.graph.TrivialNativeTransformExpander} strips the
34+
* sub-transforms of {@code Redistribute} before the fuser runs. That keeps the Redistribute
35+
* boundary intact and lets the fuser split adjacent stages correctly.
36+
*
37+
* <p>The keyed variant ({@code redistribute_by_key:v1}) is intentionally not handled here: it
38+
* implies a rehash/partition step that the runner can implement on top of GroupByKey when that
39+
* lands, but cannot reasonably no-op the way the arbitrary variant can.
40+
*/
41+
class RedistributeTranslator implements PTransformTranslator {
42+
43+
@Override
44+
public void translate(
45+
String transformId, RunnerApi.Pipeline pipeline, KafkaStreamsTranslationContext context) {
46+
RunnerApi.PTransform transform = pipeline.getComponents().getTransformsOrThrow(transformId);
47+
String inputPCollectionId = Iterables.getOnlyElement(transform.getInputsMap().values());
48+
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
49+
String outputPCollectionId = Iterables.getOnlyElement(transform.getOutputsMap().values());
50+
// Passthrough: downstream lookups for the output PCollection resolve to the producer of the
51+
// input PCollection. No KS Processor / state store / source is added.
52+
context.registerPCollectionProducer(outputPCollectionId, parentProcessor);
53+
}
54+
}

0 commit comments

Comments
 (0)