Skip to content

Commit d9cea58

Browse files
[GSoC 2026] Kafka Streams runner: validatesRunner task; Create and Flatten suites green (#39380)
1 parent 87911f0 commit d9cea58

7 files changed

Lines changed: 246 additions & 10 deletions

File tree

.github/workflows/beam_KafkaStreamsRunner_FeatureBranch.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,5 @@ jobs:
7070
${{ runner.os }}-gradle-kafka-streams-
7171
- name: Build and test Kafka Streams runner
7272
run: ./gradlew :runners:kafka-streams:build --no-daemon --stacktrace
73+
- name: Run ValidatesRunner suite
74+
run: ./gradlew :runners:kafka-streams:validatesRunner --no-daemon --stacktrace

runners/kafka-streams/build.gradle

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
* limitations under the License.
1717
*/
1818

19+
import groovy.json.JsonOutput
20+
1921
plugins { id 'org.apache.beam.module' }
2022

2123
def kafka_version = '3.9.0'
@@ -29,6 +31,10 @@ description = "Apache Beam :: Runners :: Kafka Streams"
2931
evaluationDependsOn(":sdks:java:core")
3032
evaluationDependsOn(":runners:core-java")
3133

34+
configurations {
35+
validatesRunner
36+
}
37+
3238
configurations.configureEach {
3339
resolutionStrategy.eachDependency { details ->
3440
if (details.requested.group == "org.apache.kafka") {
@@ -68,4 +74,74 @@ dependencies {
6874
testImplementation library.java.junit
6975
testImplementation library.java.mockito_core
7076
testImplementation "org.apache.kafka:kafka-streams-test-utils:$kafka_version"
77+
78+
// Beam's @ValidatesRunner suite: the test classes come from the SDK core test jar; the runner
79+
// (TestKafkaStreamsRunner) and its TopologyTestDriver harness come from this module's test
80+
// output and test runtime classpath.
81+
validatesRunner project(path: ":sdks:java:core", configuration: "shadowTest")
82+
validatesRunner project(project.path)
83+
validatesRunner sourceSets.test.output
84+
validatesRunner sourceSets.test.runtimeClasspath
85+
}
86+
87+
88+
// Known-failing @ValidatesRunner tests, excluded until the feature they need lands.
89+
def sickbayTests = [
90+
// Needs multi-output executable stages (output-tag dispatch in ExecutableStageProcessor).
91+
'org.apache.beam.sdk.transforms.FlattenTest.testFlattenMultiplePCollectionsHavingMultipleConsumers',
92+
]
93+
94+
tasks.register("validatesRunner", Test) {
95+
group = "Verification"
96+
description = "Runs the subset of Beam's ValidatesRunner suite the Kafka Streams runner supports."
97+
// Never consider up-to-date; the suite is the correctness gate.
98+
outputs.upToDateWhen { false }
99+
systemProperty "beamTestPipelineOptions",
100+
JsonOutput.toJson(["--runner=org.apache.beam.runners.kafka.streams.TestKafkaStreamsRunner"])
101+
classpath = configurations.validatesRunner
102+
testClassesDirs = files(project(":sdks:java:core").sourceSets.test.output.classesDirs)
103+
maxParallelForks 2
104+
useJUnit {
105+
includeCategories 'org.apache.beam.sdk.testing.ValidatesRunner'
106+
// Environment / harness features that need a properly configured external environment.
107+
excludeCategories 'org.apache.beam.sdk.testing.UsesExternalService'
108+
excludeCategories 'org.apache.beam.sdk.testing.UsesSdkHarnessEnvironment'
109+
excludeCategories 'org.apache.beam.sdk.testing.UsesBundleFinalizer'
110+
excludeCategories 'org.apache.beam.sdk.testing.UsesJavaExpansionService'
111+
excludeCategories 'org.apache.beam.sdk.testing.UsesPythonExpansionService'
112+
// Features the runner does not support yet.
113+
excludeCategories 'org.apache.beam.sdk.testing.UsesSideInputs'
114+
excludeCategories 'org.apache.beam.sdk.testing.UsesStatefulParDo'
115+
excludeCategories 'org.apache.beam.sdk.testing.UsesTimersInParDo'
116+
excludeCategories 'org.apache.beam.sdk.testing.UsesTimerMap'
117+
excludeCategories 'org.apache.beam.sdk.testing.UsesLoopingTimer'
118+
excludeCategories 'org.apache.beam.sdk.testing.UsesStrictTimerOrdering'
119+
excludeCategories 'org.apache.beam.sdk.testing.UsesProcessingTimeTimers'
120+
excludeCategories 'org.apache.beam.sdk.testing.UsesOnWindowExpiration'
121+
excludeCategories 'org.apache.beam.sdk.testing.UsesTestStream'
122+
excludeCategories 'org.apache.beam.sdk.testing.UsesUnboundedPCollections'
123+
excludeCategories 'org.apache.beam.sdk.testing.UsesUnboundedSplittableParDo'
124+
excludeCategories 'org.apache.beam.sdk.testing.UsesBoundedSplittableParDo'
125+
excludeCategories 'org.apache.beam.sdk.testing.UsesCustomWindowMerging'
126+
excludeCategories 'org.apache.beam.sdk.testing.UsesMetricsPusher'
127+
excludeCategories 'org.apache.beam.sdk.testing.UsesCommittedMetrics'
128+
excludeCategories 'org.apache.beam.sdk.testing.UsesSystemMetrics'
129+
excludeCategories 'org.apache.beam.sdk.testing.UsesOrderedListState'
130+
excludeCategories 'org.apache.beam.sdk.testing.UsesMultimapState'
131+
excludeCategories 'org.apache.beam.sdk.testing.UsesMapState'
132+
excludeCategories 'org.apache.beam.sdk.testing.UsesSetState'
133+
excludeCategories 'org.apache.beam.sdk.testing.FlattenWithHeterogeneousCoders'
134+
excludeCategories 'org.apache.beam.sdk.testing.LargeKeys$Above100MB'
135+
}
136+
filter {
137+
// The suites enabled so far, extended class by class as runner support grows. An explicit
138+
// include list is needed because feature gaps like windowing beyond the global window have no
139+
// JUnit category to exclude.
140+
includeTestsMatching 'org.apache.beam.sdk.transforms.CreateTest'
141+
includeTestsMatching 'org.apache.beam.sdk.transforms.FlattenTest'
142+
for (String test : sickbayTests) {
143+
excludeTestsMatching test
144+
}
145+
failOnNoMatchingTests = false
146+
}
71147
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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.translation;
19+
20+
import java.io.IOException;
21+
import java.util.Collections;
22+
import java.util.List;
23+
import java.util.NoSuchElementException;
24+
import org.apache.beam.sdk.coders.ByteArrayCoder;
25+
import org.apache.beam.sdk.coders.Coder;
26+
import org.apache.beam.sdk.io.BoundedSource;
27+
import org.apache.beam.sdk.options.PipelineOptions;
28+
29+
/**
30+
* A {@link BoundedSource} with no elements. The runner substitutes it for a {@code Flatten} of zero
31+
* PCollections (see {@link KafkaStreamsPipelineTranslator}): reading it produces no data and a
32+
* terminal watermark — exactly the semantics of an empty PCollection. The element type is never
33+
* observed since no element is ever produced.
34+
*/
35+
class EmptyBoundedSource extends BoundedSource<byte[]> {
36+
37+
@Override
38+
public List<? extends BoundedSource<byte[]>> split(
39+
long desiredBundleSizeBytes, PipelineOptions options) {
40+
return Collections.singletonList(this);
41+
}
42+
43+
@Override
44+
public long getEstimatedSizeBytes(PipelineOptions options) {
45+
return 0;
46+
}
47+
48+
@Override
49+
public BoundedReader<byte[]> createReader(PipelineOptions options) {
50+
return new EmptyReader(this);
51+
}
52+
53+
@Override
54+
public Coder<byte[]> getOutputCoder() {
55+
return ByteArrayCoder.of();
56+
}
57+
58+
/** A reader that is exhausted from the start. */
59+
private static final class EmptyReader extends BoundedReader<byte[]> {
60+
private final EmptyBoundedSource source;
61+
62+
EmptyReader(EmptyBoundedSource source) {
63+
this.source = source;
64+
}
65+
66+
@Override
67+
public boolean start() {
68+
return false;
69+
}
70+
71+
@Override
72+
public boolean advance() {
73+
return false;
74+
}
75+
76+
@Override
77+
public byte[] getCurrent() throws NoSuchElementException {
78+
throw new NoSuchElementException("EmptyBoundedSource has no elements");
79+
}
80+
81+
@Override
82+
public void close() throws IOException {}
83+
84+
@Override
85+
public BoundedSource<byte[]> getCurrentSource() {
86+
return source;
87+
}
88+
}
89+
}

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/FlattenTranslator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public void translate(
5353
// Flatten produces exactly one output PCollection, fed by all of its input PCollections.
5454
String outputPCollectionId = Iterables.getOnlyElement(transform.getOutputsMap().values());
5555

56+
Topology topology = context.getTopology();
5657
Set<String> seenInputs = new HashSet<>();
5758
List<String> parentProcessors = new ArrayList<>();
5859
Set<String> upstreamTransformIds = new HashSet<>();
@@ -71,7 +72,6 @@ public void translate(
7172
upstreamTransformIds.add(parentProcessor);
7273
}
7374

74-
Topology topology = context.getTopology();
7575
topology.addProcessor(
7676
transformId,
7777
() -> new FlattenProcessor(transformId, upstreamTransformIds),

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
* <p>Adds three nodes to the Kafka Streams {@link Topology}:
3131
*
3232
* <ul>
33-
* <li>A {@code byte[]} source bound to a dedicated per-application bootstrap topic (see {@link
34-
* KafkaStreamsTranslationContext#getImpulseBootstrapTopic()}). Kafka Streams refuses to start
35-
* a topology that has no real source topic, so the bootstrap topic exists purely to satisfy
33+
* <li>A {@code byte[]} source bound to a dedicated per-transform bootstrap topic (see {@link
34+
* KafkaStreamsTranslationContext#getImpulseBootstrapTopic}). Kafka Streams refuses to start a
35+
* topology that has no real source topic, so the bootstrap topic exists purely to satisfy
3636
* that requirement — records published to it are ignored by {@link ImpulseProcessor}.
3737
* <li>The {@link ImpulseProcessor} itself, which schedules a one-shot wall-clock punctuator on
3838
* {@code init} and emits a single empty data {@link KStreamsPayload} followed by a terminal
@@ -71,7 +71,7 @@ public void translate(
7171
Topology topology = context.getTopology();
7272
String sourceNodeName = transformId + SOURCE_SUFFIX;
7373
String stateStoreName = transformId + STATE_STORE_SUFFIX;
74-
String bootstrapTopic = context.getImpulseBootstrapTopic();
74+
String bootstrapTopic = context.getImpulseBootstrapTopic(transformId);
7575

7676
topology.addSource(
7777
sourceNodeName,

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,18 @@
2323
import org.apache.beam.model.pipeline.v1.RunnerApi;
2424
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
2525
import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
26+
import org.apache.beam.sdk.util.SerializableUtils;
2627
import org.apache.beam.sdk.util.construction.NativeTransforms;
2728
import org.apache.beam.sdk.util.construction.PTransformTranslation;
2829
import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
2930
import org.apache.beam.sdk.util.construction.graph.GreedyPipelineFuser;
3031
import org.apache.beam.sdk.util.construction.graph.PipelineNode;
3132
import org.apache.beam.sdk.util.construction.graph.QueryablePipeline;
3233
import org.apache.beam.sdk.util.construction.graph.TrivialNativeTransformExpander;
34+
import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString;
3335
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
3436
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
37+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
3538

3639
/**
3740
* Translates a portable Beam pipeline into a Kafka Streams {@link
@@ -75,7 +78,14 @@ public KafkaStreamsTranslationContext createTranslationContext(
7578
* yet (e.g. GroupByKey).
7679
*/
7780
public Set<String> knownUrns() {
78-
return urnToTranslator.keySet();
81+
// Flatten is deliberately not exposed: the fuser has its own handling for Flatten (unzipping
82+
// it into producer stages and re-introducing a runner Flatten via the OutputDeduplicator), and
83+
// declaring it native makes TrivialNativeTransformExpander interact badly with multi-branch
84+
// flattens whose inputs come from composite expansions (their producing stages get dropped
85+
// from the fused pipeline, failing "consumed but never produced" validation). Mirrors the
86+
// Flink runner, which likewise keeps Read out of its known URNs for expander reasons.
87+
return Sets.difference(
88+
urnToTranslator.keySet(), ImmutableSet.of(PTransformTranslation.FLATTEN_TRANSFORM_URN));
7989
}
8090

8191
/**
@@ -98,10 +108,60 @@ public RunnerApi.Pipeline prepareForTranslation(RunnerApi.Pipeline pipeline) {
98108
if (alreadyFused) {
99109
return pipeline;
100110
}
101-
RunnerApi.Pipeline trimmed = TrivialNativeTransformExpander.forKnownUrns(pipeline, knownUrns());
111+
RunnerApi.Pipeline withoutEmptyFlattens = replaceEmptyFlattensWithEmptyReads(pipeline);
112+
RunnerApi.Pipeline trimmed =
113+
TrivialNativeTransformExpander.forKnownUrns(withoutEmptyFlattens, knownUrns());
102114
return GreedyPipelineFuser.fuse(trimmed).toPipeline();
103115
}
104116

117+
/**
118+
* Rewrites every {@code Flatten} of zero PCollections into a primitive Read of an {@link
119+
* EmptyBoundedSource}. A zero-input Flatten produces an empty PCollection, but it is also a root
120+
* of the pipeline graph, and {@link GreedyPipelineFuser} only accepts Impulse or Read roots. The
121+
* Read of an empty source has exactly the right semantics — no elements, then the terminal
122+
* watermark — and reuses the existing {@link ReadTranslator} path.
123+
*/
124+
private static RunnerApi.Pipeline replaceEmptyFlattensWithEmptyReads(
125+
RunnerApi.Pipeline pipeline) {
126+
RunnerApi.Pipeline.Builder pipelineBuilder = null;
127+
for (Map.Entry<String, RunnerApi.PTransform> entry :
128+
pipeline.getComponents().getTransformsMap().entrySet()) {
129+
RunnerApi.PTransform transform = entry.getValue();
130+
if (!PTransformTranslation.FLATTEN_TRANSFORM_URN.equals(transform.getSpec().getUrn())
131+
|| !transform.getInputsMap().isEmpty()) {
132+
continue;
133+
}
134+
if (pipelineBuilder == null) {
135+
pipelineBuilder = pipeline.toBuilder();
136+
}
137+
RunnerApi.ReadPayload emptyReadPayload =
138+
RunnerApi.ReadPayload.newBuilder()
139+
.setIsBounded(RunnerApi.IsBounded.Enum.BOUNDED)
140+
.setSource(
141+
RunnerApi.FunctionSpec.newBuilder()
142+
.setUrn(JAVA_SERIALIZED_BOUNDED_SOURCE_URN)
143+
.setPayload(
144+
ByteString.copyFrom(
145+
SerializableUtils.serializeToByteArray(new EmptyBoundedSource()))))
146+
.build();
147+
pipelineBuilder
148+
.getComponentsBuilder()
149+
.putTransforms(
150+
entry.getKey(),
151+
transform
152+
.toBuilder()
153+
.setSpec(
154+
RunnerApi.FunctionSpec.newBuilder()
155+
.setUrn(PTransformTranslation.READ_TRANSFORM_URN)
156+
.setPayload(emptyReadPayload.toByteString()))
157+
.build());
158+
}
159+
return pipelineBuilder == null ? pipeline : pipelineBuilder.build();
160+
}
161+
162+
/** The URN {@code ReadTranslation} uses for a Java-serialized {@code BoundedSource} payload. */
163+
private static final String JAVA_SERIALIZED_BOUNDED_SOURCE_URN = "beam:java:boundedsource:v1";
164+
105165
/**
106166
* Walks the pipeline in topological order and translates each transform whose URN is supported.
107167
* Throws {@link UnsupportedOperationException} on the first unsupported URN.

runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsTranslationContext.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,18 @@ public String getProcessorNameForPCollection(String pCollectionId) {
122122
return name;
123123
}
124124

125-
/** Returns the dedicated bootstrap topic name used by Impulse for this application. */
126-
public String getImpulseBootstrapTopic() {
127-
return IMPULSE_BOOTSTRAP_TOPIC_PREFIX + pipelineOptions.getApplicationId();
125+
/**
126+
* Returns the dedicated bootstrap topic name for one Impulse transform. Keyed by transform id
127+
* (sanitized to Kafka's legal topic-name character set) because a pipeline can contain several
128+
* Impulses (e.g. an empty {@code Create} plus the dummy branch {@code PAssert} adds), and Kafka
129+
* Streams rejects registering the same topic on two source nodes.
130+
*/
131+
public String getImpulseBootstrapTopic(String transformId) {
132+
String sanitizedTransformId = ILLEGAL_TOPIC_CHARS.matcher(transformId).replaceAll("_");
133+
return IMPULSE_BOOTSTRAP_TOPIC_PREFIX
134+
+ pipelineOptions.getApplicationId()
135+
+ "_"
136+
+ sanitizedTransformId;
128137
}
129138

130139
/**

0 commit comments

Comments
 (0)