This is an automated email from the ASF dual-hosted git repository.

je-ik pushed a commit to branch feat/18479-kafka-streams-runner-skeleton
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to 
refs/heads/feat/18479-kafka-streams-runner-skeleton by this push:
     new fca14356614 [GSoC 2026] Kafka Streams runner: multi-output executable 
stages (#39410)
fca14356614 is described below

commit fca14356614faa904e69633463d710bf860b63bd
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Wed Jul 22 16:28:18 2026 +0500

    [GSoC 2026] Kafka Streams runner: multi-output executable stages (#39410)
---
 runners/kafka-streams/build.gradle                 |   8 +-
 .../translation/ExecutableStageProcessor.java      |  53 +++++++---
 .../translation/ExecutableStageTranslator.java     |  52 ++++++----
 .../streams/translation/StageOutputProcessor.java  |  93 ++++++++++++++++++
 .../kafka/streams/MultiOutputStageTest.java        |  87 +++++++++++++++++
 .../ExecutableStageProcessorWatermarkTest.java     |   5 +-
 .../translation/StageOutputProcessorTest.java      | 108 +++++++++++++++++++++
 7 files changed, 373 insertions(+), 33 deletions(-)

diff --git a/runners/kafka-streams/build.gradle 
b/runners/kafka-streams/build.gradle
index 81099560ba7..52535c6654e 100644
--- a/runners/kafka-streams/build.gradle
+++ b/runners/kafka-streams/build.gradle
@@ -87,8 +87,11 @@ dependencies {
 
 // Known-failing @ValidatesRunner tests, excluded until the feature they need 
lands.
 def sickbayTests = [
-  // Needs multi-output executable stages (output-tag dispatch in 
ExecutableStageProcessor).
-  
'org.apache.beam.sdk.transforms.FlattenTest.testFlattenMultiplePCollectionsHavingMultipleConsumers',
+  // Non-global windowing (FixedWindows, merging windows, timestamp combiners) 
is not supported
+  // yet; these apply a window and assert on window-derived output, hitting a 
GlobalWindow cast.
+  'org.apache.beam.sdk.transforms.GroupByKeyTest$WindowTests',
+  
'org.apache.beam.sdk.transforms.GroupByKeyTest$BasicTests.testTimestampCombinerLatest',
+  
'org.apache.beam.sdk.transforms.GroupByKeyTest$BasicTests.testTimestampCombinerEarliest',
 ]
 
 tasks.register("validatesRunner", Test) {
@@ -139,6 +142,7 @@ tasks.register("validatesRunner", Test) {
     // JUnit category to exclude.
     includeTestsMatching 'org.apache.beam.sdk.transforms.CreateTest'
     includeTestsMatching 'org.apache.beam.sdk.transforms.FlattenTest'
+    includeTestsMatching 'org.apache.beam.sdk.transforms.GroupByKeyTest*'
     for (String test : sickbayTests) {
       excludeTestsMatching test
     }
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessor.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessor.java
index fb26e9e71f6..15265073dd2 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessor.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessor.java
@@ -17,6 +17,7 @@
  */
 package org.apache.beam.runners.kafka.streams.translation;
 
+import java.util.Map;
 import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ConcurrentLinkedQueue;
@@ -36,6 +37,7 @@ import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
 import org.apache.beam.sdk.values.WindowedValue;
 import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.TextFormat;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import org.apache.kafka.streams.processor.api.Processor;
 import org.apache.kafka.streams.processor.api.ProcessorContext;
@@ -85,11 +87,14 @@ class ExecutableStageProcessor
 
   // pendingOutputs is enqueued by SDK harness threads (inside the 
OutputReceiverFactory callback)
   // and drained by the Kafka Streams processing thread on bundle close; needs 
to be thread-safe.
-  // The element type is intentionally wildcarded: the runner does not need to 
know the runtime
-  // value type — the bundle factory handles all coder application at the 
Fn-API boundary using
-  // the PCollection coders from the ExecutableStagePayload. Pretending the 
type was byte[] was
-  // only safe because the Impulse output coder happens to be ByteArrayCoder.
-  private final Queue<WindowedValue<?>> pendingOutputs = new 
ConcurrentLinkedQueue<>();
+  // Each entry carries the output PCollection id so it can be routed to that 
output's downstream on
+  // flush. The element type is intentionally wildcarded: the runner does not 
need to know the
+  // runtime value type — the bundle factory handles all coder application at 
the Fn-API boundary
+  // using the PCollection coders from the ExecutableStagePayload.
+  private final Queue<PendingOutput> pendingOutputs = new 
ConcurrentLinkedQueue<>();
+  // Output PCollection id -> the child node (a StageOutputProcessor relay) to 
forward that output
+  // to. Empty for a single-output stage, which forwards to its one downstream 
directly.
+  private final Map<String, String> outputChildByPCollectionId;
 
   // Computes this stage's input watermark from its upstream transform's 
reports, holding until
   // every partition of the upstream transform has reported (see 
WatermarkAggregator).
@@ -114,12 +119,25 @@ class ExecutableStageProcessor
       JobInfo jobInfo,
       String transformId,
       Set<String> upstreamTransformIds,
-      MetricsContainerImpl metricsContainer) {
+      MetricsContainerImpl metricsContainer,
+      Map<String, String> outputChildByPCollectionId) {
     this.stagePayload = stagePayload;
     this.jobInfo = jobInfo;
     this.transformId = transformId;
     this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
     this.metricsContainer = metricsContainer;
+    this.outputChildByPCollectionId = 
ImmutableMap.copyOf(outputChildByPCollectionId);
+  }
+
+  /** A harness output element together with the id of the output PCollection 
it belongs to. */
+  private static final class PendingOutput {
+    final String pCollectionId;
+    final WindowedValue<?> value;
+
+    PendingOutput(String pCollectionId, WindowedValue<?> value) {
+      this.pCollectionId = pCollectionId;
+      this.value = value;
+    }
   }
 
   @Override
@@ -182,11 +200,12 @@ class ExecutableStageProcessor
         new OutputReceiverFactory() {
           @Override
           public <OutputT> FnDataReceiver<OutputT> create(String 
pCollectionId) {
-            // Outputs are queued here on harness threads and drained on the 
processing thread
-            // after the bundle closes.
+            // Outputs are queued here on harness threads, tagged with their 
output PCollection id,
+            // and drained on the processing thread after the bundle closes.
             return receivedElement -> {
               if (receivedElement != null) {
-                pendingOutputs.add((WindowedValue<?>) receivedElement);
+                pendingOutputs.add(
+                    new PendingOutput(pCollectionId, (WindowedValue<?>) 
receivedElement));
               }
             };
           }
@@ -249,12 +268,20 @@ class ExecutableStageProcessor
     }
     ProcessorContext<byte[], KStreamsPayload<?>> ctx = 
checkInitialized(context);
     // The harness has finished the bundle (close() returned) so no further 
enqueues happen.
-    // Drain via poll() so each element is removed as it is forwarded.
-    WindowedValue<?> output;
+    // Drain via poll() so each element is removed as it is forwarded. Each 
output is routed to its
+    // own output's relay child for a multi-output stage; a single-output 
stage forwards directly to
+    // its one downstream (empty routing map).
+    PendingOutput output;
     while ((output = pendingOutputs.poll()) != null) {
-      ctx.forward(
+      Record<byte[], KStreamsPayload<?>> outputRecord =
           new Record<byte[], KStreamsPayload<?>>(
-              record.key(), KStreamsPayload.data(output), record.timestamp()));
+              record.key(), KStreamsPayload.data(output.value), 
record.timestamp());
+      String childNode = outputChildByPCollectionId.get(output.pCollectionId);
+      if (childNode == null) {
+        ctx.forward(outputRecord);
+      } else {
+        ctx.forward(outputRecord, childNode);
+      }
     }
   }
 
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageTranslator.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageTranslator.java
index 59c233a78bb..71e24f32c1f 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageTranslator.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageTranslator.java
@@ -18,9 +18,13 @@
 package org.apache.beam.runners.kafka.streams.translation;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 import org.apache.beam.model.pipeline.v1.RunnerApi;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
