diff --git a/runners/kafka-streams/build.gradle b/runners/kafka-streams/build.gradle
index bdf7e3be0585..d1326c079e5d 100644
--- a/runners/kafka-streams/build.gradle
+++ b/runners/kafka-streams/build.gradle
@@ -17,6 +17,7 @@
*/
import groovy.json.JsonOutput
+import java.time.Duration
plugins { id 'org.apache.beam.module' }
@@ -74,6 +75,7 @@ dependencies {
testImplementation library.java.junit
testImplementation library.java.mockito_core
testImplementation "org.apache.kafka:kafka-streams-test-utils:$kafka_version"
+ testImplementation library.java.testcontainers_kafka
// Beam's @ValidatesRunner suite: the test classes come from the SDK core test jar; the runner
// (TestKafkaStreamsRunner) and its TopologyTestDriver harness come from this module's test
@@ -85,6 +87,27 @@ dependencies {
}
+// The broker integration test drives the production runner against a real Kafka in Docker, so it
+// is not part of the default build. Run it with :runners:kafka-streams:brokerIntegrationTest.
+test {
+ filter {
+ excludeTestsMatching 'org.apache.beam.runners.kafka.streams.*IT'
+ }
+}
+
+tasks.register("brokerIntegrationTest", Test) {
+ group = "Verification"
+ description = "Runs the Kafka Streams runner against a real broker (requires Docker)."
+ outputs.upToDateWhen { false }
+ testClassesDirs = sourceSets.test.output.classesDirs
+ classpath = sourceSets.test.runtimeClasspath
+ filter {
+ includeTestsMatching 'org.apache.beam.runners.kafka.streams.*IT'
+ }
+ // A container start plus a streaming run is well past the default per-test expectations.
+ timeout = Duration.ofMinutes(15)
+}
+
// Known-failing @ValidatesRunner tests, excluded until the feature they need lands.
def sickbayTests = [
// Merging (session) windows are not supported yet: ReduceFnRunner drives them through a merging
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
index 2fa992e66e7e..a5a8bb9328b1 100644
--- a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineOptions.java
@@ -55,6 +55,23 @@ public interface KafkaStreamsPipelineOptions extends PortablePipelineOptions {
void setMaxBundleTimeMs(int maxBundleTimeMs);
+ @Description(
+ "How many partitions the runner gives the internal topics it creates to shuffle a pipeline"
+ + " through, which is the parallelism the shuffled parts of that pipeline can reach. A"
+ + " GroupByKey runs one task per partition of its repartition topic, so this is the"
+ + " number of instances its state and its downstream stages are spread over. Must be at"
+ + " least 1.")
+ @Default.Integer(1)
+ int getInternalParallelism();
+
+ void setInternalParallelism(int internalParallelism);
+
+ @Description("Replication factor for the internal topics the runner creates for a pipeline.")
+ @Default.Short(1)
+ short getTopicReplicationFactor();
+
+ void setTopicReplicationFactor(short topicReplicationFactor);
+
@Description("Directory where Kafka Streams stores local state.")
@Default.InstanceFactory(StateDirDefaultFactory.class)
String getStateDir();
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineRunner.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineRunner.java
index cdb59f67cce9..96b4b2cd7f92 100644
--- a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineRunner.java
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPipelineRunner.java
@@ -24,10 +24,10 @@
import org.apache.beam.runners.jobsubmission.PortablePipelineRunner;
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsPipelineTranslator;
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsTranslationContext;
-import org.apache.beam.sdk.options.PipelineOptionsValidator;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.Topology;
+import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,9 +44,22 @@ public KafkaStreamsPipelineRunner(KafkaStreamsPipelineOptions pipelineOptions) {
@Override
public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo jobInfo) {
- // Surface a clear error if a required option (e.g. applicationId) is missing instead of
- // letting Properties.put fail with a raw NullPointerException further down.
- PipelineOptionsValidator.validate(KafkaStreamsPipelineOptions.class, pipelineOptions);
+ // Surface a clear error if an option this runner needs is missing, instead of letting
+ // Properties.put fail with a raw NullPointerException further down. Only the options that are
+ // meaningful here are checked, rather than validating the whole interface: this runs on the job
+ // server, executing a pipeline that has already been submitted, so the client-side options
+ // PortablePipelineOptions marks required — jobEndpoint above all — do not apply. Flink's
+ // equivalent PortablePipelineRunner does not validate here either.
+ checkRequiredOption("applicationId", pipelineOptions.getApplicationId());
+ checkRequiredOption("bootstrapServers", pipelineOptions.getBootstrapServers());
+ // A topic cannot have fewer than one partition, and the value is also the number of watermark
+ // reports a shuffle's consumer waits for, so a non-positive value would leave it waiting
+ // forever rather than failing.
+ if (pipelineOptions.getInternalParallelism() < 1) {
+ throw new IllegalArgumentException(
+ "--internalParallelism must be at least 1, but was "
+ + pipelineOptions.getInternalParallelism());
+ }
KafkaStreamsPipelineTranslator translator = new KafkaStreamsPipelineTranslator();
KafkaStreamsTranslationContext context =
@@ -55,15 +68,28 @@ public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo jobInfo)
translator.translate(context, prepared);
Topology topology = context.getTopology();
+ // The runner names its own bootstrap and repartition topics, which Kafka Streams treats as
+ // user topics and will not create; it refuses to start if a source topic is missing.
+ KafkaStreamsTopicManager.createMissingTopics(topology, pipelineOptions);
LOG.info(
"Translated pipeline {} into Kafka Streams topology:\n{}",
jobInfo.jobId(),
topology.describe());
KafkaStreams kafkaStreams = new KafkaStreams(topology, streamsConfig(jobInfo));
+ // Build the result before starting: it registers a state listener, and Kafka Streams only
+ // accepts one while the application is still in the CREATED state.
+ KafkaStreamsPortablePipelineResult result =
+ new KafkaStreamsPortablePipelineResult(kafkaStreams, context.getMetricsContainerStepMap());
kafkaStreams.start();
- return new KafkaStreamsPortablePipelineResult(
- kafkaStreams, context.getMetricsContainerStepMap());
+ return result;
+ }
+
+ private static void checkRequiredOption(String name, @Nullable String value) {
+ if (value == null || value.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Missing required pipeline option --" + name + " for the Kafka Streams runner");
+ }
}
private Properties streamsConfig(JobInfo jobInfo) {
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPortablePipelineResult.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPortablePipelineResult.java
index 972afa15c358..0d508b8189fd 100644
--- a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPortablePipelineResult.java
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPortablePipelineResult.java
@@ -48,6 +48,10 @@ class KafkaStreamsPortablePipelineResult implements PortablePipelineResult {
private final CountDownLatch terminated = new CountDownLatch(1);
private volatile boolean cancelled = false;
+ /**
+ * Must be constructed before {@link KafkaStreams#start()} is called: it registers a state
+ * listener, and Kafka Streams rejects one once the application has left the CREATED state.
+ */
KafkaStreamsPortablePipelineResult(
KafkaStreams kafkaStreams, MetricsContainerStepMap metricsContainerStepMap) {
this.kafkaStreams = kafkaStreams;
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTopicManager.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTopicManager.java
new file mode 100644
index 000000000000..0e5f46f53eac
--- /dev/null
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTopicManager.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.AdminClientConfig;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.common.errors.TopicExistsException;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyDescription;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Creates the topics a translated pipeline needs before the Kafka Streams application starts.
+ *
+ *
The runner shuffles data through topics it names itself: a bootstrap topic per Impulse and per
+ * primitive Read, and a repartition topic per GroupByKey. Kafka Streams does create the internal
+ * topics it manages on its own, but these are declared with explicit names through {@code
+ * addSource} and {@code addSink}, so to Kafka Streams they are ordinary user topics — it will not
+ * create them, and refuses to start with {@code MissingSourceTopicException} if a source topic is
+ * absent. Relying on the broker's {@code auto.create.topics.enable} is not an option either: it is
+ * off on many clusters, and a topic auto-created on first fetch gets the broker's default partition
+ * count rather than the pipeline's.
+ *
+ *
Only topics carrying one of the runner's own prefixes are created. Any other topic in the
+ * topology belongs to the user (a source or sink they named), and creating those implicitly would
+ * hide a misconfiguration behind an empty topic.
+ */
+class KafkaStreamsTopicManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(KafkaStreamsTopicManager.class);
+
+ /**
+ * Prefixes of the bootstrap topics, which must have exactly one partition.
+ *
+ *
An Impulse or a primitive Read emits its elements once per task, gated by a state store that
+ * is itself per task. Kafka Streams creates one task per partition of the source topic, so a
+ * bootstrap topic with several partitions would make the same Impulse fire once per partition and
+ * the same source be read once per partition.
+ */
+ private static final List SINGLE_PARTITION_TOPIC_PREFIXES =
+ java.util.Arrays.asList("__beam_impulse_", "__beam_read_");
+
+ /**
+ * Prefixes of the topics whose partition count sets the pipeline's parallelism — the repartition
+ * topic a GroupByKey shuffles through.
+ */
+ private static final List PARTITIONED_TOPIC_PREFIXES =
+ java.util.Arrays.asList("__beam_gbk_");
+
+ private KafkaStreamsTopicManager() {}
+
+ /**
+ * Creates any runner-owned topic in {@code topology} that does not exist yet.
+ *
+ *
Safe to run concurrently with another instance of the same job: a topic that appears between
+ * the existence check and the create request surfaces as {@link TopicExistsException}, which is
+ * treated as success.
+ */
+ static void createMissingTopics(Topology topology, KafkaStreamsPipelineOptions options) {
+ Set runnerTopics = runnerOwnedTopics(topology);
+ if (runnerTopics.isEmpty()) {
+ return;
+ }
+ Properties adminConfig = new Properties();
+ adminConfig.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, options.getBootstrapServers());
+ try (Admin admin = Admin.create(adminConfig)) {
+ Set existing = admin.listTopics().names().get();
+ List toCreate = new ArrayList<>();
+ for (String topic : runnerTopics) {
+ if (!existing.contains(topic)) {
+ toCreate.add(
+ new NewTopic(
+ topic, partitionsFor(topic, options), options.getTopicReplicationFactor()));
+ }
+ }
+ if (toCreate.isEmpty()) {
+ return;
+ }
+ LOG.info("Creating {} runner-owned topic(s): {}", toCreate.size(), toCreate);
+ createAll(admin, toCreate);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while creating the pipeline's Kafka topics", e);
+ } catch (ExecutionException e) {
+ throw new RuntimeException("Failed to create the pipeline's Kafka topics", e);
+ }
+ }
+
+ private static void createAll(Admin admin, Collection topics)
+ throws InterruptedException, ExecutionException {
+ try {
+ admin.createTopics(topics).all().get();
+ } catch (ExecutionException e) {
+ // Another instance of the same application may have created them first, which is fine.
+ if (!(e.getCause() instanceof TopicExistsException)) {
+ throw e;
+ }
+ LOG.debug("Some topics already existed; another instance created them first", e);
+ }
+ }
+
+ /** The topics in the topology that the runner named, and so is responsible for creating. */
+ private static Set runnerOwnedTopics(Topology topology) {
+ Set topics = new HashSet<>();
+ for (TopologyDescription.Subtopology subtopology : topology.describe().subtopologies()) {
+ for (TopologyDescription.Node node : subtopology.nodes()) {
+ if (node instanceof TopologyDescription.Source) {
+ Set sourceTopics = ((TopologyDescription.Source) node).topicSet();
+ if (sourceTopics != null) {
+ topics.addAll(sourceTopics);
+ }
+ } else if (node instanceof TopologyDescription.Sink) {
+ String topic = ((TopologyDescription.Sink) node).topic();
+ if (topic != null) {
+ topics.add(topic);
+ }
+ }
+ }
+ }
+ topics.removeIf(topic -> !isRunnerOwned(topic));
+ return topics;
+ }
+
+ /**
+ * The partition count a runner-owned topic is created with: one for a bootstrap topic, and the
+ * configured parallelism for a shuffle topic.
+ */
+ private static int partitionsFor(String topic, KafkaStreamsPipelineOptions options) {
+ return hasAnyPrefix(topic, SINGLE_PARTITION_TOPIC_PREFIXES)
+ ? 1
+ : options.getInternalParallelism();
+ }
+
+ private static boolean isRunnerOwned(String topic) {
+ return hasAnyPrefix(topic, SINGLE_PARTITION_TOPIC_PREFIXES)
+ || hasAnyPrefix(topic, PARTITIONED_TOPIC_PREFIXES);
+ }
+
+ private static boolean hasAnyPrefix(String topic, List prefixes) {
+ for (String prefix : prefixes) {
+ if (topic.startsWith(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
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 15265073dd21..63fefe3e15c4 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
@@ -286,10 +286,11 @@ private void closeBundleAndFlush(Record> record) {
}
private void forwardWatermark(Record> record, long watermarkMillis) {
- // 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).
+ // Labelled as the only source a consumer will see. Forwarding here is in-process, to the
+ // stage's
+ // fused children, so exactly one instance of this stage reaches each of them. Where the output
+ // instead crosses a shuffle, ShuffleByKeyProcessor relabels the report with the real partition
+ // identity, because the broadcast then delivers every instance's report to every consumer.
ProcessorContext> ctx = checkInitialized(context);
ctx.forward(
new Record>(
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 71e24f32c1f5..caefa6534fad 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
@@ -74,6 +74,8 @@ public void translate(
// is unambiguous even before we add side-input support.
String inputPCollectionId = stagePayload.getInput();
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
+ // A fused stage runs wherever its input runs: same task, so same partition identity.
+ int partitionCount = context.getPartitionCount(inputPCollectionId);
// A multi-output stage (a DoFn with side outputs, or a Read whose SDF wrapper produces several
// outputs) needs each output routed to the right downstream. Since downstream transforms are
@@ -117,9 +119,11 @@ public void translate(
topology.addProcessor(
relayName, () -> new StageOutputProcessor(relayName), transformId);
context.registerPCollectionProducer(outputPCollectionId, relayName);
+ context.registerPCollectionPartitionCount(outputPCollectionId, partitionCount);
});
} else if (!outputPCollectionIds.isEmpty()) {
context.registerPCollectionProducer(outputPCollectionIds.get(0), transformId);
+ context.registerPCollectionPartitionCount(outputPCollectionIds.get(0), partitionCount);
}
}
}
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
index d09fed8185a1..e37da6774489 100644
--- 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
@@ -99,8 +99,7 @@ public void process(Record> record) {
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).
+ // Labelled as the only source a consumer will see; a shuffle downstream relabels it.
ctx.forward(
new Record>(
record.key(),
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
index 3ac4c1304daa..a5c8ce05baeb 100644
--- 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
@@ -57,6 +57,9 @@ public void translate(
Set seenInputs = new HashSet<>();
List parentProcessors = new ArrayList<>();
Set upstreamTransformIds = new HashSet<>();
+ // Kafka Streams puts a processor and the parents it is wired to in one subtopology, so the
+ // inputs are co-partitioned and this Flatten runs at their partition count.
+ int partitionCount = 1;
for (String inputPCollectionId : transform.getInputsMap().values()) {
if (!seenInputs.add(inputPCollectionId)) {
throw new UnsupportedOperationException(
@@ -70,6 +73,7 @@ public void translate(
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
parentProcessors.add(parentProcessor);
upstreamTransformIds.add(parentProcessor);
+ partitionCount = Math.max(partitionCount, context.getPartitionCount(inputPCollectionId));
}
topology.addProcessor(
@@ -78,5 +82,6 @@ public void translate(
parentProcessors.toArray(new String[0]));
context.registerPCollectionProducer(outputPCollectionId, transformId);
+ context.registerPCollectionPartitionCount(outputPCollectionId, partitionCount);
}
}
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 c5327e28e069..c460436eedf8 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
@@ -95,6 +95,9 @@ public void translate(
hydrateWindowingStrategy(pipeline, inputPCollectionId);
String parentProcessor = context.getProcessorNameForPCollection(inputPCollectionId);
+ // The shuffle is what changes the parallelism: everything from the repartition topic onwards
+ // runs one task per partition of it.
+ int partitionCount = context.getPipelineOptions().getInternalParallelism();
String shuffleName = transformId + SHUFFLE_SUFFIX;
String sinkName = transformId + SINK_SUFFIX;
@@ -110,7 +113,13 @@ public void translate(
Topology topology = context.getTopology();
// Re-key data records by the encoded Beam key; pass watermark reports through.
- topology.addProcessor(shuffleName, () -> new ShuffleByKeyProcessor(keyCoder), parentProcessor);
+ // The shuffle runs in the upstream transform's task, so it relabels each report with that
+ // transform's instance identity before the sink broadcasts it to every partition.
+ int upstreamPartitionCount = context.getPartitionCount(inputPCollectionId);
+ topology.addProcessor(
+ shuffleName,
+ () -> new ShuffleByKeyProcessor(keyCoder, upstreamPartitionCount),
+ parentProcessor);
// Shuffle through the repartition topic: data partitioned by key, watermark broadcast.
topology.addSink(
@@ -168,6 +177,7 @@ public void translate(
transformId);
context.registerPCollectionProducer(outputPCollectionId, transformId);
+ context.registerPCollectionPartitionCount(outputPCollectionId, partitionCount);
}
/** Hydrates the input PCollection's windowing strategy from the pipeline proto. */
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsTranslationContext.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsTranslationContext.java
index ec1b3f26aded..d03169615665 100644
--- a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsTranslationContext.java
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsTranslationContext.java
@@ -46,6 +46,12 @@ public class KafkaStreamsTranslationContext {
private final KafkaStreamsPipelineOptions pipelineOptions;
private final Topology topology;
private final Map pCollectionIdToProcessorName;
+
+ /**
+ * How many partitions the transform producing each PCollection runs across. A PCollection that
+ * has not been registered is produced by a single instance; only a shuffle raises the count.
+ */
+ private final Map pCollectionIdToPartitionCount = new HashMap<>();
// Accumulates the Beam metrics reported by the SDK harness, one container per executable stage.
// Processors update it as bundles complete (in-JVM reference sharing); the pipeline result
// exposes it as MetricResults. Sharing one container across a stage's parallel tasks is safe and
@@ -113,6 +119,30 @@ public void registerPCollectionProducer(String pCollectionId, String processorNa
}
}
+ /**
+ * Records how many partitions the transform producing {@code pCollectionId} runs across.
+ *
+ *
This is the {@code totalSourcePartitions} its watermark reports carry, and what a downstream
+ * {@link WatermarkAggregator} waits to hear from before it lets the watermark advance. It changes
+ * only at a shuffle: everything fused downstream of one runs at the shuffle topic's partition
+ * count, and everything else runs as a single instance.
+ */
+ public void registerPCollectionPartitionCount(String pCollectionId, int partitionCount) {
+ pCollectionIdToPartitionCount.put(pCollectionId, partitionCount);
+ }
+
+ /**
+ * How many partitions the transform producing {@code pCollectionId} runs across; one unless a
+ * shuffle upstream raised it.
+ *
+ *
Always at least one: an unregistered PCollection is produced by a single instance, and the
+ * only value ever registered is {@code --internalParallelism}, which the runner rejects below one
+ * before translating.
+ */
+ public int getPartitionCount(String pCollectionId) {
+ return pCollectionIdToPartitionCount.getOrDefault(pCollectionId, 1);
+ }
+
/** Returns the processor node name producing the given PCollection. */
public String getProcessorNameForPCollection(String pCollectionId) {
String name = pCollectionIdToProcessorName.get(pCollectionId);
diff --git a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/RedistributeTranslator.java b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/RedistributeTranslator.java
index 72c47db8d4b1..a44740887b3e 100644
--- a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/RedistributeTranslator.java
+++ b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/RedistributeTranslator.java
@@ -50,5 +50,8 @@ public void translate(
// Passthrough: downstream lookups for the output PCollection resolve to the producer of the
// input PCollection. No KS Processor / state store / source is added.
context.registerPCollectionProducer(outputPCollectionId, parentProcessor);
+ // A pass-through: the output is produced by the same processor, so same partition identity.
+ context.registerPCollectionPartitionCount(
+ outputPCollectionId, context.getPartitionCount(inputPCollectionId));
}
}
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 774d36db0f2e..79595cba7c96 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
@@ -34,9 +34,16 @@
*
This is not GroupByKey-specific: any transform that needs the values of a key co-located on
* one partition uses it — GroupByKey today, and stateful ParDo later. For a data record it sets the
* Kafka record key to the encoded Beam key (taken from the {@code KV}), so the downstream
- * repartition sink co-locates every value of a key. Watermark reports are forwarded unchanged — the
- * {@link GroupByKeyBroadcastPartitioner} fans them out to all partitions so every downstream task
- * can fire.
+ * repartition sink co-locates every value of a key.
+ *
+ *
Watermark reports are relabelled here with the reporting instance's real partition identity.
+ * This is the point at which a report stops being delivered in-process and starts crossing a topic:
+ * upstream of it a transform forwards to its fused children, which see exactly one instance of it,
+ * so the report names a single source. The {@link GroupByKeyBroadcastPartitioner} on the sink below
+ * fans each report out to every partition, so a downstream task instead sees a report from
+ * every instance of the upstream transform, and has to be able to tell them apart to know when it
+ * has heard from all of them. The transform id is left alone, so the report still names the
+ * transform that produced it.
*/
class ShuffleByKeyProcessor
implements Processor, byte[], KStreamsPayload>> {
@@ -44,15 +51,25 @@ class ShuffleByKeyProcessor
private static final Logger LOG = LoggerFactory.getLogger(ShuffleByKeyProcessor.class);
private final Coder