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 27198768f89 [GSoC 2026] Kafka Streams runner: run on a real broker,
correctly across partitions (#39546)
27198768f89 is described below
commit 27198768f89f5b177e65e9c00c32dbba2d94cfb1
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Sat Aug 1 14:31:20 2026 +0500
[GSoC 2026] Kafka Streams runner: run on a real broker, correctly across
partitions (#39546)
* [GSoC 2026] Kafka Streams runner: run against a real broker
Adds what the runner needs to execute on an actual Kafka cluster, and an
integration test that runs a pipeline through the production
KafkaStreamsPipelineRunner against a Kafka container. Everything until now
ran through TopologyTestDriver, which fakes the topics and never builds a
KafkaStreams application, so the production path had not been executed.
---
runners/kafka-streams/build.gradle | 23 ++
.../kafka/streams/KafkaStreamsPipelineOptions.java | 17 ++
.../kafka/streams/KafkaStreamsPipelineRunner.java | 38 ++-
.../KafkaStreamsPortablePipelineResult.java | 4 +
.../kafka/streams/KafkaStreamsTopicManager.java | 171 ++++++++++++
.../translation/ExecutableStageProcessor.java | 9 +-
.../translation/ExecutableStageTranslator.java | 4 +
.../streams/translation/FlattenProcessor.java | 3 +-
.../streams/translation/FlattenTranslator.java | 5 +
.../streams/translation/GroupByKeyTranslator.java | 12 +-
.../KafkaStreamsTranslationContext.java | 30 +++
.../translation/RedistributeTranslator.java | 3 +
.../streams/translation/ShuffleByKeyProcessor.java | 37 ++-
.../translation/WindowedGroupByKeyProcessor.java | 3 +-
.../kafka/streams/KafkaStreamsRunnerBrokerIT.java | 286 +++++++++++++++++++++
.../translation/ShuffleByKeyProcessorTest.java | 123 +++++++++
.../StandardWindowFnTranslationTest.java | 152 +++++++++++
17 files changed, 900 insertions(+), 20 deletions(-)
diff --git a/runners/kafka-streams/build.gradle
b/runners/kafka-streams/build.gradle
index bdf7e3be058..d1326c079e5 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 2fa992e66e7..a5a8bb9328b 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 cdb59f67cce..96b4b2cd7f9 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.PortablePipelineResult;
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 class KafkaStreamsPipelineRunner implements
PortablePipelineRunner {
@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 class KafkaStreamsPipelineRunner implements
PortablePipelineRunner {
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 972afa15c35..0d508b8189f 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 00000000000..0e5f46f53ea
--- /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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p>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<String> 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<String> PARTITIONED_TOPIC_PREFIXES =
+ java.util.Arrays.asList("__beam_gbk_");
+
+ private KafkaStreamsTopicManager() {}
+
+ /**
+ * Creates any runner-owned topic in {@code topology} that does not exist
yet.
+ *
+ * <p>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<String> 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<String> existing = admin.listTopics().names().get();
+ List<NewTopic> 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<NewTopic> 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<String> runnerOwnedTopics(Topology topology) {
+ Set<String> topics = new HashSet<>();
+ for (TopologyDescription.Subtopology subtopology :
topology.describe().subtopologies()) {
+ for (TopologyDescription.Node node : subtopology.nodes()) {
+ if (node instanceof TopologyDescription.Source) {
+ Set<String> 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<String> 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 15265073dd2..63fefe3e15c 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 @@ class ExecutableStageProcessor
}
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> 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<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
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 71e24f32c1f..caefa6534fa 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 @@ class ExecutableStageTranslator implements
PTransformTranslator {
// 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 @@ class ExecutableStageTranslator implements
PTransformTranslator {
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 d09fed8185a..e37da677448 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 @@ class FlattenProcessor
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<byte[], KStreamsPayload<?>>(
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 3ac4c1304da..a5c8ce05bae 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 @@ class FlattenTranslator implements PTransformTranslator {
Set<String> seenInputs = new HashSet<>();
List<String> parentProcessors = new ArrayList<>();
Set<String> 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 @@ class FlattenTranslator implements PTransformTranslator {
String parentProcessor =
context.getProcessorNameForPCollection(inputPCollectionId);
parentProcessors.add(parentProcessor);
upstreamTransformIds.add(parentProcessor);
+ partitionCount = Math.max(partitionCount,
context.getPartitionCount(inputPCollectionId));
}
topology.addProcessor(
@@ -78,5 +82,6 @@ class FlattenTranslator implements PTransformTranslator {
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 c5327e28e06..c460436eedf 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 @@ class GroupByKeyTranslator implements PTransformTranslator {
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 @@ class GroupByKeyTranslator implements PTransformTranslator
{
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 @@ class GroupByKeyTranslator implements PTransformTranslator {
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 ec1b3f26ade..d0316961566 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<String, String> 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<String, Integer> 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 class KafkaStreamsTranslationContext {
}
}
+ /**
+ * Records how many partitions the transform producing {@code pCollectionId}
runs across.
+ *
+ * <p>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.
+ *
+ * <p>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 72c47db8d4b..a44740887b3 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 @@ class RedistributeTranslator implements PTransformTranslator {
// 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 774d36db0f2..79595cba7c9 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 @@ import org.slf4j.LoggerFactory;
* <p>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.
+ *
+ * <p>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 <em>every</em> 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<?>, byte[],
KStreamsPayload<?>> {
@@ -44,15 +51,25 @@ class ShuffleByKeyProcessor
private static final Logger LOG =
LoggerFactory.getLogger(ShuffleByKeyProcessor.class);
private final Coder<Object> keyCoder;
+
+ /** How many instances the transform being shuffled runs as, and which one
this is. */
+ private final int upstreamPartitionCount;
+
+ private int upstreamPartition;
+
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
- ShuffleByKeyProcessor(Coder<Object> keyCoder) {
+ ShuffleByKeyProcessor(Coder<Object> keyCoder, int upstreamPartitionCount) {
this.keyCoder = keyCoder;
+ this.upstreamPartitionCount = upstreamPartitionCount;
}
@Override
public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
this.context = context;
+ // This processor runs in the upstream transform's task, so the task's
partition is the
+ // identity of the instance whose reports it is forwarding.
+ this.upstreamPartition = context.taskId().partition();
}
@Override
@@ -82,8 +99,16 @@ class ShuffleByKeyProcessor
}
ctx.forward(record.withKey(encodedKey));
} else {
- // Watermark report: forward as-is; the sink's partitioner broadcasts it
to all partitions.
- ctx.forward(record);
+ WatermarkPayload report = payload.asWatermark();
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<?>>(
+ record.key(),
+ KStreamsPayload.watermark(
+ report.getWatermarkMillis(),
+ report.getTransformId(),
+ upstreamPartition,
+ upstreamPartitionCount),
+ record.timestamp()));
}
}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WindowedGroupByKeyProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WindowedGroupByKeyProcessor.java
index 1b0ffae23fd..d00b01a3753 100644
---
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WindowedGroupByKeyProcessor.java
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/WindowedGroupByKeyProcessor.java
@@ -284,7 +284,8 @@ class WindowedGroupByKeyProcessor<K, V, W extends
BoundedWindow>
private void forwardWatermark(Record<byte[], KStreamsPayload<?>> trigger,
long watermarkMillis) {
ProcessorContext<byte[], KStreamsPayload<?>> ctx =
checkInitialized(context);
- // Stamped with this transform's own id; GroupByKey is a single instance
for now (0 of 1).
+ // Labelled as the only source a consumer will see; see
ExecutableStageProcessor for why an
+ // in-process edge reports a single source and a shuffle relabels.
ctx.forward(
new Record<byte[], KStreamsPayload<?>>(
trigger.key(),
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerBrokerIT.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerBrokerIT.java
new file mode 100644
index 00000000000..bb82a403e55
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerBrokerIT.java
@@ -0,0 +1,286 @@
+/*
+ * 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 static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.nio.file.Files;
+import java.util.UUID;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.PipelineResult;
+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.metrics.Counter;
+import org.apache.beam.sdk.metrics.MetricNameFilter;
+import org.apache.beam.sdk.metrics.MetricQueryResults;
+import org.apache.beam.sdk.metrics.MetricResult;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.metrics.MetricsFilter;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.options.PortablePipelineOptions;
+import org.apache.beam.sdk.testing.CrashingRunner;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.GroupByKey;
+import org.apache.beam.sdk.transforms.Impulse;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.util.construction.Environments;
+import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import org.apache.beam.sdk.util.construction.PipelineTranslation;
+import org.apache.beam.sdk.util.construction.SplittableParDo;
+import org.apache.beam.sdk.values.KV;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.joda.time.Duration;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.testcontainers.kafka.KafkaContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * Runs a pipeline through the production {@link KafkaStreamsPipelineRunner}
against a real Kafka
+ * broker, rather than through the {@code TopologyTestDriver} the rest of the
suite uses.
+ *
+ * <p>The test driver stands in for a broker well enough for translation and
windowing logic, but it
+ * runs one instance in one thread and fakes the topics. Everything that only
exists on a real
+ * cluster is untested by it: the runner creating its own bootstrap and
repartition topics, records
+ * actually round-tripping through a repartition topic, exactly-once
processing, the state stores'
+ * changelog, and the Kafka Streams application lifecycle. This test covers
that path.
+ *
+ * <p>It needs Docker and so is not part of the default build; the {@code
brokerIntegrationTest}
+ * Gradle task runs it.
+ */
+@RunWith(JUnit4.class)
+public class KafkaStreamsRunnerBrokerIT {
+
+ private static final String NAMESPACE = "brokerIT";
+ private static final String GROUPS_COUNTER = "groups";
+
+ /** How long to wait for the streaming application to work through the
pipeline. */
+ private static final Duration TIMEOUT = Duration.standardMinutes(2);
+
+ private static KafkaContainer kafka;
+
+ @BeforeClass
+ public static void startBroker() {
+ // The official Apache Kafka image. 4.0.0 rather than the 3.9.0 the
runner's client is built
+ // against: Testcontainers' KafkaContainer cannot bring up the 3.9.0 image
(it exits during
+ // startup), and a client talking to a newer broker is the compatibility
direction Kafka
+ // supports anyway.
+ kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:4.0.0"));
+ kafka.start();
+ }
+
+ @AfterClass
+ public static void stopBroker() {
+ if (kafka != null) {
+ kafka.stop();
+ }
+ }
+
+ /** Emits a fixed set of keyed elements, one per key group. */
+ private static class EmitKvsFn extends DoFn<byte[], KV<String, Integer>> {
+ @ProcessElement
+ public void processElement(OutputReceiver<KV<String, Integer>> out) {
+ out.output(KV.of("a", 1));
+ out.output(KV.of("a", 2));
+ out.output(KV.of("b", 3));
+ }
+ }
+
+ /** Counts the groups that come out of the GroupByKey. */
+ private static class CountGroupsFn extends DoFn<KV<String,
Iterable<Integer>>, Void> {
+ private final Counter groups = Metrics.counter(NAMESPACE, GROUPS_COUNTER);
+
+ @ProcessElement
+ public void processElement() {
+ groups.inc();
+ }
+ }
+
+ private KafkaStreamsPipelineOptions options() {
+ return options(1);
+ }
+
+ private KafkaStreamsPipelineOptions options(int topicPartitions) {
+ KafkaStreamsPipelineOptions options =
+ PipelineOptionsFactory.create().as(KafkaStreamsPipelineOptions.class);
+ options.setRunner(CrashingRunner.class);
+ options.setBootstrapServers(kafka.getBootstrapServers());
+ options.setApplicationId("ks-broker-it-" + UUID.randomUUID());
+ options.setInternalParallelism(topicPartitions);
+ options
+ .as(PortablePipelineOptions.class)
+ .setDefaultEnvironmentType(Environments.ENVIRONMENT_EMBEDDED);
+ try {
+
options.setStateDir(Files.createTempDirectory("ks-broker-it").toString());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return options;
+ }
+
+ @Test
+ public void groupByKeyRunsThroughARealBrokerAndReportsMetrics() throws
Exception {
+ KafkaStreamsPipelineOptions options = options();
+ Pipeline pipeline = Pipeline.create(options);
+ pipeline
+ .apply(Impulse.create())
+ .apply("emit", ParDo.of(new EmitKvsFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()))
+ .apply(GroupByKey.create())
+ .apply("countGroups", ParDo.of(new CountGroupsFn()));
+
+ // The same conversion the test runner does: translate Read-based sources
as the primitive Read
+ // the runner supports rather than the splittable-DoFn expansion.
+ SplittableParDo.convertReadBasedSplittableDoFnsToPrimitiveReads(pipeline);
+ RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
+ JobInfo jobInfo =
+ JobInfo.create(
+ options.getApplicationId(),
+ options.getJobName(),
+ "",
+ PipelineOptionsTranslation.toProto(options));
+
+ PipelineResult result = new
KafkaStreamsPipelineRunner(options).run(pipelineProto, jobInfo);
+ try {
+ // Two keys in, so two groups out once the elements have travelled
through the repartition
+ // topic and the watermark has closed the global window.
+ assertThat(awaitCounter(result, 2L), is(2L));
+ } finally {
+ result.cancel();
+ }
+ }
+
+ /**
+ * Collapses every group onto one key, so the next GroupByKey has to shuffle
across partitions.
+ */
+ private static class ToSingleKeyFn
+ extends DoFn<KV<String, Iterable<Integer>>, KV<String, Integer>> {
+ @ProcessElement
+ public void processElement(
+ @Element KV<String, Iterable<Integer>> group,
OutputReceiver<KV<String, Integer>> out) {
+ int sum = 0;
+ for (int value : group.getValue()) {
+ sum += value;
+ }
+ out.output(KV.of("all", sum));
+ }
+ }
+
+ /** Builds the two-GroupByKey pipeline used by the chained tests. */
+ private static void buildChainedPipeline(Pipeline pipeline) {
+ pipeline
+ .apply(Impulse.create())
+ .apply("emit", ParDo.of(new EmitKvsFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()))
+ .apply("groupPerKey", GroupByKey.create())
+ .apply("toSingleKey", ParDo.of(new ToSingleKeyFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()))
+ .apply("groupAll", GroupByKey.create())
+ .apply("countGroups", ParDo.of(new CountGroupsFn()));
+ }
+
+ private static PipelineResult runPipeline(
+ Pipeline pipeline, KafkaStreamsPipelineOptions options) {
+ SplittableParDo.convertReadBasedSplittableDoFnsToPrimitiveReads(pipeline);
+ RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
+ JobInfo jobInfo =
+ JobInfo.create(
+ options.getApplicationId(),
+ options.getJobName(),
+ "",
+ PipelineOptionsTranslation.toProto(options));
+ return new KafkaStreamsPipelineRunner(options).run(pipelineProto, jobInfo);
+ }
+
+ @Test
+ public void chainedGroupByKeysAreCorrectOnOnePartition() throws Exception {
+ // The control for the partitioned case below: the same shape, one
partition throughout.
+ KafkaStreamsPipelineOptions options = options(1);
+ Pipeline pipeline = Pipeline.create(options);
+ buildChainedPipeline(pipeline);
+
+ PipelineResult result = runPipeline(pipeline, options);
+ try {
+ assertThat(awaitCounter(result, 1L), is(1L));
+ } finally {
+ result.cancel();
+ }
+ }
+
+ @Test
+ public void chainedGroupByKeysAreCorrectAcrossPartitions() throws Exception {
+ // Two GroupByKeys with a partitioned shuffle between them, which is what
makes each task's
+ // watermark identity matter. The second GroupByKey aggregates the reports
of every task of the
+ // first, so those tasks have to report under their own partition: if each
claimed to be the
+ // only partition, the second would advance its watermark on the first
report it saw and fire
+ // before the remaining partitions had contributed their groups.
+ KafkaStreamsPipelineOptions options = options(4);
+ Pipeline pipeline = Pipeline.create(options);
+ buildChainedPipeline(pipeline);
+
+ PipelineResult result = runPipeline(pipeline, options);
+ try {
+ // Everything collapses onto one key, so the second GroupByKey emits
exactly one group — and
+ // only once every partition of the first has contributed to it.
+ assertThat(awaitCounter(result, 1L), is(1L));
+ // A premature firing would show up as a second group, so give one a
chance to appear.
+ Thread.sleep(5_000L);
+ assertThat(counterValue(result), is(1L));
+ } finally {
+ result.cancel();
+ }
+ }
+
+ /**
+ * Polls the pipeline's metrics until the counter reaches {@code expected}
or the timeout hits.
+ */
+ private static long awaitCounter(PipelineResult result, long expected)
throws Exception {
+ long deadline = System.currentTimeMillis() + TIMEOUT.getMillis();
+ long value = 0;
+ while (System.currentTimeMillis() < deadline) {
+ value = counterValue(result);
+ if (value >= expected) {
+ return value;
+ }
+ Thread.sleep(500L);
+ }
+ return value;
+ }
+
+ private static long counterValue(PipelineResult result) {
+ MetricQueryResults query =
+ result
+ .metrics()
+ .queryMetrics(
+ MetricsFilter.builder()
+ .addNameFilter(MetricNameFilter.named(NAMESPACE,
GROUPS_COUNTER))
+ .build());
+ if (Iterables.isEmpty(query.getCounters())) {
+ return 0L;
+ }
+ MetricResult<Long> counter = Iterables.getOnlyElement(query.getCounters());
+ return counter.getAttempted();
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessorTest.java
new file mode 100644
index 00000000000..38669138075
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ShuffleByKeyProcessorTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams.translation;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Properties;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.values.WindowedValues;
+import org.apache.kafka.streams.processor.TaskId;
+import org.apache.kafka.streams.processor.api.MockProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.junit.Test;
+
+/**
+ * Tests how {@link ShuffleByKeyProcessor} restamps a watermark report as it
is about to cross a
+ * repartition topic.
+ *
+ * <p>Upstream of the shuffle a transform forwards its watermark in process,
to its fused children,
+ * which see exactly one instance of it — so the report names a single source.
The sink below the
+ * shuffle broadcasts each report to every partition, so a downstream task
sees a report from every
+ * instance of the upstream transform and has to tell them apart to know when
it has heard from all
+ * of them. The shuffle is where that identity is attached.
+ */
+public class ShuffleByKeyProcessorTest {
+
+ private static final String UPSTREAM_ID = "upstream";
+
+ @SuppressWarnings("unchecked")
+ private static ShuffleByKeyProcessor processorFor(int taskPartition, int
upstreamPartitions) {
+ ShuffleByKeyProcessor processor =
+ new ShuffleByKeyProcessor(
+ (org.apache.beam.sdk.coders.Coder<Object>)
+ (org.apache.beam.sdk.coders.Coder<?>) StringUtf8Coder.of(),
+ upstreamPartitions);
+ MockProcessorContext<byte[], KStreamsPayload<?>> ctx =
+ new MockProcessorContext<>(new Properties(), new TaskId(0,
taskPartition), null);
+ processor.init(ctx);
+ lastContext = ctx;
+ return processor;
+ }
+
+ private static MockProcessorContext<byte[], KStreamsPayload<?>> lastContext;
+
+ private static Record<byte[], KStreamsPayload<?>> watermark(long millis) {
+ // As forwarded in process by the upstream transform: a single source,
since a fused child sees
+ // exactly one instance of it.
+ return new Record<>(new byte[0], KStreamsPayload.watermark(millis,
UPSTREAM_ID, 0, 1), 0L);
+ }
+
+ @Test
+ public void restampsTheWatermarkWithTheUpstreamInstanceIdentity() {
+ // Instance 2 of a 4-instance upstream transform.
+ ShuffleByKeyProcessor processor = processorFor(2, 4);
+
+ processor.process(watermark(500L));
+
+ assertThat(lastContext.forwarded().size(), is(1));
+ WatermarkPayload out =
lastContext.forwarded().get(0).record().value().asWatermark();
+ assertThat(out.getWatermarkMillis(), is(500L));
+ // The transform id still names the producer, so a downstream aggregator
matches it to the
+ // upstream it expects; the partition identity is what it counts.
+ assertThat(out.getTransformId(), is(UPSTREAM_ID));
+ assertThat(out.getSourcePartition(), is(2));
+ assertThat(out.getTotalSourcePartitions(), is(4));
+ }
+
+ @Test
+ public void distinctUpstreamInstancesRestampDistinctly() {
+ processorFor(0, 4).process(watermark(100L));
+ WatermarkPayload first =
lastContext.forwarded().get(0).record().value().asWatermark();
+ processorFor(3, 4).process(watermark(100L));
+ WatermarkPayload second =
lastContext.forwarded().get(0).record().value().asWatermark();
+
+ // Two instances of the same transform must be distinguishable downstream,
or a consumer would
+ // treat one report as if every instance had already reported.
+ assertThat(first.getSourcePartition(), is(0));
+ assertThat(second.getSourcePartition(), is(3));
+ }
+
+ @Test
+ public void anUnpartitionedUpstreamStillReportsASingleSource() {
+ ShuffleByKeyProcessor processor = processorFor(0, 1);
+
+ processor.process(watermark(700L));
+
+ WatermarkPayload out =
lastContext.forwarded().get(0).record().value().asWatermark();
+ assertThat(out.getSourcePartition(), is(0));
+ assertThat(out.getTotalSourcePartitions(), is(1));
+ }
+
+ @Test
+ public void dataIsRekeyedByTheBeamKeyAndNotRestamped() {
+ ShuffleByKeyProcessor processor = processorFor(1, 4);
+
+ processor.process(
+ new Record<>(
+ new byte[0],
+ KStreamsPayload.data(
+ WindowedValues.valueInGlobalWindow(
+ org.apache.beam.sdk.values.KV.of("key", "value"))),
+ 0L));
+
+ assertThat(lastContext.forwarded().size(), is(1));
+ assertThat(lastContext.forwarded().get(0).record().value().isData(),
is(true));
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StandardWindowFnTranslationTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StandardWindowFnTranslationTest.java
new file mode 100644
index 00000000000..912d51d6b5d
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/StandardWindowFnTranslationTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+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.DoFn;
+import org.apache.beam.sdk.transforms.GroupByKey;
+import org.apache.beam.sdk.transforms.Impulse;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.FixedWindows;
+import org.apache.beam.sdk.transforms.windowing.IntervalWindow;
+import org.apache.beam.sdk.transforms.windowing.SlidingWindows;
+import org.apache.beam.sdk.transforms.windowing.Window;
+import org.apache.beam.sdk.transforms.windowing.WindowFn;
+import org.apache.beam.sdk.util.construction.PipelineTranslation;
+import org.apache.beam.sdk.util.construction.RehydratedComponents;
+import org.apache.beam.sdk.util.construction.WindowingStrategyTranslation;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.WindowingStrategy;
+import org.joda.time.Duration;
+import org.junit.Test;
+
+/**
+ * Checks that the runner reconstructs the standard WindowFns from the
language-neutral windowing
+ * strategy in the pipeline proto.
+ *
+ * <p>The runner executes GroupAlsoByWindow itself (see {@link
WindowedGroupByKeyProcessor}), so it
+ * has to rebuild the WindowFn from the proto rather than call into the SDK.
Beam gives the standard
+ * WindowFns a URN and a parameter payload — {@code
beam:window_fn:fixed_windows:v1} and friends —
+ * and those are what {@link org.apache.beam.runners.core.ReduceFnRunner}
interprets directly. Every
+ * SDK emits the same URNs for them, so a pipeline built in another language
that uses fixed,
+ * sliding, session or global windows produces a strategy this runner can
rebuild; these tests pin
+ * that down.
+ *
+ * <p>What this does <em>not</em> cover is a WindowFn the user wrote
themselves. That cannot be
+ * interpreted runner-side at all: it is opaque to the runner and would have
to be executed through
+ * the SDK harness that owns it. The runner does not support that today —
{@code
+ * WindowingStrategyTranslation.windowFnFromProto} rejects an unrecognised URN
— and it is the case
+ * that would really exercise cross-language windowing.
+ */
+public class StandardWindowFnTranslationTest {
+
+ private static final Duration WINDOW_SIZE = Duration.millis(10);
+
+ private static class EmitKvFn extends DoFn<byte[], KV<String, Integer>> {
+ @ProcessElement
+ public void processElement(OutputReceiver<KV<String, Integer>> out) {
+ out.output(KV.of("a", 1));
+ }
+ }
+
+ /** A windowed GroupByKey pipeline, as a proto — the form the runner is
handed. */
+ private static RunnerApi.Pipeline windowedPipelineProto(WindowFn<Object, ?>
windowFn) {
+ Pipeline pipeline = Pipeline.create(KafkaStreamsTestRunner.testOptions());
+ pipeline
+ .apply(Impulse.create())
+ .apply(ParDo.of(new EmitKvFn()))
+ .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of()))
+ .apply(Window.into(windowFn))
+ .apply(GroupByKey.create());
+ return PipelineTranslation.toProto(pipeline);
+ }
+
+ /** The windowing strategy of the GroupByKey's input, which is what the
translator reads. */
+ private static RunnerApi.WindowingStrategy
nonGlobalStrategy(RunnerApi.Pipeline proto) {
+ for (RunnerApi.WindowingStrategy strategy :
+ proto.getComponents().getWindowingStrategiesMap().values()) {
+ if (!strategy
+ .getWindowFn()
+ .getUrn()
+ .equals(WindowingStrategyTranslation.GLOBAL_WINDOWS_URN)) {
+ return strategy;
+ }
+ }
+ throw new AssertionError("pipeline has no non-global windowing strategy");
+ }
+
+ @Test
+ public void fixedWindowsTravelAsTheStandardUrn() {
+ RunnerApi.WindowingStrategy strategy =
+ nonGlobalStrategy(windowedPipelineProto(FixedWindows.of(WINDOW_SIZE)));
+
+ // The same URN and payload any SDK emits for fixed windows.
+ assertThat(strategy.getWindowFn().getUrn(),
is(WindowingStrategyTranslation.FIXED_WINDOWS_URN));
+ }
+
+ @Test
+ public void slidingWindowsTravelAsTheStandardUrn() {
+ RunnerApi.WindowingStrategy strategy =
+ nonGlobalStrategy(
+
windowedPipelineProto(SlidingWindows.of(WINDOW_SIZE).every(Duration.millis(5))));
+
+ assertThat(
+ strategy.getWindowFn().getUrn(),
is(WindowingStrategyTranslation.SLIDING_WINDOWS_URN));
+ }
+
+ @Test
+ public void aStandardWindowFnNeedsNoJavaSerialization() {
+ RunnerApi.Pipeline proto =
windowedPipelineProto(FixedWindows.of(WINDOW_SIZE));
+
+ // Java serialization is the fallback for a WindowFn with no standard URN.
A strategy that fell
+ // back to it here would only be reconstructable by a Java runner reading
a Java pipeline.
+ for (RunnerApi.WindowingStrategy strategy :
+ proto.getComponents().getWindowingStrategiesMap().values()) {
+ assertThat(
+ strategy.getWindowFn().getUrn(),
+ is(not(WindowingStrategyTranslation.SERIALIZED_JAVA_WINDOWFN_URN)));
+ }
+ }
+
+ @Test
+ public void hydratingTheStandardUrnRebuildsTheWindowFnTheRunnerRunsWith()
throws Exception {
+ RunnerApi.Pipeline proto =
windowedPipelineProto(FixedWindows.of(WINDOW_SIZE));
+ RunnerApi.WindowingStrategy strategy = nonGlobalStrategy(proto);
+
+ // The translator's own path: rebuild the strategy from the proto alone.
+ WindowingStrategy<?, ?> hydrated =
+ WindowingStrategyTranslation.fromProto(
+ strategy,
RehydratedComponents.forComponents(proto.getComponents()));
+
+ assertThat(hydrated.getWindowFn(), instanceOf(FixedWindows.class));
+ assertThat(((FixedWindows) hydrated.getWindowFn()).getSize(),
is(WINDOW_SIZE));
+ // The window coder the runner encodes state and timers with comes from
this WindowFn, so it is
+ // the standard interval-window coder rather than anything SDK-specific.
+ assertThat(hydrated.getWindowFn().windowCoder(),
is(IntervalWindow.getCoder()));
+ }
+}