-import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import org.apache.kafka.streams.Topology;
 
 /**
@@ -66,23 +70,28 @@ class ExecutableStageTranslator implements 
PTransformTranslator {
               + " uses user state or timers; stateful ParDo is not yet 
supported by the Kafka"
               + " Streams runner.");
     }
-    if (transform.getOutputsMap().size() > 1) {
-      // Multi-output stages (DoFns with side outputs, etc.) are a planned 
follow-up — they need
-      // an output-tag dispatch in the processor + per-output PCollection 
routing. The current
-      // rejection just fails loudly until that's wired in.
-      throw new UnsupportedOperationException(
-          "ExecutableStage "
-              + transformId
-              + " has "
-              + transform.getOutputsMap().size()
-              + " outputs; multi-output stages are not yet supported by the 
Kafka Streams runner.");
-    }
-
     // The payload distinguishes the main input from side inputs, so reading 
it from the payload
     // is unambiguous even before we add side-input support.
     String inputPCollectionId = stagePayload.getInput();
     String parentProcessor = 
context.getProcessorNameForPCollection(inputPCollectionId);
 
+    // A multi-output stage (a DoFn with side outputs, or a Read whose SDF 
wrapper produces several
+    // outputs) needs each output routed to the right downstream. Since 
downstream transforms are
+    // wired to a producer node by PCollection id, and that node must exist 
when the stage is
+    // translated, give each output its own relay node (StageOutputProcessor) 
and route to it by
+    // name. A single-output stage needs none of this — it forwards to its one 
downstream directly
+    // and registers itself as that output's producer. Outputs are sorted so 
the routing is
+    // deterministic across topology builds.
+    List<String> outputPCollectionIds = new 
ArrayList<>(transform.getOutputsMap().values());
+    Collections.sort(outputPCollectionIds);
+    boolean multiOutput = outputPCollectionIds.size() > 1;
+    Map<String, String> outputChildByPCollectionId = new LinkedHashMap<>();
+    if (multiOutput) {
+      for (int i = 0; i < outputPCollectionIds.size(); i++) {
+        outputChildByPCollectionId.put(outputPCollectionIds.get(i), 
transformId + "-output-" + i);
+      }
+    }
+
     Topology topology = context.getTopology();
     // The stage stamps its own transform id on the watermarks it emits, and 
aggregates its input
     // watermark from the reports of its single upstream transform (the 
producer of its input
@@ -96,12 +105,21 @@ class ExecutableStageTranslator implements 
PTransformTranslator {
                 context.getJobInfo(),
                 transformId,
                 ImmutableSet.of(parentProcessor),
-                
context.getMetricsContainerStepMap().getContainer(transformId)),
+                context.getMetricsContainerStepMap().getContainer(transformId),
+                outputChildByPCollectionId),
         parentProcessor);
 
-    if (!transform.getOutputsMap().isEmpty()) {
-      String outputPCollectionId = 
Iterables.getOnlyElement(transform.getOutputsMap().values());
-      context.registerPCollectionProducer(outputPCollectionId, transformId);
+    if (multiOutput) {
+      // One relay per output; downstream transforms wire to the relay, which 
re-stamps the stage's
+      // watermark with the relay's own id so their watermark aggregation 
stays consistent.
+      outputChildByPCollectionId.forEach(
+          (outputPCollectionId, relayName) -> {
+            topology.addProcessor(
+                relayName, () -> new StageOutputProcessor(relayName), 
transformId);
+            context.registerPCollectionProducer(outputPCollectionId, 
relayName);
+          });
+    } else if (!outputPCollectionIds.isEmpty()) {
+      context.registerPCollectionProducer(outputPCollectionIds.get(0), 
transformId);
     }
   }
 }
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessor.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessor.java
new file mode 100644
index 00000000000..eec3f2bae08
--- /dev/null
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessor.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams.translation;
+
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One output port of a multi-output {@link ExecutableStageProcessor}: a relay 
node that stands in
+ * as the producer of one of the stage's output PCollections.
+ *
+ * <p>A Kafka Streams processor forwards a record either to all of its 
children or to one named
+ * child, but downstream transforms are wired to a <em>producer node</em> by 
PCollection id, and
+ * that node's identity has to be known when the stage is translated — before 
the downstream
+ * transforms are. So each output PCollection of a multi-output stage gets its 
own relay: the stage
+ * routes each harness output to the matching relay by name, and downstream 
transforms wire to the
+ * relay. (A single-output stage needs none of this and forwards directly.)
+ *
+ * <p>The relay forwards data records unchanged, and on the watermark it 
relabels <em>only</em> the
+ * transform id — keeping the stage instance's source partition and total 
partition count intact.
+ * This is a 1:1 pass-through of one stage task's watermark (relay task {@code 
i} sees stage task
+ * {@code i}), so preserving the partition identity is what lets a downstream 
aggregator both know
+ * the report comes from this output (the relay's id) and still infer how many 
parallel instances of
+ * the stage produced it. The relay does no aggregation of its own.
+ */
+class StageOutputProcessor
+    implements Processor<byte[], KStreamsPayload<?>, byte[], 
KStreamsPayload<?>> {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(StageOutputProcessor.class);
+
+  private final String transformId;
+  private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
+
+  StageOutputProcessor(String transformId) {
+    this.transformId = transformId;
+  }
+
+  @Override
+  public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
+    this.context = context;
+  }
+
+  @Override
+  public void process(Record<byte[], KStreamsPayload<?>> record) {
+    KStreamsPayload<?> payload = record.value();
+    ProcessorContext<byte[], KStreamsPayload<?>> ctx = context;
+    if (ctx == null) {
+      throw new IllegalStateException("StageOutputProcessor used before 
init()");
+    }
+    if (payload == null) {
+      LOG.warn(
+          "Stage output {} dropping record with null payload (external write 
or tombstone)",
+          transformId);
+      return;
+    }
+    if (!payload.isWatermark()) {
+      // Data for this output: forward unchanged.
+      ctx.forward(record);
+      return;
+    }
+    // Relabel the transform id to this output port's, but keep the reporting 
stage instance's
+    // partition and partition count so downstream can still infer the stage's 
parallelism.
+    WatermarkPayload report = payload.asWatermark();
+    ctx.forward(
+        new Record<byte[], KStreamsPayload<?>>(
+            record.key(),
+            KStreamsPayload.watermark(
+                report.getWatermarkMillis(),
+                transformId,
+                report.getSourcePartition(),
+                report.getTotalSourcePartitions()),
+            record.timestamp()));
+  }
+}
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/MultiOutputStageTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/MultiOutputStageTest.java
new file mode 100644
index 00000000000..7fe85d732f2
--- /dev/null
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/MultiOutputStageTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams;
+
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Count;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.junit.Rule;
+import org.junit.Test;
+
+/**
+ * Verifies that a fused executable stage with more than one output routes 
each output to its own
+ * downstream — including the case the mentor asked about, where each output 
is followed by a
+ * separate GroupByKey (so each output ends up in its own repartition topic).
+ *
+ * <p>A DoFn splits its input to a main and an additional (side) output. One 
output is grouped with
+ * {@code Count.perElement} (a GroupByKey under the hood, so that output lands 
in its own
+ * repartition topic); the other is asserted directly (a plain in-process 
output). A {@link PAssert}
+ * on each confirms the two outputs carried the right elements to the right 
downstream.
+ */
+public class MultiOutputStageTest {
+
+  private static final TupleTag<Integer> EVENS = new TupleTag<Integer>() {};
+  private static final TupleTag<Integer> ODDS = new TupleTag<Integer>() {};
+
+  /** Routes even elements to the main output and odd elements to the side 
output. */
+  private static class SplitByParityFn extends DoFn<Integer, Integer> {
+    @ProcessElement
+    public void processElement(@Element Integer input, MultiOutputReceiver 
out) {
+      if (input % 2 == 0) {
+        out.get(EVENS).output(input);
+      } else {
+        out.get(ODDS).output(input);
+      }
+    }
+  }
+
+  private static PipelineOptions options() {
+    PipelineOptions options = PipelineOptionsFactory.create();
+    options.setRunner(TestKafkaStreamsRunner.class);
+    return options;
+  }
+
+  @Rule public final transient TestPipeline pipeline = 
TestPipeline.fromOptions(options());
+
+  @Test
+  public void eachStageOutputFeedsItsOwnGroupByKey() {
+    PCollectionTuple split =
+        pipeline
+            .apply("create", Create.of(1, 2, 3, 4, 5, 6))
+            .apply(
+                "split",
+                ParDo.of(new SplitByParityFn()).withOutputTags(EVENS, 
TupleTagList.of(ODDS)));
+
+    // The evens branch goes through a GroupByKey (Count.perElement), so that 
output lands in its
+    // own repartition topic; the odds branch is asserted directly, exercising 
a plain output.
+    PAssert.that(split.get(EVENS).apply("countEvens", Count.perElement()))
+        .containsInAnyOrder(KV.of(2, 1L), KV.of(4, 1L), KV.of(6, 1L));
+    PAssert.that(split.get(ODDS)).containsInAnyOrder(1, 3, 5);
+
+    pipeline.run();
+  }
+}
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
index de3a23911ad..010d98d52e8 100644
--- 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
@@ -26,6 +26,7 @@ import 
org.apache.beam.runners.fnexecution.provisioning.JobInfo;
 import org.apache.beam.sdk.options.PipelineOptionsFactory;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
 import org.apache.kafka.streams.processor.api.MockProcessorContext;
 import org.apache.kafka.streams.processor.api.Record;
