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 1058e94027e [GSoC 2026] Kafka Streams runner #38987: Wire
WatermarkManager into ExecutableStageProcessor
1058e94027e is described below
commit 1058e94027e5925a6324c0cbd9f3013a5a870218
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Thu Jun 18 16:30:35 2026 +0500
[GSoC 2026] Kafka Streams runner #38987: Wire WatermarkManager into
ExecutableStageProcessor
---
.../translation/ExecutableStageProcessor.java | 62 ++++++---
.../streams/translation/ImpulseProcessor.java | 10 +-
.../kafka/streams/translation/KStreamsPayload.java | 77 ++++++++--
.../streams/translation/WatermarkPayload.java | 41 ++++++
.../kafka/streams/KafkaStreamsRunnerTest.java | 2 +-
.../ExecutableStageProcessorWatermarkTest.java | 124 +++++++++++++++++
.../streams/translation/ImpulseTranslatorTest.java | 3 +-
.../translation/WatermarkPropagationTest.java | 155 +++++++++++++++++++++
8 files changed, 440 insertions(+), 34 deletions(-)
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 b8eb5413b27..00f85032d04 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
@@ -28,6 +28,7 @@ import
org.apache.beam.runners.fnexecution.control.StageBundleFactory;
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
import org.apache.beam.runners.fnexecution.state.StateRequestHandler;
import org.apache.beam.sdk.fn.data.FnDataReceiver;
+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.guava.v32_1_2_jre.com.google.common.collect.Iterables;
@@ -35,6 +36,7 @@ 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.joda.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -49,9 +51,12 @@ import org.slf4j.LoggerFactory;
* ProcessorContext#forward} must only be called from the processing thread,
so outputs are never
* forwarded directly from a harness callback.
*
- * <p>A {@link KStreamsPayload#isWatermark() watermark} payload marks a bundle
boundary: the open
- * bundle (if any) is closed (flushing outputs), and the watermark is then
forwarded downstream so
- * that subsequent stages observe it after all data of the bundle.
+ * <p>A {@link KStreamsPayload#isWatermark() watermark} payload is a
per-source-partition report and
+ * marks a bundle boundary: the open bundle (if any) is closed (flushing
outputs), the report is fed
+ * to the {@link WatermarkManager}, and the stage's output watermark is
forwarded downstream only
+ * when the {@code min()} across its source partitions actually advances.
Until every source
+ * partition has reported, the watermark is held and nothing is forwarded —
but data is still
+ * processed in the meantime.
*
* <p>This is the Kafka Streams analogue of Flink's {@code
ExecutableStageDoFnOperator} and Spark's
* {@code SparkExecutableStageFunction}. State, timers, and side inputs are
out of scope for this
@@ -74,6 +79,12 @@ class ExecutableStageProcessor
// only safe because the Impulse output coder happens to be ByteArrayCoder.
private final Queue<WindowedValue<?>> pendingOutputs = new
ConcurrentLinkedQueue<>();
+ // Computes this stage's output watermark as min() over its source
partitions' reported
+ // watermarks, holding until every source partition has reported (see
WatermarkManager).
+ private final WatermarkManager watermarkManager = new WatermarkManager();
+ // The last watermark actually forwarded downstream, so we only forward when
it advances.
+ private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
+
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
private @Nullable ExecutableStageContext stageContext;
private @Nullable StageBundleFactory stageBundleFactory;
@@ -87,22 +98,40 @@ class ExecutableStageProcessor
@Override
public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
this.context = context;
+ // The SDK harness (stage context + bundle factory) is created lazily on
the first data
+ // element, so a stage that only forwards watermarks never spins one up.
This mirrors Spark's
+ // SparkExecutableStageFunction, which likewise does not build a bundle
factory when there are
+ // no inputs to process.
+ }
+
+ private void ensureStageBundleFactory() {
+ if (stageBundleFactory != null) {
+ return;
+ }
ExecutableStage executableStage =
ExecutableStage.fromPayload(stagePayload);
- this.stageContext =
KafkaStreamsExecutableStageContextFactory.getInstance().get(jobInfo);
- this.stageBundleFactory =
stageContext.getStageBundleFactory(executableStage);
+ stageContext =
KafkaStreamsExecutableStageContextFactory.getInstance().get(jobInfo);
+ stageBundleFactory = stageContext.getStageBundleFactory(executableStage);
}
@Override
public void process(Record<byte[], KStreamsPayload<?>> record) {
KStreamsPayload<?> payload = record.value();
if (payload.isWatermark()) {
- // NOTE: flushing the bundle on every received watermark is provisional.
Once the
- // WatermarkManager lands, a stage will receive watermarks from multiple
parent instances and
- // the output watermark becomes min() across them — the bundle should
flush / the output
- // watermark advance only when that minimum actually moves forward, not
on every received
- // watermark. Tracked in #38743.
+ // Emit any buffered outputs before the watermark. Data is processed
regardless of watermark
+ // readiness; only the watermark itself is held until every source
partition has reported.
closeBundleAndFlush(record);
- forwardWatermark(record, payload.getWatermarkMillis());
+ // Feed the report into the WatermarkManager and forward the stage's
output watermark only
+ // when min() across the source partitions actually advances, not on
every received watermark.
+ WatermarkPayload report = payload.asWatermark();
+ watermarkManager.observe(
+ report.getSourcePartition(),
+ new Instant(report.getWatermarkMillis()),
+ report.getTotalSourcePartitions());
+ Instant advanced = watermarkManager.advance();
+ if (advanced.isAfter(lastForwardedWatermark)) {
+ lastForwardedWatermark = advanced;
+ forwardWatermark(record, advanced.getMillis());
+ }
return;
}
try {
@@ -117,6 +146,7 @@ class ExecutableStageProcessor
if (currentBundle != null) {
return;
}
+ ensureStageBundleFactory();
StageBundleFactory factory = checkInitialized(stageBundleFactory);
OutputReceiverFactory outputReceiverFactory =
new OutputReceiverFactory() {
@@ -173,14 +203,14 @@ class ExecutableStageProcessor
}
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> record,
long watermarkMillis) {
- // TODO(#38743 / WatermarkManager): a watermark must reach every parallel
instance of every
- // downstream processor, but ctx.forward routes to one downstream
partition per Kafka Streams'
- // partitioning. The simplest correct approach is to fan the watermark out
to all downstream
- // partitions; that wiring lands with the WatermarkManager sub-issue (per
Jan on PR #38764).
+ // This stage is a single instance for now, so it forwards its watermark
as the only source
+ // partition (0 of 1). Fanning the watermark out to every downstream
partition — and producing
+ // it atomically with the offset commit so it is durable — lands with the
topic-based shuffle
+ // work, when there are real source partitions to track (#18479).
ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
- record.key(), KStreamsPayload.watermark(watermarkMillis),
record.timestamp()));
+ record.key(), KStreamsPayload.watermark(watermarkMillis, 0, 1),
record.timestamp()));
}
@Override
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
index e9fc8ddb36a..675bee4d859 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
@@ -121,12 +121,18 @@ class ImpulseProcessor implements Processor<byte[],
byte[], byte[], KStreamsPayl
LOG.debug("Impulse {} emitted single element and terminal watermark",
transformId);
}
- /** Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors. */
+ /**
+ * Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors.
+ *
+ * <p>Impulse is a single-instance source, so the report is stamped as the
only source partition:
+ * {@code sourcePartition=0} of {@code totalSourcePartitions=1}. Real
per-partition identities
+ * arrive once the topology gains topic-based shuffle.
+ */
private static void forwardWatermarkMax(ProcessorContext<byte[],
KStreamsPayload<byte[]>> ctx) {
long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
ctx.forward(
new Record<byte[], KStreamsPayload<byte[]>>(
- new byte[0], KStreamsPayload.watermark(maxMillis), 0L));
+ new byte[0], KStreamsPayload.watermark(maxMillis, 0, 1), 0L));
}
/** Cancels the wall-clock punctuator after the impulse has fired to stop
periodic wakeups. */
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
index 53e47b1216b..93b40346761 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
@@ -20,6 +20,7 @@ package org.apache.beam.runners.kafka.streams.translation;
import java.util.Objects;
import org.apache.beam.sdk.values.WindowedValue;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
import org.checkerframework.checker.nullness.qual.Nullable;
/**
@@ -29,7 +30,9 @@ import org.checkerframework.checker.nullness.qual.Nullable;
*
* <ul>
* <li>A {@link #isData() data} element wrapping a {@link WindowedValue}, or
- * <li>A {@link #isWatermark() watermark} signal carrying an event-time
milliseconds value.
+ * <li>A {@link #isWatermark() watermark} report carrying an event-time
milliseconds value plus
+ * the in-band coordination fields (source partition and total source
partition count) the
+ * downstream {@link WatermarkManager} needs.
* </ul>
*
* <p>The envelope lets a single Kafka Streams output channel carry both Beam
data and the watermark
@@ -54,21 +57,45 @@ public final class KStreamsPayload<T> {
private final Kind kind;
private final @Nullable WindowedValue<T> data;
private final long watermarkMillis;
+ private final int sourcePartition;
+ private final int totalSourcePartitions;
- private KStreamsPayload(Kind kind, @Nullable WindowedValue<T> data, long
watermarkMillis) {
+ private KStreamsPayload(
+ Kind kind,
+ @Nullable WindowedValue<T> data,
+ long watermarkMillis,
+ int sourcePartition,
+ int totalSourcePartitions) {
this.kind = kind;
this.data = data;
this.watermarkMillis = watermarkMillis;
+ this.sourcePartition = sourcePartition;
+ this.totalSourcePartitions = totalSourcePartitions;
}
/** Returns a data payload wrapping the given {@link WindowedValue}. */
public static <T> KStreamsPayload<T> data(WindowedValue<T> value) {
- return new KStreamsPayload<>(Kind.DATA, value, 0L);
+ return new KStreamsPayload<>(Kind.DATA, value, 0L, 0, 0);
}
- /** Returns a watermark payload carrying the given event-time milliseconds.
*/
- public static <T> KStreamsPayload<T> watermark(long watermarkMillis) {
- return new KStreamsPayload<>(Kind.WATERMARK, null, watermarkMillis);
+ /**
+ * Returns a watermark report payload: the event-time milliseconds together
with the in-band
+ * coordination fields the downstream stage's {@link WatermarkManager} needs
— which source
+ * partition this report is for and how many source partitions feed the
stage in total.
+ */
+ public static <T> KStreamsPayload<T> watermark(
+ long watermarkMillis, int sourcePartition, int totalSourcePartitions) {
+ Preconditions.checkArgument(
+ totalSourcePartitions > 0,
+ "totalSourcePartitions must be positive: %s",
+ totalSourcePartitions);
+ Preconditions.checkArgument(
+ sourcePartition >= 0 && sourcePartition < totalSourcePartitions,
+ "sourcePartition %s out of range for totalSourcePartitions %s",
+ sourcePartition,
+ totalSourcePartitions);
+ return new KStreamsPayload<>(
+ Kind.WATERMARK, null, watermarkMillis, sourcePartition,
totalSourcePartitions);
}
public boolean isData() {
@@ -91,14 +118,31 @@ public final class KStreamsPayload<T> {
}
/**
- * Returns the watermark event-time milliseconds. Caller must check {@link
#isWatermark()} first;
- * calling this on a data payload throws.
+ * Narrows this payload to its {@link WatermarkPayload} view, through which
the watermark report
+ * fields are read. Caller must check {@link #isWatermark()} first; calling
this on a data payload
+ * throws.
*/
- public long getWatermarkMillis() {
- if (kind != Kind.WATERMARK) {
- throw new IllegalStateException("Payload is not a watermark: kind=" +
kind);
+ public WatermarkPayload asWatermark() {
+ Preconditions.checkState(isWatermark(), "Payload is not a watermark:
kind=%s", kind);
+ return new WatermarkView();
+ }
+
+ /** {@link WatermarkPayload} view backed by this payload's fields. */
+ private final class WatermarkView implements WatermarkPayload {
+ @Override
+ public long getWatermarkMillis() {
+ return watermarkMillis;
+ }
+
+ @Override
+ public int getSourcePartition() {
+ return sourcePartition;
+ }
+
+ @Override
+ public int getTotalSourcePartitions() {
+ return totalSourcePartitions;
}
- return watermarkMillis;
}
@Override
@@ -112,12 +156,14 @@ public final class KStreamsPayload<T> {
KStreamsPayload<?> that = (KStreamsPayload<?>) o;
return kind == that.kind
&& watermarkMillis == that.watermarkMillis
+ && sourcePartition == that.sourcePartition
+ && totalSourcePartitions == that.totalSourcePartitions
&& Objects.equals(data, that.data);
}
@Override
public int hashCode() {
- return Objects.hash(kind, data, watermarkMillis);
+ return Objects.hash(kind, data, watermarkMillis, sourcePartition,
totalSourcePartitions);
}
@Override
@@ -126,7 +172,10 @@ public final class KStreamsPayload<T> {
if (kind == Kind.DATA) {
helper.add("data", data);
} else {
- helper.add("watermarkMillis", watermarkMillis);
+ helper
+ .add("watermarkMillis", watermarkMillis)
+ .add("sourcePartition", sourcePartition)
+ .add("totalSourcePartitions", totalSourcePartitions);
}
return helper.toString();
}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPayload.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPayload.java
new file mode 100644
index 00000000000..194be701ece
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPayload.java
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+/**
+ * The watermark-only view of a {@link KStreamsPayload}, obtained via {@link
+ * KStreamsPayload#asWatermark()}. Keeping the watermark accessors on this
interface — rather than
+ * on {@link KStreamsPayload} itself — means they are only reachable after the
caller has checked
+ * {@link KStreamsPayload#isWatermark()} and narrowed the payload, so there is
no kind check to do
+ * on each accessor.
+ *
+ * <p>A watermark report is the in-band coordination message a downstream
stage's {@link
+ * WatermarkManager} consumes: the watermark value plus which source partition
reported it and how
+ * many source partitions feed the stage in total.
+ */
+public interface WatermarkPayload {
+
+ /** The reported watermark, in event-time milliseconds. */
+ long getWatermarkMillis();
+
+ /** The source partition this report is for, in {@code [0,
getTotalSourcePartitions())}. */
+ int getSourcePartition();
+
+ /** The total number of source partitions feeding the downstream stage. */
+ int getTotalSourcePartitions();
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
index 30000d77d86..0c576d27122 100644
---
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
@@ -97,7 +97,7 @@ public class KafkaStreamsRunnerTest {
assertThat(capture.received.get(0).getData().getValue().length, is(0));
assertThat(capture.received.get(1).isWatermark(), is(true));
assertThat(
- capture.received.get(1).getWatermarkMillis(),
+ capture.received.get(1).asWatermark().getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
}
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
new file mode 100644
index 00000000000..fc5797a12c2
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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 org.apache.beam.model.pipeline.v1.RunnerApi;
+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.kafka.streams.processor.api.MockProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.junit.Test;
+
+/**
+ * Tests the watermark wiring of {@link ExecutableStageProcessor}: how it
feeds incoming watermark
+ * reports to the {@link WatermarkManager} and forwards the stage's output
watermark.
+ *
+ * <p>Only the watermark path is exercised, so the SDK harness is never
started (it is created
+ * lazily on the first data element). A {@link MockProcessorContext} captures
what the processor
+ * forwards downstream.
+ */
+public class ExecutableStageProcessorWatermarkTest {
+
+ private static ExecutableStageProcessor newProcessor() {
+ JobInfo jobInfo =
+ JobInfo.create(
+ "job-id",
+ "job-name",
+ "",
+
PipelineOptionsTranslation.toProto(PipelineOptionsFactory.create()));
+ return new ExecutableStageProcessor(
+ RunnerApi.ExecutableStagePayload.getDefaultInstance(), jobInfo);
+ }
+
+ private static Record<byte[], KStreamsPayload<?>> watermark(
+ long millis, int sourcePartition, int totalSourcePartitions) {
+ KStreamsPayload<?> payload =
+ KStreamsPayload.watermark(millis, sourcePartition,
totalSourcePartitions);
+ return new Record<>(new byte[0], payload, 0L);
+ }
+
+ private static KStreamsPayload<?> onlyForwarded(
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx) {
+ assertThat(ctx.forwarded().size(), is(1));
+ return ctx.forwarded().get(0).record().value();
+ }
+
+ @Test
+ public void singleSourcePartitionForwardsImmediatelyStampedAsItsOwnSource() {
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new
MockProcessorContext<>();
+ ExecutableStageProcessor processor = newProcessor();
+ processor.init(ctx);
+
+ processor.process(watermark(100L, 0, 1));
+
+ KStreamsPayload<?> out = onlyForwarded(ctx);
+ assertThat(out.isWatermark(), is(true));
+ WatermarkPayload report = out.asWatermark();
+ assertThat(report.getWatermarkMillis(), is(100L));
+ // The stage forwards as its own single source (0 of 1), not the
upstream's identity.
+ assertThat(report.getSourcePartition(), is(0));
+ assertThat(report.getTotalSourcePartitions(), is(1));
+ }
+
+ @Test
+ public void holdsUntilAllSourcePartitionsReportThenForwardsMin() {
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new
MockProcessorContext<>();
+ ExecutableStageProcessor processor = newProcessor();
+ processor.init(ctx);
+
+ processor.process(watermark(300L, 0, 3));
+ processor.process(watermark(100L, 1, 3));
+ // Two of three source partitions reported — still holding, nothing
forwarded.
+ assertThat(ctx.forwarded().isEmpty(), is(true));
+
+ processor.process(watermark(500L, 2, 3));
+ // All three reported — forward min(300, 100, 500) = 100.
+ assertThat(onlyForwarded(ctx).asWatermark().getWatermarkMillis(),
is(100L));
+ }
+
+ @Test
+ public void doesNotReforwardWhenWatermarkDoesNotAdvance() {
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new
MockProcessorContext<>();
+ ExecutableStageProcessor processor = newProcessor();
+ processor.init(ctx);
+
+ processor.process(watermark(100L, 0, 1));
+ assertThat(ctx.forwarded().size(), is(1));
+
+ // A repeated, non-advancing watermark must not be forwarded again.
+ processor.process(watermark(100L, 0, 1));
+ assertThat(ctx.forwarded().size(), is(1));
+ }
+
+ @Test
+ public void forwardsTerminalMaxWatermark() {
+ long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx = new
MockProcessorContext<>();
+ ExecutableStageProcessor processor = newProcessor();
+ processor.init(ctx);
+
+ processor.process(watermark(maxMillis, 0, 1));
+
+ assertThat(onlyForwarded(ctx).asWatermark().getWatermarkMillis(),
is(maxMillis));
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
index a092b10e02c..f15e818f43e 100644
---
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
@@ -77,7 +77,8 @@ public class ImpulseTranslatorTest {
KStreamsPayload<byte[]> watermarkPayload = capture.received.get(1);
assertThat(watermarkPayload.isWatermark(), is(true));
assertThat(
- watermarkPayload.getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
+ watermarkPayload.asWatermark().getWatermarkMillis(),
+ is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
}
@Test
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPropagationTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPropagationTest.java
new file mode 100644
index 00000000000..e4bdbe4d973
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkPropagationTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
+import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.options.PortablePipelineOptions;
+import org.apache.beam.sdk.testing.CrashingRunner;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.Impulse;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.util.construction.Environments;
+import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import org.apache.beam.sdk.util.construction.PipelineTranslation;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyDescription;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.Record;
+import org.junit.Test;
+
+/**
+ * End-to-end test that a watermark propagates through the topology: {@code
Impulse ->
+ * ExecutableStage -> a recording sink}. The Impulse source emits a terminal
{@code
+ * TIMESTAMP_MAX_VALUE} watermark report; the ExecutableStage routes it
through its {@link
+ * WatermarkManager} and forwards it on, stamped as its own single source
partition. A sink
+ * processor attached to the leaf captures the forwarded watermark and the
test asserts on it.
+ */
+public class WatermarkPropagationTest {
+
+ private static final String APPLICATION_ID = "ks-watermark-propagation-test";
+
+ /** Identity DoFn so the pipeline contains a fused ExecutableStage. */
+ private static class IdentityFn extends DoFn<byte[], byte[]> {
+ @ProcessElement
+ public void processElement(@Element byte[] input, OutputReceiver<byte[]>
out) {
+ out.output(input);
+ }
+ }
+
+ /** Sink processor that records the watermark payloads it is forwarded. */
+ private static final class WatermarkCapture
+ implements Processor<byte[], KStreamsPayload<?>, Void, Void> {
+ private final List<KStreamsPayload<?>> watermarks;
+
+ WatermarkCapture(List<KStreamsPayload<?>> watermarks) {
+ this.watermarks = watermarks;
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<?>> record) {
+ if (record.value().isWatermark()) {
+ watermarks.add(record.value());
+ }
+ }
+ }
+
+ @Test
+ public void terminalWatermarkPropagatesToDownstreamStampedAsSingleSource()
throws Exception {
+ Pipeline pipeline = Pipeline.create(pipelineOptions());
+ pipeline.apply("impulse", Impulse.create()).apply("identity", ParDo.of(new
IdentityFn()));
+
+ RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
+ KafkaStreamsPipelineOptions options =
+ pipeline.getOptions().as(KafkaStreamsPipelineOptions.class);
+ KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
+ JobInfo jobInfo =
+ JobInfo.create(
+ APPLICATION_ID, options.getJobName(), "",
PipelineOptionsTranslation.toProto(options));
+ KafkaStreamsTranslationContext context =
translator.createTranslationContext(jobInfo, options);
+ translator.translate(context,
translator.prepareForTranslation(pipelineProto));
+
+ // Attach a sink to the leaf ExecutableStage processor to capture the
watermark it forwards.
+ Topology topology = context.getTopology();
+ List<KStreamsPayload<?>> captured = new ArrayList<>();
+ topology.addProcessor(
+ "watermark-capture", () -> new WatermarkCapture(captured),
findLeafProcessor(topology));
+
+ try (TopologyTestDriver driver = new TopologyTestDriver(topology,
streamsConfig())) {
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ }
+
+ assertThat("a watermark reached the downstream sink", captured.isEmpty(),
is(false));
+ WatermarkPayload terminal = captured.get(captured.size() -
1).asWatermark();
+ assertThat(terminal.getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
+ assertThat(terminal.getSourcePartition(), is(0));
+ assertThat(terminal.getTotalSourcePartitions(), is(1));
+ }
+
+ /**
+ * Returns the name of the single processor node with no successors (the
leaf of the topology).
+ */
+ private static String findLeafProcessor(Topology topology) {
+ for (TopologyDescription.Subtopology subtopology :
topology.describe().subtopologies()) {
+ for (TopologyDescription.Node node : subtopology.nodes()) {
+ if (node instanceof TopologyDescription.Processor &&
node.successors().isEmpty()) {
+ return node.name();
+ }
+ }
+ }
+ throw new IllegalStateException("no leaf processor found in topology");
+ }
+
+ private static PipelineOptions pipelineOptions() {
+ PipelineOptions options =
+ PipelineOptionsFactory.fromArgs("--applicationId=" +
APPLICATION_ID).create();
+ options.setRunner(CrashingRunner.class);
+
options.as(KafkaStreamsPipelineOptions.class).setApplicationId(APPLICATION_ID);
+ options
+ .as(PortablePipelineOptions.class)
+ .setDefaultEnvironmentType(Environments.ENVIRONMENT_EMBEDDED);
+ return options;
+ }
+
+ private static Properties streamsConfig() {
+ Properties props = new Properties();
+ props.put(StreamsConfig.APPLICATION_ID_CONFIG, APPLICATION_ID);
+ props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
+ props.put(
+ StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ props.put(
+ StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ return props;
+ }
+}