Skip to content

Commit 4d5847f

Browse files
[GSoC 2026] Kafka Streams runner #39211: Add KafkaStreamsTestRunner test harness
1 parent 5e65d47 commit 4d5847f

5 files changed

Lines changed: 251 additions & 299 deletions

File tree

runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java

Lines changed: 9 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,11 @@
2424
import java.util.ArrayList;
2525
import java.util.Collections;
2626
import java.util.List;
27-
import java.util.Properties;
2827
import org.apache.beam.model.pipeline.v1.RunnerApi;
29-
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
3028
import org.apache.beam.runners.kafka.streams.translation.KStreamsPayload;
31-
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsPipelineTranslator;
32-
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsTranslationContext;
3329
import org.apache.beam.sdk.Pipeline;
34-
import org.apache.beam.sdk.options.PipelineOptions;
35-
import org.apache.beam.sdk.options.PipelineOptionsFactory;
36-
import org.apache.beam.sdk.testing.CrashingRunner;
3730
import org.apache.beam.sdk.transforms.Impulse;
3831
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
39-
import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
40-
import org.apache.beam.sdk.util.construction.PipelineTranslation;
41-
import org.apache.kafka.common.serialization.Serdes;
42-
import org.apache.kafka.streams.StreamsConfig;
4332
import org.apache.kafka.streams.Topology;
4433
import org.apache.kafka.streams.TopologyTestDriver;
4534
import org.apache.kafka.streams.processor.api.Processor;
@@ -51,8 +40,8 @@
5140