@@ -59,7 +60,9 @@ public class ExecutableStageProcessorWatermarkTest {
         jobInfo,
         STAGE_ID,
         ImmutableSet.of(UPSTREAM_ID),
-        new MetricsContainerImpl(STAGE_ID));
+        new MetricsContainerImpl(STAGE_ID),
+        // Single-output: no per-output routing (this test drives the 
watermark path directly).
+        ImmutableMap.of());
   }
 
   /** A report from the upstream transform's given partition. */
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessorTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessorTest.java
new file mode 100644
index 00000000000..83653f13c89
--- /dev/null
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StageOutputProcessorTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams.translation;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.List;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.sdk.values.WindowedValues;
+import org.apache.kafka.streams.processor.api.MockProcessorContext;
+import 
org.apache.kafka.streams.processor.api.MockProcessorContext.CapturedForward;
+import org.apache.kafka.streams.processor.api.Record;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link StageOutputProcessor}, the per-output relay of a 
multi-output stage: it
+ * forwards data unchanged and relabels the watermark's transform id while 
preserving the reporting
+ * stage instance's partition and partition count.
+ *
+ * <p>The partition preservation is what makes the watermark propagate 
correctly once the multi-
+ * output stage runs in several parallel instances (per je-ik's review): each 
stage task's report
+ * must reach downstream carrying its own {@code (partition, 
totalPartitions)}, so the downstream
+ * aggregator can tell how many parallel instances produced the output. A 
{@link
+ * MockProcessorContext} lets the reports from different stage partitions be 
fed directly, which a
+ * single-instance {@code TopologyTestDriver} cannot do.
+ */
+public class StageOutputProcessorTest {
+
+  private static final String RELAY_ID = "stage-output-0";
+  private static final String STAGE_ID = "stage";
+
+  private static Record<byte[], KStreamsPayload<?>> watermark(
+      long millis, int sourcePartition, int totalPartitions) {
+    return new Record<>(
+        new byte[0],
+        KStreamsPayload.watermark(millis, STAGE_ID, sourcePartition, 
totalPartitions),
+        0L);
+  }
+
+  @Test
+  public void watermarkKeepsPartitionIdentityAndRelabelsTransformId() {
+    MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new 
MockProcessorContext<>();
+    StageOutputProcessor processor = new StageOutputProcessor(RELAY_ID);
+    processor.init(ctx);
+
+    // A report from partition 1 of a 3-instance stage.
+    processor.process(watermark(500L, 1, 3));
+
+    List<CapturedForward<? extends byte[], ? extends KStreamsPayload<?>>> 
forwarded =
+        ctx.forwarded();
+    assertThat(forwarded.size(), is(1));
+    WatermarkPayload out = forwarded.get(0).record().value().asWatermark();
+    assertThat(out.getWatermarkMillis(), is(500L));
+    // Relabeled to the relay's id, but the stage instance's partition and 
count are preserved.
+    assertThat(out.getTransformId(), is(RELAY_ID));
+    assertThat(out.getSourcePartition(), is(1));
+    assertThat(out.getTotalSourcePartitions(), is(3));
+  }
+
+  @Test
+  public void distinctStagePartitionsStayDistinctDownstream() {
+    MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new 
MockProcessorContext<>();
+    StageOutputProcessor processor = new StageOutputProcessor(RELAY_ID);
+    processor.init(ctx);
+
+    processor.process(watermark(100L, 0, 3));
+    processor.process(watermark(200L, 2, 3));
+
+    List<CapturedForward<? extends byte[], ? extends KStreamsPayload<?>>> 
forwarded =
+        ctx.forwarded();
+    assertThat(forwarded.size(), is(2));
+    
assertThat(forwarded.get(0).record().value().asWatermark().getSourcePartition(),
 is(0));
+    
assertThat(forwarded.get(1).record().value().asWatermark().getSourcePartition(),
 is(2));
+    
assertThat(forwarded.get(0).record().value().asWatermark().getTotalSourcePartitions(),
 is(3));
+  }
+
+  @Test
+  public void dataIsForwardedUnchanged() {
+    MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new 
MockProcessorContext<>();
+    StageOutputProcessor processor = new StageOutputProcessor(RELAY_ID);
+    processor.init(ctx);
+
+    WindowedValue<byte[]> element = WindowedValues.valueInGlobalWindow(new 
byte[] {7});
+    KStreamsPayload<byte[]> data = KStreamsPayload.data(element);
+    processor.process(new Record<>(new byte[0], data, 0L));
+
+    List<CapturedForward<? extends byte[], ? extends KStreamsPayload<?>>> 
forwarded =
+        ctx.forwarded();
+    assertThat(forwarded.size(), is(1));
+    assertThat(forwarded.get(0).record().value(), is(data));
+  }
+}

Reply via email to