1818package org .apache .beam .runners .kafka .streams .translation ;
1919
2020import java .util .Queue ;
21+ import java .util .Set ;
2122import java .util .concurrent .ConcurrentLinkedQueue ;
2223import org .apache .beam .model .pipeline .v1 .RunnerApi ;
2324import org .apache .beam .runners .fnexecution .control .BundleProgressHandler ;
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
0 commit comments