5241
/**
5342
* Pipeline-level integration tests that build a Beam {@link Pipeline} via the high-level Java SDK
54-
* ({@code Pipeline.create().apply(Impulse.create())}), translate it via the runner, and execute the
55-
* resulting Kafka Streams topology under {@link TopologyTestDriver}.
43+
* ({@code Pipeline.create().apply(Impulse.create())}) and run it through {@link
44+
* KafkaStreamsTestRunner}.
5645
*
5746
* <p>This is the test layer Jan requested on PR #38689: rather than building hand-rolled {@link
5847
* RunnerApi.Pipeline} protos, drive translation from the same surface a user would write. The tests
@@ -61,33 +50,19 @@
6150
*/
6251
public class KafkaStreamsRunnerTest {
6352

64-
private static final String JOB_ID = "kafka-streams-runner-test";
65-
private static final String APPLICATION_ID = "ks-runner-test";
66-
6753
@Test
6854
public void impulseOnlyPipelineEmitsDataAndTerminalWatermark() {
69-
Pipeline pipeline = Pipeline.create(pipelineOptions());
55+
Pipeline pipeline = Pipeline.create(KafkaStreamsTestRunner.testOptions());
7056
pipeline.apply("impulse", Impulse.create());
7157

72-
RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
73-
74-
KafkaStreamsPipelineOptions options =
75-
pipeline.getOptions().as(KafkaStreamsPipelineOptions.class);
76-
KafkaStreamsPipelineTranslator translator = new KafkaStreamsPipelineTranslator();
77-
JobInfo jobInfo =
78-
JobInfo.create(
79-
JOB_ID, options.getJobName(), "", PipelineOptionsTranslation.toProto(options));
80-
KafkaStreamsTranslationContext context = translator.createTranslationContext(jobInfo, options);
81-
translator.translate(context, translator.prepareForTranslation(pipelineProto));
82-
8358
CapturingProcessor capture = new CapturingProcessor();
84-
Topology topology = context.getTopology();
85-
// Wire a downstream test sink to every translated transform node so we can capture emissions.
86-
// Impulse is the only transform here, so we attach to "impulse" (the processor name registered
87-
// by ImpulseTranslator).
88-
topology.addProcessor("capture", capture, expectedImpulseProcessorName(pipelineProto));
59+
Topology topology = KafkaStreamsTestRunner.translate(pipeline).getTopology();
60+
// Impulse is the only transform, so it is the topology leaf; capture what it forwards.
61+
topology.addProcessor(
62+
"capture", capture, KafkaStreamsTestRunner.findAnyLeafProcessorName(topology));
8963

90-
try (TopologyTestDriver driver = new TopologyTestDriver(topology, streamsConfig())) {
64+
try (TopologyTestDriver driver =
65+
new TopologyTestDriver(topology, KafkaStreamsTestRunner.streamsConfig(pipeline))) {
9166
driver.advanceWallClockTime(Duration.ofSeconds(1));
9267
driver.advanceWallClockTime(Duration.ofSeconds(1));
9368
}
@@ -101,42 +76,6 @@ public void impulseOnlyPipelineEmitsDataAndTerminalWatermark() {
10176
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
10277
}
10378

104-
/**
105-
* Finds the transform id that {@link Impulse} got assigned by the SDK so the test can attach a
106-
* capturing processor to the matching Kafka Streams processor node (the translator names the
107-
* processor after the transform id).
108-
*/
109-
private static String expectedImpulseProcessorName(RunnerApi.Pipeline pipelineProto) {
110-
for (java.util.Map.Entry<String, RunnerApi.PTransform> entry :
111-
pipelineProto.getComponents().getTransformsMap().entrySet()) {
112-
if ("beam:transform:impulse:v1".equals(entry.getValue().getSpec().getUrn())) {
113-
return entry.getKey();
114-
}
115-
}
116-
throw new IllegalStateException("Impulse transform not found in pipeline proto");
117-
}
118-
119-
private static PipelineOptions pipelineOptions() {
120-
PipelineOptions options =
121-
PipelineOptionsFactory.fromArgs("--applicationId=" + APPLICATION_ID).create();
122-
// Pipeline.create() requires a runner; CrashingRunner is the conventional "this pipeline is
123-
// not going to be run() directly" choice used by other portable-runner tests.
124-
options.setRunner(CrashingRunner.class);
125-
options.as(KafkaStreamsPipelineOptions.class).setApplicationId(APPLICATION_ID);
126-
return options;
127-
}
128-
129-
private static Properties streamsConfig() {
130-
Properties props = new Properties();
131-
props.put(StreamsConfig.APPLICATION_ID_CONFIG, APPLICATION_ID);
132-
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
133-
props.put(
134-
StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName());
135-
props.put(
136-
StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName());
137-
return props;
138-
}
139-
14079
private static class CapturingProcessor
14180
implements ProcessorSupplier<
14281
byte[], KStreamsPayload<byte[]>, byte[], KStreamsPayload<byte[]>> {
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams;
19+
20+
import java.time.Duration;
21+
import java.util.ArrayList;
22+
import java.util.HashSet;
23+
import java.util.List;
24+
import java.util.Properties;
25+
import java.util.Set;
26+
import java.util.UUID;
27+
import org.apache.beam.model.pipeline.v1.RunnerApi;
28+
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
29+
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsPipelineTranslator;
30+
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsTranslationContext;
31+
import org.apache.beam.sdk.Pipeline;
32+
import org.apache.beam.sdk.options.PipelineOptions;
33+
import org.apache.beam.sdk.options.PipelineOptionsFactory;
34+
import org.apache.beam.sdk.options.PortablePipelineOptions;
35+
import org.apache.beam.sdk.testing.CrashingRunner;
36+
import org.apache.beam.sdk.util.construction.Environments;
37+
import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
38+
import org.apache.beam.sdk.util.construction.PipelineTranslation;
39+
import org.apache.kafka.common.serialization.ByteArrayDeserializer;
40+
import org.apache.kafka.common.serialization.ByteArraySerializer;
41+
import org.apache.kafka.common.serialization.Serdes;
42+
import org.apache.kafka.streams.StreamsConfig;
43+
import org.apache.kafka.streams.TestInputTopic;
44+
import org.apache.kafka.streams.TestOutputTopic;
45+
import org.apache.kafka.streams.Topology;
46+
import org.apache.kafka.streams.TopologyDescription;
47+
import org.apache.kafka.streams.TopologyTestDriver;
48+
import org.apache.kafka.streams.test.TestRecord;
49+
50+
/**
51+
* Test harness that runs a Beam {@link Pipeline} through the Kafka Streams runner's translation and
52+
* a {@link TopologyTestDriver}, so tests do not repeat the translate + drive boilerplate.
53+
*
54+
* <p>Usage: build a pipeline with {@link #testOptions()}, then call {@link #run(Pipeline)}. Side
55+
* effects (e.g. a {@code SharedTestCollector} written by a recording DoFn) have completed when it
56+
* returns.
57+
*
58+
* <p>{@link TopologyTestDriver} does not loop a low-level sink topic back into its source, so an
59+
* internal repartition topic (one that is both a sink and a source in the topology — e.g. the one
60+
* GroupByKey introduces) would otherwise dead-end. {@link #run(Pipeline)} discovers those topics
61+
* from the {@link TopologyDescription} and round-trips them until no more records flow, standing in
62+
* for the broker.
63+
*/
64+
public final class KafkaStreamsTestRunner {
65+
66+
private static final int MAX_ROUND_TRIPS = 100;
67+
68+
private KafkaStreamsTestRunner() {}
69+
70+
/** Pipeline options for a Kafka Streams runner test: the EMBEDDED harness and a unique app id. */
71+
public static PipelineOptions testOptions() {
72+
String applicationId = "ks-test-" + UUID.randomUUID();
73+
PipelineOptions options =
74+
PipelineOptionsFactory.fromArgs("--applicationId=" + applicationId).create();
75+
options.setRunner(CrashingRunner.class);
76+
options.as(KafkaStreamsPipelineOptions.class).setApplicationId(applicationId);
77+
options
78+
.as(PortablePipelineOptions.class)
79+
.setDefaultEnvironmentType(Environments.ENVIRONMENT_EMBEDDED);
80+
return options;
81+
}
82+
83+
/**
84+
* Translates the pipeline into a Kafka Streams {@link KafkaStreamsTranslationContext}. Tests that
85+
* need the {@link Topology} (e.g. to attach a capture processor before driving) use this and
86+
* build their own {@link TopologyTestDriver}; simpler tests use {@link #run(Pipeline)}.
87+
*/
88+
public static KafkaStreamsTranslationContext translate(Pipeline pipeline) {
89+
KafkaStreamsPipelineOptions options =
90+
pipeline.getOptions().as(KafkaStreamsPipelineOptions.class);
91+
RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
92+
KafkaStreamsPipelineTranslator translator = new KafkaStreamsPipelineTranslator();
93+
JobInfo jobInfo =
94+
JobInfo.create(
95+
options.getApplicationId(),
96+
options.getJobName(),
97+
"",
98+
PipelineOptionsTranslation.toProto(options));
99+
KafkaStreamsTranslationContext context = translator.createTranslationContext(jobInfo, options);
100+
translator.translate(context, translator.prepareForTranslation(pipelineProto));
101+
return context;
102+
}
103+
104+
/** Translates and drives the pipeline to quiescence through a {@link TopologyTestDriver}. */
105+
public static void run(Pipeline pipeline) {
106+
KafkaStreamsTranslationContext context = translate(pipeline);
107+
Topology topology = context.getTopology();
108+
try (TopologyTestDriver driver = new TopologyTestDriver(topology, streamsConfig(pipeline))) {
109+
// Fire the Impulse wall-clock punctuator and let the initial records flow.
110+
driver.advanceWallClockTime(Duration.ofSeconds(1));
111+
driver.advanceWallClockTime(Duration.ofSeconds(1));
112+
roundTripInternalTopics(driver, internalTopics(topology));
113+
}
114+
}
115+
116+
/**
117+
* The name of some processor node with no successors (a topology leaf). Tests with a single leaf
118+
* attach a capture processor here to observe what the last stage forwards; if a topology has more
119+
* than one leaf, which one is returned is unspecified.
120+
*/
121+
public static String findAnyLeafProcessorName(Topology topology) {
122+
for (TopologyDescription.Subtopology subtopology : topology.describe().subtopologies()) {
123+
for (TopologyDescription.Node node : subtopology.nodes()) {
124+
if (node instanceof TopologyDescription.Processor && node.successors().isEmpty()) {
125+
return node.name();
126+
}
127+
}
128+
}
129+
throw new IllegalStateException("no leaf processor found in topology");
130+
}
131+
132+
/** Repartition/internal topics are the ones that appear as both a sink and a source. */
133+
private static Set<String> internalTopics(Topology topology) {
134+
Set<String> sinkTopics = new HashSet<>();
135+
Set<String> sourceTopics = new HashSet<>();
136+
for (TopologyDescription.Subtopology subtopology : topology.describe().subtopologies()) {
137+
for (TopologyDescription.Node node : subtopology.nodes()) {
138+
if (node instanceof TopologyDescription.Sink) {
139+
String topic = ((TopologyDescription.Sink) node).topic();
140+
if (topic != null) {
141+
sinkTopics.add(topic);
142+
}
143+
} else if (node instanceof TopologyDescription.Source) {
144+
sourceTopics.addAll(((TopologyDescription.Source) node).topicSet());
145+
}
146+
}
147+
}
148+
sinkTopics.retainAll(sourceTopics);
149+
return sinkTopics;
150+
}
151+
152+
/**
153+
* Simulates the broker for internal repartition topics.
154+
*
155+
* <p>The runner shuffles data (and the watermark) through internal topics that a processor both
156+
* writes to (a sink) and reads back from (a source) — e.g. the topic GroupByKey introduces to
157+
* partition by key. On a real broker those records make the round trip automatically, but {@link
158+
* TopologyTestDriver} does not connect a sink back to a source, so the downstream half of the
159+
* topology would never see them. This drains what each internal topic's sink wrote and pipes it
160+
* into that topic's source, repeating until nothing new flows (a fixpoint), which stands in for
161+
* the broker and lets the pipeline run to completion.
162+
*/
163+
private static void roundTripInternalTopics(TopologyTestDriver driver, Set<String> topics) {
164+
// Create the sink-output and source-input handles once and reuse them across rounds; a single
165+
// TestOutputTopic keeps returning newly produced records on each read.
166+
List<TopicRoundTrip> roundTrips = new ArrayList<>();
167+
for (String topic : topics) {
168+
roundTrips.add(
169+
new TopicRoundTrip(
170+
driver.createOutputTopic(
171+
topic, new ByteArrayDeserializer(), new ByteArrayDeserializer()),
172+
driver.createInputTopic(
173+
topic, new ByteArraySerializer(), new ByteArraySerializer())));
174+
}
175+
176+
for (int round = 0; round < MAX_ROUND_TRIPS; round++) {
177+
boolean progressed = false;
178+
for (TopicRoundTrip roundTrip : roundTrips) {
179+
List<TestRecord<byte[], byte[]>> records = roundTrip.output.readRecordsToList();
180+
if (records.isEmpty()) {
181+
continue;
182+
}
183+
progressed = true;
184+
for (TestRecord<byte[], byte[]> record : records) {
185+
roundTrip.input.pipeInput(record);
186+
}
187+
}
188+
if (!progressed) {
189+
return;
190+
}
191+
}
192+
throw new IllegalStateException(
193+
"Internal topics did not reach quiescence after " + MAX_ROUND_TRIPS + " round trips");
194+
}
195+
196+
/** The reusable sink-output and source-input handles for one internal topic. */
197+
private static final class TopicRoundTrip {
198+
final TestOutputTopic<byte[], byte[]> output;
199+
final TestInputTopic<byte[], byte[]> input;
200+
201+
TopicRoundTrip(TestOutputTopic<byte[], byte[]> output, TestInputTopic<byte[], byte[]> input) {
202+
this.output = output;
203+
this.input = input;
204+
}
205+
}
206+
207+
/** Kafka Streams config for a {@link TopologyTestDriver} built from the pipeline's app id. */
208+
public static Properties streamsConfig(Pipeline pipeline) {
209+
String applicationId =
210+
pipeline.getOptions().as(KafkaStreamsPipelineOptions.class).getApplicationId();
211+
Properties props = new Properties();
212+
props.put(StreamsConfig.APPLICATION_ID_CONFIG, applicationId);
213+
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
214+
props.put(
215+
StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName());
216+
props.put(
217+
StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName());
218+
return props;
219+
}
220+
}

0 commit comments

Comments
 (0)