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 ff323ebd6db [GSoC 2026] Kafka Streams runner #39273: Kafka Streams
runner: Flatten support
ff323ebd6db is described below
commit ff323ebd6dbcb47fa1dd11c300aad721527bd0b8
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Wed Jul 15 15:19:13 2026 +0500
[GSoC 2026] Kafka Streams runner #39273: Kafka Streams runner: Flatten
support
---
.../src/main/proto/kafka_streams_payload.proto | 14 +-
.../translation/ExecutableStageProcessor.java | 66 ++++---
.../translation/ExecutableStageTranslator.java | 8 +-
.../streams/translation/FlattenProcessor.java | 119 ++++++++++++
.../streams/translation/FlattenTranslator.java | 82 ++++++++
.../streams/translation/GroupByKeyProcessor.java | 56 ++++--
.../streams/translation/GroupByKeyTranslator.java | 13 +-
.../streams/translation/ImpulseProcessor.java | 13 +-
.../kafka/streams/translation/KStreamsPayload.java | 27 ++-
.../streams/translation/KStreamsPayloadSerde.java | 2 +
.../KafkaStreamsPipelineTranslator.java | 1 +
.../kafka/streams/translation/ReadProcessor.java | 13 +-
.../streams/translation/ShuffleByKeyProcessor.java | 10 +
.../streams/translation/WatermarkAggregator.java | 107 +++++++++++
.../streams/translation/WatermarkPayload.java | 18 +-
.../ExecutableStageProcessorWatermarkTest.java | 21 +-
.../kafka/streams/translation/FlattenTest.java | 212 +++++++++++++++++++++
.../translation/KStreamsPayloadSerdeTest.java | 7 +-
.../translation/WatermarkAggregatorTest.java | 160 ++++++++++++++++
19 files changed, 873 insertions(+), 76 deletions(-)
diff --git
a/runners/kafka-streams/proto/src/main/proto/kafka_streams_payload.proto
b/runners/kafka-streams/proto/src/main/proto/kafka_streams_payload.proto
index 87e3de44397..4dadf2d14b7 100644
--- a/runners/kafka-streams/proto/src/main/proto/kafka_streams_payload.proto
+++ b/runners/kafka-streams/proto/src/main/proto/kafka_streams_payload.proto
@@ -27,16 +27,22 @@ option java_outer_classname = "KafkaStreamsPayloadProtos";
// topic boundary (e.g. the GroupByKey repartition topic and the watermark
fan-out). Protobuf is
// used for compatible schema evolution and compact varint encoding.
message KafkaStreamsPayload {
- // A watermark report: the watermark plus the in-band coordination fields
the downstream
- // WatermarkManager needs.
+ // A watermark report: the watermark plus the in-band coordination fields a
downstream
+ // watermark aggregator needs to reconstruct its input watermark.
message WatermarkPayload {
// Event-time watermark in milliseconds. Signed (sint64, zigzag-encoded)
because Beam event
// times can be negative, e.g. BoundedWindow.TIMESTAMP_MIN_VALUE.
sint64 millis = 1;
- // The source partition this report is for.
+ // Which partition (physical instance) of the producing transform this
report is for, in
+ // [0, total_partitions).
uint32 source_partition = 2;
- // The total number of source partitions feeding the downstream stage.
+ // How many partitions (physical instances) the producing transform has in
total.
uint32 total_partitions = 3;
+ // Globally unique id of the transform that produced this report. A
producer stamps its own id
+ // without regard to who consumes the report; a consumer with several
upstream transforms
+ // (e.g. Flatten) aggregates per producing transform, holding its output
watermark until every
+ // partition of every upstream transform it expects has reported.
+ string transform_id = 4;
}
// A data element: the Beam WindowedValue encoded with the PCollection's
windowed-value coder.
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 00f85032d04..38d3601e814 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
@@ -18,6 +18,7 @@
package org.apache.beam.runners.kafka.streams.translation;
import java.util.Queue;
+import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.apache.beam.model.pipeline.v1.RunnerApi;
import org.apache.beam.runners.fnexecution.control.BundleProgressHandler;
@@ -51,12 +52,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 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>A {@link KStreamsPayload#isWatermark() watermark} payload is a report
from one partition of
+ * one upstream transform and marks a bundle boundary: the open bundle (if
any) is closed (flushing
+ * outputs), the report is fed to the {@link WatermarkAggregator}, and the
stage's output watermark
+ * is forwarded downstream — stamped with this stage's own transform id — only
when the aggregate
+ * across the upstream transform's partitions actually advances. Until every
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
@@ -70,6 +71,9 @@ class ExecutableStageProcessor
private final RunnerApi.ExecutableStagePayload stagePayload;
private final JobInfo jobInfo;
+ // This stage's own transform id, stamped on every watermark it forwards so
downstream watermark
+ // aggregators know which transform the report came from — regardless of who
consumes it.
+ private final String transformId;
// 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.
@@ -79,9 +83,9 @@ 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();
+ // Computes this stage's input watermark from its upstream transform's
reports, holding until
+ // every partition of the upstream transform has reported (see
WatermarkAggregator).
+ private final WatermarkAggregator watermarkAggregator;
// The last watermark actually forwarded downstream, so we only forward when
it advances.
private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
@@ -90,9 +94,20 @@ class ExecutableStageProcessor
private @Nullable StageBundleFactory stageBundleFactory;
private @Nullable RemoteBundle currentBundle;
- ExecutableStageProcessor(RunnerApi.ExecutableStagePayload stagePayload,
JobInfo jobInfo) {
+ /**
+ * @param transformId this stage's own transform id, stamped on the
watermarks it emits
+ * @param upstreamTransformIds the transform ids feeding this stage (known
from the pipeline
+ * graph), whose reports the {@link WatermarkAggregator} waits for
+ */
+ ExecutableStageProcessor(
+ RunnerApi.ExecutableStagePayload stagePayload,
+ JobInfo jobInfo,
+ String transformId,
+ Set<String> upstreamTransformIds) {
this.stagePayload = stagePayload;
this.jobInfo = jobInfo;
+ this.transformId = transformId;
+ this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
}
@Override
@@ -116,18 +131,21 @@ class ExecutableStageProcessor
@Override
public void process(Record<byte[], KStreamsPayload<?>> record) {
KStreamsPayload<?> payload = record.value();
+ if (payload == null) {
+ // A topic feeding the runner can always be written to from outside (or
carry a tombstone),
+ // so recover from the obvious error instead of crashing the task: warn
and drop.
+ LOG.warn(
+ "Stage {} dropping record with null payload (external write or
tombstone)", transformId);
+ return;
+ }
if (payload.isWatermark()) {
// 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);
- // 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();
+ // Feed the report into the aggregator and forward the stage's output
watermark only when the
+ // aggregate across the upstream transform's partitions actually
advances.
+ watermarkAggregator.observe(payload.asWatermark());
+ Instant advanced = watermarkAggregator.advance();
if (advanced.isAfter(lastForwardedWatermark)) {
lastForwardedWatermark = advanced;
forwardWatermark(record, advanced.getMillis());
@@ -203,14 +221,16 @@ class ExecutableStageProcessor
}
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> record,
long watermarkMillis) {
- // 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).
+ // Stamped with this stage's own transform id; this stage is a single
instance for now, so the
+ // report is for its only 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 (#18479).
ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
- record.key(), KStreamsPayload.watermark(watermarkMillis, 0, 1),
record.timestamp()));
+ record.key(),
+ KStreamsPayload.watermark(watermarkMillis, transformId, 0, 1),
+ record.timestamp()));
}
@Override
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 dc56d57f57c..a2e6ed837c7 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
@@ -19,6 +19,7 @@ package org.apache.beam.runners.kafka.streams.translation;
import java.io.IOException;
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;
@@ -83,9 +84,14 @@ class ExecutableStageTranslator implements
PTransformTranslator {
String parentProcessor =
context.getProcessorNameForPCollection(inputPCollectionId);
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
+ // PCollection, whose node name is the upstream transform id).
topology.addProcessor(
transformId,
- () -> new ExecutableStageProcessor(stagePayload, context.getJobInfo()),
+ () ->
+ new ExecutableStageProcessor(
+ stagePayload, context.getJobInfo(), transformId,
ImmutableSet.of(parentProcessor)),
parentProcessor);
if (!transform.getOutputsMap().isEmpty()) {
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenProcessor.java
new file mode 100644
index 00000000000..d09fed8185a
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenProcessor.java
@@ -0,0 +1,119 @@
+/*
+ * 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 java.util.Set;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+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;
+
+/**
+ * Kafka Streams {@link Processor} implementing Beam's {@code Flatten}
primitive ({@code
+ * beam:transform:flatten:v1}): the union of N input PCollections into one
output PCollection.
+ *
+ * <p><b>Data</b> records are forwarded straight through unchanged — the merge
of the N parents'
+ * data streams <em>is</em> the flatten.
+ *
+ * <p><b>Watermark</b> reports are where Flatten does real work, and it owns
its output watermark
+ * the same way GroupByKey does: it runs a {@link WatermarkAggregator} over
its inputs, forwards its
+ * own watermark only when the {@code min()} across them advances, and stamps
that as a single
+ * source ({@code 0 of 1}) to its downstream. This holds the output watermark
back until
+ * <em>every</em> input branch has reported, so a downstream GroupByKey does
not fire before all
+ * flattened branches are drained.
+ *
+ * <p>The {@link WatermarkAggregator} tells the input branches apart by the
transform id each
+ * branch's producer stamps on its watermark (Kafka Streams does not tell a
processor which parent
+ * forwarded a record). Each producer stamps its own identity regardless of
who consumes it, so a
+ * PCollection feeding several Flattens reports one identity and every Flatten
still waits only for
+ * the upstream transforms it expects — the set handed to it at construction
from the pipeline
+ * graph.
+ */
+class FlattenProcessor
+ implements Processor<byte[], KStreamsPayload<?>, byte[],
KStreamsPayload<?>> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(FlattenProcessor.class);
+
+ // This transform's own id, stamped on every watermark it forwards
downstream.
+ private final String transformId;
+ // Computes the output watermark as min() over the upstream transforms'
reports, holding until
+ // every partition of every expected upstream transform has reported (see
WatermarkAggregator).
+ private final WatermarkAggregator watermarkAggregator;
+ // 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;
+
+ /**
+ * @param transformId this Flatten's own transform id, stamped on the
watermarks it emits
+ * @param upstreamTransformIds the producers of this Flatten's input
PCollections (known from the
+ * pipeline graph), whose reports the {@link WatermarkAggregator} waits
for
+ */
+ FlattenProcessor(String transformId, Set<String> upstreamTransformIds) {
+ this.transformId = transformId;
+ this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
+ }
+
+ @Override
+ public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
+ this.context = context;
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<?>> record) {
+ KStreamsPayload<?> payload = record.value();
+ if (payload == null) {
+ // A topic feeding the runner can always be written to from outside (or
carry a tombstone),
+ // so recover from the obvious error instead of crashing the task: warn
and drop.
+ LOG.warn(
+ "Flatten {} dropping record with null payload (external write or
tombstone)",
+ transformId);
+ return;
+ }
+ ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
+ if (!payload.isWatermark()) {
+ // Data: the union of the parents' data streams is the flatten — forward
unchanged.
+ ctx.forward(record);
+ return;
+ }
+ watermarkAggregator.observe(payload.asWatermark());
+ Instant advanced = watermarkAggregator.advance();
+ if (advanced.isAfter(lastForwardedWatermark)) {
+ lastForwardedWatermark = advanced;
+ // Stamped with this Flatten's own transform id; Flatten is a single
instance for now, so the
+ // report is for its only partition (0 of 1).
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<?>>(
+ record.key(),
+ KStreamsPayload.watermark(advanced.getMillis(), transformId, 0,
1),
+ record.timestamp()));
+ }
+ }
+
+ private static ProcessorContext<byte[], KStreamsPayload<?>> checkInitialized(
+ @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context) {
+ if (context == null) {
+ throw new IllegalStateException("FlattenProcessor used before init()");
+ }
+ return context;
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenTranslator.java
new file mode 100644
index 00000000000..8f6ce2b546d
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenTranslator.java
@@ -0,0 +1,82 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.kafka.streams.Topology;
+
+/**
+ * Translates Beam's {@code Flatten} primitive ({@code
beam:transform:flatten:v1}): the union of N
+ * input PCollections into one output PCollection.
+ *
+ * <p>Wires a single {@link FlattenProcessor} node to the producer of every
input PCollection (Kafka
+ * Streams lets a processor have many parents), so the parents' data streams
merge into it, and
+ * registers it as the producer of the flattened output so downstream
translators wire to it. The
+ * processor forwards data through and owns its output watermark via a {@link
WatermarkAggregator},
+ * which is handed the producers of the input PCollections — the upstream
transform ids whose
+ * watermark reports the Flatten must hear from. Producers stamp their own
transform id on the
+ * reports they emit, without regard to who consumes them, so an input shared
with another Flatten
+ * needs no special handling.
+ *
+ * <p>A user-written self-flatten never reaches this translator: the fuser
folds the Flatten into
+ * the consuming SDK-harness stage, which performs the duplication itself. The
duplicate-input check
+ * below is defensive — if a runner-executed Flatten ever did receive the same
PCollection twice,
+ * Kafka Streams could not wire the same parent to a child twice and the
duplicate copy would be
+ * silently dropped, so failing fast is safer.
+ */
+class FlattenTranslator implements PTransformTranslator {
+
+ @Override
+ public void translate(
+ String transformId, RunnerApi.Pipeline pipeline,
KafkaStreamsTranslationContext context) {
+ RunnerApi.PTransform transform =
pipeline.getComponents().getTransformsOrThrow(transformId);
+ // Flatten produces exactly one output PCollection, fed by all of its
input PCollections.
+ String outputPCollectionId =
Iterables.getOnlyElement(transform.getOutputsMap().values());
+
+ Set<String> seenInputs = new HashSet<>();
+ List<String> parentProcessors = new ArrayList<>();
+ Set<String> upstreamTransformIds = new HashSet<>();
+ for (String inputPCollectionId : transform.getInputsMap().values()) {
+ if (!seenInputs.add(inputPCollectionId)) {
+ throw new UnsupportedOperationException(
+ "Flatten "
+ + transform.getUniqueName()
+ + " has PCollection "
+ + inputPCollectionId
+ + " as an input more than once; a self-flatten is not yet
supported by the Kafka"
+ + " Streams runner.");
+ }
+ String parentProcessor =
context.getProcessorNameForPCollection(inputPCollectionId);
+ parentProcessors.add(parentProcessor);
+ upstreamTransformIds.add(parentProcessor);
+ }
+
+ Topology topology = context.getTopology();
+ topology.addProcessor(
+ transformId,
+ () -> new FlattenProcessor(transformId, upstreamTransformIds),
+ parentProcessors.toArray(new String[0]));
+
+ context.registerPCollectionProducer(outputPCollectionId, transformId);
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
index a6c0cb8c406..3e82935b807 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyProcessor.java
@@ -19,6 +19,7 @@ package org.apache.beam.runners.kafka.streams.translation;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import org.apache.beam.sdk.coders.Coder;
import org.apache.beam.sdk.coders.CoderException;
import org.apache.beam.sdk.coders.IterableCoder;
@@ -35,16 +36,18 @@ import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.joda.time.Instant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Executes a {@code GroupByKey} (GlobalWindow, default trigger, no allowed
lateness).
*
* <p>Records arrive on the repartition topic keyed by the encoded Beam key,
so every value of a key
* is co-located here. Each value is appended to a per-key buffer in a Kafka
Streams state store.
- * Watermark reports are fed to a {@link WatermarkManager}; when the input
watermark reaches {@link
- * BoundedWindow#TIMESTAMP_MAX_VALUE} (the end of the global window) every
buffered key is emitted
- * once as {@code KV<K, Iterable<V>>} and the buffer cleared, then the
watermark is forwarded
- * downstream.
+ * Watermark reports are fed to a {@link WatermarkAggregator}; when the input
watermark reaches
+ * {@link BoundedWindow#TIMESTAMP_MAX_VALUE} (the end of the global window)
every buffered key is
+ * emitted once as {@code KV<K, Iterable<V>>} and the buffer cleared, then the
watermark is
+ * forwarded downstream.
*
* <p>Buffering whole value lists and re-encoding on each append is O(n^2) per
key; fine for this
* first GroupByKey, and replaced when this moves to runner-core {@code
GroupAlsoByWindow}.
@@ -52,11 +55,18 @@ import org.joda.time.Instant;
class GroupByKeyProcessor
implements Processor<byte[], KStreamsPayload<?>, byte[],
KStreamsPayload<?>> {
+ private static final Logger LOG =
LoggerFactory.getLogger(GroupByKeyProcessor.class);
+
private final String stateStoreName;
+ // This transform's own id, stamped on every watermark it forwards
downstream.
+ private final String transformId;
private final Coder<Object> keyCoder;
private final IterableCoder<@Nullable Object> bufferCoder;
- private final WatermarkManager watermarkManager = new WatermarkManager();
+ // Aggregates the input watermark from the upstream transform's reports,
which arrive through the
+ // repartition topic with the upstream producer's transform id intact (the
shuffle forwards
+ // watermark payloads unchanged).
+ private final WatermarkAggregator watermarkAggregator;
private Instant lastForwardedWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
// The global window fires exactly once, when the watermark first reaches
its end. Later watermark
// reports (e.g. the same terminal watermark broadcast across repartition
partitions) must not
@@ -69,9 +79,20 @@ class GroupByKeyProcessor
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
private @Nullable KeyValueStore<byte[], byte[]> store;
+ /**
+ * @param transformId this transform's own id, stamped on the watermarks it
emits
+ * @param upstreamTransformIds the transform ids feeding this GroupByKey
(known from the pipeline
+ * graph), whose reports the {@link WatermarkAggregator} waits for
+ */
GroupByKeyProcessor(
- String stateStoreName, Coder<Object> keyCoder, Coder<@Nullable Object>
valueCoder) {
+ String stateStoreName,
+ String transformId,
+ Set<String> upstreamTransformIds,
+ Coder<Object> keyCoder,
+ Coder<@Nullable Object> valueCoder) {
this.stateStoreName = stateStoreName;
+ this.transformId = transformId;
+ this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
this.keyCoder = keyCoder;
this.bufferCoder = IterableCoder.of(valueCoder);
}
@@ -85,6 +106,14 @@ class GroupByKeyProcessor
@Override
public void process(Record<byte[], KStreamsPayload<?>> record) {
KStreamsPayload<?> payload = record.value();
+ if (payload == null) {
+ // The repartition topic can be written to from outside the runner (or
carry a tombstone),
+ // so recover from the obvious error instead of crashing the task: warn
and drop.
+ LOG.warn(
+ "GroupByKey {} dropping record with null payload (external write or
tombstone)",
+ transformId);
+ return;
+ }
if (payload.isData()) {
byte[] encodedKey = record.key();
Object element = payload.getData().getValue();
@@ -94,12 +123,8 @@ class GroupByKeyProcessor
appendValue(encodedKey, element);
return;
}
- WatermarkPayload report = payload.asWatermark();
- watermarkManager.observe(
- report.getSourcePartition(),
- new Instant(report.getWatermarkMillis()),
- report.getTotalSourcePartitions());
- Instant advanced = watermarkManager.advance();
+ watermarkAggregator.observe(payload.asWatermark());
+ Instant advanced = watermarkAggregator.advance();
if (!fired && !advanced.isBefore(BoundedWindow.TIMESTAMP_MAX_VALUE)) {
fireAll(record);
fired = true;
@@ -153,10 +178,13 @@ class GroupByKeyProcessor
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> trigger,
long watermarkMillis) {
ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
- // GroupByKey is a single logical source for the next stage; report it as
partition 0 of 1.
+ // Stamped with this transform's own id; GroupByKey is a single instance
for now, so the report
+ // is for its only partition (0 of 1).
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
- trigger.key(), KStreamsPayload.watermark(watermarkMillis, 0, 1),
trigger.timestamp()));
+ trigger.key(),
+ KStreamsPayload.watermark(watermarkMillis, transformId, 0, 1),
+ trigger.timestamp()));
}
private byte[] encodeBuffer(List<@Nullable Object> values) {
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
index d7c4a309d9c..9e23dbb5cfb 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/GroupByKeyTranslator.java
@@ -24,6 +24,7 @@ import org.apache.beam.sdk.coders.Coder;
import org.apache.beam.sdk.coders.KvCoder;
import org.apache.beam.sdk.values.KV;
import org.apache.beam.sdk.values.WindowedValues;
+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.common.serialization.Serdes;
import org.apache.kafka.streams.Topology;
@@ -110,10 +111,18 @@ class GroupByKeyTranslator implements
PTransformTranslator {
payloadSerde.deserializer(),
repartitionTopic);
- // Buffer values per key and fire KV<K, Iterable<V>> at the terminal
watermark.
+ // Buffer values per key and fire KV<K, Iterable<V>> at the terminal
watermark. Watermark
+ // reports cross the repartition topic unchanged, so they still carry the
id of the transform
+ // that produced this GroupByKey's input — the parent the shuffle is
attached to.
topology.addProcessor(
transformId,
- () -> new GroupByKeyProcessor(stateStoreName, keyCoder, valueCoder),
+ () ->
+ new GroupByKeyProcessor(
+ stateStoreName,
+ transformId,
+ ImmutableSet.of(parentProcessor),
+ keyCoder,
+ valueCoder),
sourceName);
topology.addStateStore(
Stores.keyValueStoreBuilder(
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 675bee4d859..bac91978a29 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
@@ -122,17 +122,16 @@ class ImpulseProcessor implements Processor<byte[],
byte[], byte[], KStreamsPayl
}
/**
- * 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.
+ * Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors,
+ * stamped with this transform's id. Impulse is a single-instance source, so
the report is for its
+ * only 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) {
+ private 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, 0, 1), 0L));
+ new byte[0], KStreamsPayload.watermark(maxMillis, transformId, 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 93b40346761..c165f0e875d 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
@@ -57,6 +57,7 @@ public final class KStreamsPayload<T> {
private final Kind kind;
private final @Nullable WindowedValue<T> data;
private final long watermarkMillis;
+ private final String transformId;
private final int sourcePartition;
private final int totalSourcePartitions;
@@ -64,27 +65,33 @@ public final class KStreamsPayload<T> {
Kind kind,
@Nullable WindowedValue<T> data,
long watermarkMillis,
+ String transformId,
int sourcePartition,
int totalSourcePartitions) {
this.kind = kind;
this.data = data;
this.watermarkMillis = watermarkMillis;
+ this.transformId = transformId;
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, 0, 0);
+ return new KStreamsPayload<>(Kind.DATA, value, 0L, "", 0, 0);
}
/**
* 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.
+ * coordination fields a downstream watermark aggregator needs — which
transform produced the
+ * report ({@code transformId}, stamped by the producer without regard to
who consumes it), which
+ * of that transform's partitions this report is for, and how many
partitions that transform has
+ * in total.
*/
public static <T> KStreamsPayload<T> watermark(
- long watermarkMillis, int sourcePartition, int totalSourcePartitions) {
+ long watermarkMillis, String transformId, int sourcePartition, int
totalSourcePartitions) {
+ Preconditions.checkArgument(
+ transformId != null && !transformId.isEmpty(), "transformId must be
non-empty");
Preconditions.checkArgument(
totalSourcePartitions > 0,
"totalSourcePartitions must be positive: %s",
@@ -95,7 +102,7 @@ public final class KStreamsPayload<T> {
sourcePartition,
totalSourcePartitions);
return new KStreamsPayload<>(
- Kind.WATERMARK, null, watermarkMillis, sourcePartition,
totalSourcePartitions);
+ Kind.WATERMARK, null, watermarkMillis, transformId, sourcePartition,
totalSourcePartitions);
}
public boolean isData() {
@@ -134,6 +141,11 @@ public final class KStreamsPayload<T> {
return watermarkMillis;
}
+ @Override
+ public String getTransformId() {
+ return transformId;
+ }
+
@Override
public int getSourcePartition() {
return sourcePartition;
@@ -156,6 +168,7 @@ public final class KStreamsPayload<T> {
KStreamsPayload<?> that = (KStreamsPayload<?>) o;
return kind == that.kind
&& watermarkMillis == that.watermarkMillis
+ && transformId.equals(that.transformId)
&& sourcePartition == that.sourcePartition
&& totalSourcePartitions == that.totalSourcePartitions
&& Objects.equals(data, that.data);
@@ -163,7 +176,8 @@ public final class KStreamsPayload<T> {
@Override
public int hashCode() {
- return Objects.hash(kind, data, watermarkMillis, sourcePartition,
totalSourcePartitions);
+ return Objects.hash(
+ kind, data, watermarkMillis, transformId, sourcePartition,
totalSourcePartitions);
}
@Override
@@ -174,6 +188,7 @@ public final class KStreamsPayload<T> {
} else {
helper
.add("watermarkMillis", watermarkMillis)
+ .add("transformId", transformId)
.add("sourcePartition", sourcePartition)
.add("totalSourcePartitions", totalSourcePartitions);
}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerde.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerde.java
index 912eb5fb604..1363740d58b 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerde.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerde.java
@@ -82,6 +82,7 @@ public final class KStreamsPayloadSerde<T> implements
Serde<KStreamsPayload<T>>
proto.setWatermark(
KafkaStreamsPayload.WatermarkPayload.newBuilder()
.setMillis(watermark.getWatermarkMillis())
+ .setTransformId(watermark.getTransformId())
.setSourcePartition(watermark.getSourcePartition())
.setTotalPartitions(watermark.getTotalSourcePartitions()));
}
@@ -109,6 +110,7 @@ public final class KStreamsPayloadSerde<T> implements
Serde<KStreamsPayload<T>>
KafkaStreamsPayload.WatermarkPayload watermark =
proto.getWatermark();
return KStreamsPayload.watermark(
watermark.getMillis(),
+ watermark.getTransformId(),
watermark.getSourcePartition(),
watermark.getTotalPartitions());
case PAYLOAD_NOT_SET:
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
index 943673dfe98..a9e26ecd641 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
@@ -52,6 +52,7 @@ public class KafkaStreamsPipelineTranslator {
.put(PTransformTranslation.IMPULSE_TRANSFORM_URN, new
ImpulseTranslator())
.put(PTransformTranslation.READ_TRANSFORM_URN, new
ReadTranslator())
.put(PTransformTranslation.REDISTRIBUTE_ARBITRARILY_URN, new
RedistributeTranslator())
+ .put(PTransformTranslation.FLATTEN_TRANSFORM_URN, new
FlattenTranslator())
.put(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN, new
GroupByKeyTranslator())
.put(ExecutableStage.URN, new ExecutableStageTranslator())
.build());
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadProcessor.java
index 604eca54014..eb20a8b8358 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadProcessor.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ReadProcessor.java
@@ -183,17 +183,16 @@ class ReadProcessor<T> implements Processor<byte[],
byte[], byte[], KStreamsPayl
}
/**
- * Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors.
- *
- * <p>Read 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.
+ * Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors,
+ * stamped with this transform's id. Read is a single-instance source, so
the report is for its
+ * only 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<?>> ctx) {
+ private void forwardWatermarkMax(ProcessorContext<byte[],
KStreamsPayload<?>> ctx) {
long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
- new byte[0], KStreamsPayload.<Object>watermark(maxMillis, 0, 1),
0L));
+ new byte[0], KStreamsPayload.<Object>watermark(maxMillis,
transformId, 0, 1), 0L));
}
/** Cancels the wall-clock punctuator after the read has fired to stop
periodic wakeups. */
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
index 3184d838e09..774d36db0f2 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessor.java
@@ -25,6 +25,8 @@ 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;
/**
* Re-keys a {@code KV}-valued stream by the Beam key so Kafka Streams
shuffles by it.
@@ -39,6 +41,8 @@ import org.checkerframework.checker.nullness.qual.Nullable;
class ShuffleByKeyProcessor
implements Processor<byte[], KStreamsPayload<?>, byte[],
KStreamsPayload<?>> {
+ private static final Logger LOG =
LoggerFactory.getLogger(ShuffleByKeyProcessor.class);
+
private final Coder<Object> keyCoder;
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
@@ -55,6 +59,12 @@ class ShuffleByKeyProcessor
public void process(Record<byte[], KStreamsPayload<?>> record) {
ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
KStreamsPayload<?> payload = record.value();
+ if (payload == null) {
+ // A topic feeding the runner can always be written to from outside (or
carry a tombstone),
+ // so recover from the obvious error instead of crashing the task: warn
and drop.
+ LOG.warn("Shuffle dropping record with null payload (external write or
tombstone)");
+ return;
+ }
if (payload.isData()) {
Object element = payload.getData().getValue();
if (element == null) {
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregator.java
new file mode 100644
index 00000000000..c9af03df26a
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregator.java
@@ -0,0 +1,107 @@
+/*
+ * 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 java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.joda.time.Instant;
+
+/**
+ * Computes a transform's input watermark from the watermark reports of its
upstream transforms.
+ *
+ * <p>A watermark report carries three orthogonal pieces of information (see
{@link
+ * WatermarkPayload}): <i>which transform</i> produced it, <i>which
partition</i> (physical
+ * instance) of that transform it is for, and <i>how many partitions</i> that
transform has. A
+ * producer stamps its own identity without regard to who consumes the report.
This aggregator is
+ * the consuming side, used by every transform that aggregates a watermark —
ExecutableStage,
+ * GroupByKey, Flatten (and CombinePerKey later):
+ *
+ * <ul>
+ * <li>It is constructed with the set of upstream transform ids the consumer
expects, known from
+ * the pipeline graph at translation time (a single-input transform
passes its one parent; a
+ * Flatten passes the producers of all of its input PCollections).
+ * <li>Per upstream transform it tracks partitions with a dedicated {@link
WatermarkManager},
+ * which holds until every partition of that transform has reported and
keeps each partition
+ * monotonic.
+ * <li>The aggregate input watermark is the {@code min()} across the
upstream transforms'
+ * watermarks, defined only once <em>every</em> expected upstream
transform is ready; until
+ * then {@link #advance()} returns {@link
BoundedWindow#TIMESTAMP_MIN_VALUE} and the caller
+ * emits nothing.
+ * </ul>
+ *
+ * <p>Not thread-safe; the caller (a single Kafka Streams processor thread)
serializes access.
+ */
+final class WatermarkAggregator {
+
+ /** Upstream transform ids this consumer must hear from, fixed by the
pipeline graph. */
+ private final Set<String> expectedUpstreamTransformIds;
+
+ /** Per-upstream-transform partition tracking. */
+ private final Map<String, WatermarkManager> managerByTransformId = new
HashMap<>();
+
+ WatermarkAggregator(Set<String> expectedUpstreamTransformIds) {
+ Preconditions.checkArgument(
+ !expectedUpstreamTransformIds.isEmpty(), "expectedUpstreamTransformIds
must not be empty");
+ this.expectedUpstreamTransformIds =
ImmutableSet.copyOf(expectedUpstreamTransformIds);
+ }
+
+ /**
+ * Records one upstream watermark report. A report from a transform this
consumer does not expect
+ * indicates a translation wiring bug and fails fast.
+ */
+ void observe(WatermarkPayload report) {
+ String transformId = report.getTransformId();
+ if (!expectedUpstreamTransformIds.contains(transformId)) {
+ throw new IllegalStateException(
+ "Received a watermark report from unexpected transform "
+ + transformId
+ + "; expected one of "
+ + expectedUpstreamTransformIds);
+ }
+ managerByTransformId
+ .computeIfAbsent(transformId, id -> new WatermarkManager())
+ .observe(
+ report.getSourcePartition(),
+ new Instant(report.getWatermarkMillis()),
+ report.getTotalSourcePartitions());
+ }
+
+ /**
+ * Returns the aggregate input watermark: {@code min()} across all expected
upstream transforms,
+ * or {@link BoundedWindow#TIMESTAMP_MIN_VALUE} while any upstream transform
has not yet fully
+ * reported (the hold).
+ */
+ Instant advance() {
+ if (managerByTransformId.size() < expectedUpstreamTransformIds.size()) {
+ return BoundedWindow.TIMESTAMP_MIN_VALUE;
+ }
+ Instant min = BoundedWindow.TIMESTAMP_MAX_VALUE;
+ for (WatermarkManager manager : managerByTransformId.values()) {
+ // A not-yet-ready manager advances to TIMESTAMP_MIN_VALUE, which
correctly holds the min.
+ Instant watermark = manager.advance();
+ if (watermark.isBefore(min)) {
+ min = watermark;
+ }
+ }
+ return min;
+ }
+}
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
index 194be701ece..bac5314a6da 100644
---
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
@@ -24,18 +24,26 @@ package org.apache.beam.runners.kafka.streams.translation;
* {@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.
+ * <p>A watermark report is the in-band coordination message a downstream
watermark aggregator
+ * consumes: the watermark value, which transform produced it, which of that
transform's partitions
+ * reported it, and how many partitions that transform has in total. The
producer stamps its own
+ * identity without regard to who consumes the report; a consumer with several
upstream transforms
+ * (e.g. Flatten) aggregates per producing transform.
*/
public interface WatermarkPayload {
/** The reported watermark, in event-time milliseconds. */
long getWatermarkMillis();
- /** The source partition this report is for, in {@code [0,
getTotalSourcePartitions())}. */
+ /** Globally unique id of the transform that produced this report. */
+ String getTransformId();
+
+ /**
+ * Which partition (physical instance) of the producing transform this
report is for, in {@code
+ * [0, getTotalSourcePartitions())}.
+ */
int getSourcePartition();
- /** The total number of source partitions feeding the downstream stage. */
+ /** How many partitions (physical instances) the producing transform has in
total. */
int getTotalSourcePartitions();
}
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 fc5797a12c2..74169acb81c 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
@@ -25,13 +25,14 @@ 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.ImmutableSet;
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.
+ * reports to the {@link WatermarkAggregator} 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
@@ -39,6 +40,12 @@ import org.junit.Test;
*/
public class ExecutableStageProcessorWatermarkTest {
+ /** The stage's own transform id, expected on every watermark it forwards. */
+ private static final String STAGE_ID = "stage";
+
+ /** The single upstream transform whose reports the stage aggregates. */
+ private static final String UPSTREAM_ID = "upstream";
+
private static ExecutableStageProcessor newProcessor() {
JobInfo jobInfo =
JobInfo.create(
@@ -47,13 +54,17 @@ public class ExecutableStageProcessorWatermarkTest {
"",
PipelineOptionsTranslation.toProto(PipelineOptionsFactory.create()));
return new ExecutableStageProcessor(
- RunnerApi.ExecutableStagePayload.getDefaultInstance(), jobInfo);
+ RunnerApi.ExecutableStagePayload.getDefaultInstance(),
+ jobInfo,
+ STAGE_ID,
+ ImmutableSet.of(UPSTREAM_ID));
}
+ /** A report from the upstream transform's given partition. */
private static Record<byte[], KStreamsPayload<?>> watermark(
long millis, int sourcePartition, int totalSourcePartitions) {
KStreamsPayload<?> payload =
- KStreamsPayload.watermark(millis, sourcePartition,
totalSourcePartitions);
+ KStreamsPayload.watermark(millis, UPSTREAM_ID, sourcePartition,
totalSourcePartitions);
return new Record<>(new byte[0], payload, 0L);
}
@@ -75,7 +86,9 @@ public class ExecutableStageProcessorWatermarkTest {
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.
+ // The stage forwards under its own identity — its transform id and its
own single partition
+ // (0 of 1) — not the upstream's.
+ assertThat(report.getTransformId(), is(STAGE_ID));
assertThat(report.getSourcePartition(), is(0));
assertThat(report.getTotalSourcePartitions(), is(1));
}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/FlattenTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/FlattenTest.java
new file mode 100644
index 00000000000..a5af0cdc922
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/FlattenTest.java
@@ -0,0 +1,212 @@
+/*
+ * 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.hasItems;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.beam.runners.kafka.streams.KafkaStreamsTestRunner;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VarIntCoder;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.Flatten;
+import org.apache.beam.sdk.transforms.GroupByKey;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionList;
+import org.junit.Test;
+
+/**
+ * End-to-end test for {@link FlattenTranslator}: two branches are flattened
into one PCollection
+ * and a recording ParDo sees every element from both.
+ *
+ * <p>Each branch is a {@code Create -> identity ParDo}, so its producer
feeding the Flatten is an
+ * {@link ExecutableStageProcessor} — the same shape PAssert's {@code
GroupGlobally} produces. This
+ * exercises the per-producing-transform watermark aggregation: each branch's
producer stamps its
+ * own transform id on its watermark, and the Flatten holds its output
watermark until every
+ * upstream transform it expects has reported. Without that, the Flatten would
release its watermark
+ * after the first branch drained and the downstream stage's bundle would
close early, dropping the
+ * second branch's elements.
+ */
+public class FlattenTest {
+
+ private static class IdentityFn extends DoFn<Integer, Integer> {
+ @ProcessElement
+ public void processElement(@Element Integer input, OutputReceiver<Integer>
out) {
+ out.output(input);
+ }
+ }
+
+ /** Records every element the harness feeds it so the test can assert the
flatten's union. */
+ private static class RecordingFn extends DoFn<Integer, Integer> {
+ private final SharedTestCollector<Integer> collector;
+
+ RecordingFn(SharedTestCollector<Integer> collector) {
+ this.collector = collector;
+ }
+
+ @ProcessElement
+ public void processElement(@Element Integer input, OutputReceiver<Integer>
out) {
+ collector.record(input);
+ out.output(input);
+ }
+ }
+
+ @Test
+ public void flattenUnionsEveryBranch() {
+ try (SharedTestCollector<Integer> collector =
SharedTestCollector.create()) {
+ Pipeline pipeline =
Pipeline.create(KafkaStreamsTestRunner.testOptions());
+ PCollection<Integer> a =
+ pipeline.apply("createA", Create.of(1, 2)).apply("idA", ParDo.of(new
IdentityFn()));
+ PCollection<Integer> b =
+ pipeline.apply("createB", Create.of(3, 4)).apply("idB", ParDo.of(new
IdentityFn()));
+ PCollectionList.of(a)
+ .and(b)
+ .apply("flatten", Flatten.pCollections())
+ .apply("record", ParDo.of(new RecordingFn(collector)));
+
+ KafkaStreamsTestRunner.run(pipeline);
+
+ List<Integer> recorded = collector.recorded();
+ assertThat(recorded.size(), is(4));
+ assertThat(recorded, hasItems(1, 2, 3, 4));
+ }
+ }
+
+ @Test
+ public void pCollectionFeedingTwoFlattensIsSupported() {
+ // input2 feeds both flattens, so its producer's watermark report is
consumed by two different
+ // aggregators. The producer stamps its own transform id once, and each
flatten holds until its
+ // own two branches drain. Verify both flattens produce the right union.
+ try (SharedTestCollector<Integer> left = SharedTestCollector.create();
+ SharedTestCollector<Integer> right = SharedTestCollector.create()) {
+ Pipeline pipeline =
Pipeline.create(KafkaStreamsTestRunner.testOptions());
+ PCollection<Integer> input1 =
+ pipeline.apply("c1", Create.of(1, 2)).apply("id1", ParDo.of(new
IdentityFn()));
+ PCollection<Integer> input2 =
+ pipeline.apply("c2", Create.of(3, 4)).apply("id2", ParDo.of(new
IdentityFn()));
+ PCollection<Integer> input3 =
+ pipeline.apply("c3", Create.of(5, 6)).apply("id3", ParDo.of(new
IdentityFn()));
+ PCollectionList.of(input1)
+ .and(input2)
+ .apply("l1", Flatten.pCollections())
+ .apply("recordL1", ParDo.of(new RecordingFn(left)));
+ PCollectionList.of(input2)
+ .and(input3)
+ .apply("l2", Flatten.pCollections())
+ .apply("recordL2", ParDo.of(new RecordingFn(right)));
+
+ KafkaStreamsTestRunner.run(pipeline);
+
+ assertThat(left.recorded().size(), is(4));
+ assertThat(left.recorded(), hasItems(1, 2, 3, 4));
+ assertThat(right.recorded().size(), is(4));
+ assertThat(right.recorded(), hasItems(3, 4, 5, 6));
+ }
+ }
+
+ /** Maps each int to {@code KV("k", int)} so a downstream GroupByKey groups
all branches. */
+ private static class ToKvFn extends DoFn<Integer, KV<String, Integer>> {
+ @ProcessElement
+ public void processElement(@Element Integer input,
OutputReceiver<KV<String, Integer>> out) {
+ out.output(KV.of("k", input));
+ }
+ }
+
+ /** Records each grouped result as {@code "key=[sorted values]"}. */
+ private static class RecordGroupFn extends DoFn<KV<String,
Iterable<Integer>>, Void> {
+ private final SharedTestCollector<String> collector;
+
+ RecordGroupFn(SharedTestCollector<String> collector) {
+ this.collector = collector;
+ }
+
+ @ProcessElement
+ public void processElement(@Element KV<String, Iterable<Integer>> group) {
+ List<Integer> values = new ArrayList<>();
+ group.getValue().forEach(values::add);
+ Collections.sort(values);
+ collector.record(group.getKey() + "=" + values);
+ }
+ }
+
+ @Test
+ public void watermarkPropagatesThroughFlattenAndFiresDownstreamGroupByKey() {
+ // GroupByKey fires exactly once, when its input watermark reaches the end
of the global
+ // window, and the Flatten forwards its watermark only after every branch
has drained. Both
+ // branches share the key, so a single group holding the elements of both
branches proves the
+ // watermark propagated through the Flatten at the right time — a
premature release would fire
+ // a partial group instead.
+ try (SharedTestCollector<String> collector = SharedTestCollector.create())
{
+ Pipeline pipeline =
Pipeline.create(KafkaStreamsTestRunner.testOptions());
+ PCollection<KV<String, Integer>> a =
+ pipeline
+ .apply("createA", Create.of(1, 2))
+ .apply("kvA", ParDo.of(new ToKvFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()));
+ PCollection<KV<String, Integer>> b =
+ pipeline
+ .apply("createB", Create.of(3, 4))
+ .apply("kvB", ParDo.of(new ToKvFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()));
+ PCollectionList.of(a)
+ .and(b)
+ .apply("flatten", Flatten.pCollections())
+ .apply("gbk", GroupByKey.create())
+ .apply("record", ParDo.of(new RecordGroupFn(collector)));
+
+ KafkaStreamsTestRunner.run(pipeline);
+
+ List<String> groups = collector.recorded();
+ assertThat(groups.size(), is(1));
+ assertThat(groups, hasItems("k=[1, 2, 3, 4]"));
+ }
+ }
+
+ @Test
+ public void flattenOfOneBranchTwiceDuplicatesEveryElement() {
+ // A self-flatten is a bag union with itself: every element must appear
twice. The fuser folds
+ // the Flatten into the SDK-harness stage (it never reaches the runner's
Flatten translator as a
+ // duplicate-input node), so the duplication happens in the harness.
+ try (SharedTestCollector<Integer> collector =
SharedTestCollector.create()) {
+ Pipeline pipeline =
Pipeline.create(KafkaStreamsTestRunner.testOptions());
+ PCollection<Integer> branch =
+ pipeline.apply("create", Create.of(1, 2)).apply("id", ParDo.of(new
IdentityFn()));
+ PCollectionList.of(branch)
+ .and(branch)
+ .apply("flatten", Flatten.pCollections())
+ .apply("record", ParDo.of(new RecordingFn(collector)));
+
+ KafkaStreamsTestRunner.run(pipeline);
+
+ List<Integer> recorded = collector.recorded();
+ assertThat(recorded.size(), is(4));
+ assertThat(recorded, hasItems(1, 2));
+ assertThat(recorded.stream().filter(v -> v == 1).count(), is(2L));
+ assertThat(recorded.stream().filter(v -> v == 2).count(), is(2L));
+ }
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerdeTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerdeTest.java
index 19346f54763..95ce70b8857 100644
---
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerdeTest.java
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayloadSerdeTest.java
@@ -58,10 +58,11 @@ public class KStreamsPayloadSerdeTest {
@Test
public void roundTripsWatermarkPayload() {
- KStreamsPayload<Integer> payload = KStreamsPayload.watermark(12345L, 2, 4);
+ KStreamsPayload<Integer> payload = KStreamsPayload.watermark(12345L,
"transform-a", 2, 4);
KStreamsPayload<Integer> out = roundTrip(payload);
assertThat(out.isWatermark(), is(true));
assertThat(out.asWatermark().getWatermarkMillis(), is(12345L));
+ assertThat(out.asWatermark().getTransformId(), is("transform-a"));
assertThat(out.asWatermark().getSourcePartition(), is(2));
assertThat(out.asWatermark().getTotalSourcePartitions(), is(4));
assertThat(out, is(payload));
@@ -70,7 +71,7 @@ public class KStreamsPayloadSerdeTest {
@Test
public void roundTripsTerminalMaxWatermark() {
KStreamsPayload<Integer> payload =
-
KStreamsPayload.watermark(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis(), 0, 1);
+
KStreamsPayload.watermark(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis(), "t",
0, 1);
assertThat(
roundTrip(payload).asWatermark().getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
@@ -80,7 +81,7 @@ public class KStreamsPayloadSerdeTest {
public void roundTripsNegativeWatermark() {
// Beam event times can be negative; sint64 must round-trip them
losslessly.
KStreamsPayload<Integer> payload =
-
KStreamsPayload.watermark(BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis(), 0, 1);
+
KStreamsPayload.watermark(BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis(), "t",
0, 1);
assertThat(
roundTrip(payload).asWatermark().getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis()));
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregatorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregatorTest.java
new file mode 100644
index 00000000000..88d541bdc0c
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/WatermarkAggregatorTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.joda.time.Instant;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link WatermarkAggregator}: aggregation of watermark
reports across multiple
+ * upstream transforms, each with its own partition set. Per-partition
behaviour within one upstream
+ * transform (monotonicity, repartition reset) is covered in depth by {@code
WatermarkManagerTest};
+ * here it is exercised through the aggregate.
+ */
+public class WatermarkAggregatorTest {
+
+ private static Instant ts(long millis) {
+ return new Instant(millis);
+ }
+
+ /** A report from the given transform's partition {@code sourcePartition} of
{@code total}. */
+ private static WatermarkPayload report(
+ String transformId, long millis, int sourcePartition, int total) {
+ return KStreamsPayload.watermark(millis, transformId, sourcePartition,
total).asWatermark();
+ }
+
+ @Test
+ public void holdsBeforeAnyReport() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a"));
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MIN_VALUE));
+ }
+
+ @Test
+ public void singleUpstreamSinglePartitionAdvances() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a"));
+ aggregator.observe(report("a", 100L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+ }
+
+ @Test
+ public void holdsUntilEveryExpectedUpstreamReports() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a", "b"));
+ aggregator.observe(report("a", 100L, 0, 1));
+ // Only one of the two expected upstream transforms has reported — still
holding.
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MIN_VALUE));
+
+ aggregator.observe(report("b", 200L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+ }
+
+ @Test
+ public void holdsUntilEveryPartitionOfEachUpstreamReports() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a", "b"));
+ aggregator.observe(report("a", 100L, 0, 1));
+ aggregator.observe(report("b", 200L, 0, 2));
+ // Upstream "b" has reported only one of its two partitions — still
holding.
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MIN_VALUE));
+
+ aggregator.observe(report("b", 300L, 1, 2));
+ // Ready: min(100, min(200, 300)) = 100.
+ assertThat(aggregator.advance(), is(ts(100L)));
+ }
+
+ @Test
+ public void aggregateIsMinAcrossUpstreams() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a", "b", "c"));
+ aggregator.observe(report("a", 300L, 0, 1));
+ aggregator.observe(report("b", 100L, 0, 1));
+ aggregator.observe(report("c", 500L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+
+ // The slowest upstream advances; the aggregate follows the new min.
+ aggregator.observe(report("b", 400L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(300L)));
+ }
+
+ @Test
+ public void perUpstreamWatermarkIsMonotonic() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a"));
+ aggregator.observe(report("a", 200L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(200L)));
+
+ // A late lower report from the same upstream partition is ignored.
+ aggregator.observe(report("a", 100L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(200L)));
+ }
+
+ @Test
+ public void duplicateReportDoesNotAdvance() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a"));
+ aggregator.observe(report("a", 100L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+
+ // The same report again (e.g. a broadcast duplicate) leaves the aggregate
unchanged.
+ aggregator.observe(report("a", 100L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+ }
+
+ @Test
+ public void terminalMaxWatermarkAggregates() {
+ long max = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a", "b"));
+ aggregator.observe(report("a", max, 0, 1));
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MIN_VALUE));
+
+ aggregator.observe(report("b", max, 0, 1));
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MAX_VALUE));
+ }
+
+ @Test
+ public void repartitionOfOneUpstreamReopensTheHold() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a", "b"));
+ aggregator.observe(report("a", 100L, 0, 1));
+ aggregator.observe(report("b", 200L, 0, 1));
+ assertThat(aggregator.advance(), is(ts(100L)));
+
+ // Upstream "b" changes its partition count (repartition): its
per-partition state resets and
+ // the aggregate holds again until b's new full partition set has reported.
+ aggregator.observe(report("b", 250L, 0, 2));
+ assertThat(aggregator.advance(), is(BoundedWindow.TIMESTAMP_MIN_VALUE));
+
+ aggregator.observe(report("b", 300L, 1, 2));
+ assertThat(aggregator.advance(), is(ts(100L)));
+ }
+
+ @Test
+ public void reportFromUnexpectedTransformFailsFast() {
+ WatermarkAggregator aggregator = new
WatermarkAggregator(ImmutableSet.of("a"));
+ IllegalStateException thrown =
+ assertThrows(
+ IllegalStateException.class, () ->
aggregator.observe(report("intruder", 100L, 0, 1)));
+ assertThat(thrown.getMessage(), containsString("intruder"));
+ }
+
+ @Test
+ public void emptyExpectedUpstreamsIsRejected() {
+ assertThrows(IllegalArgumentException.class, () -> new
WatermarkAggregator(ImmutableSet.of()));
+ }
+}