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 d9cea589e5b [GSoC 2026] Kafka Streams runner: validatesRunner task; 
Create and Flatten suites green (#39380)
d9cea589e5b is described below

commit d9cea589e5b9e2da28730620ca1ffb045607fdbc
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Mon Jul 20 13:12:26 2026 +0500

    [GSoC 2026] Kafka Streams runner: validatesRunner task; Create and Flatten 
suites green (#39380)
---
 .../beam_KafkaStreamsRunner_FeatureBranch.yml      |  2 +
 runners/kafka-streams/build.gradle                 | 76 ++++++++++++++++++
 .../streams/translation/EmptyBoundedSource.java    | 89 ++++++++++++++++++++++
 .../streams/translation/FlattenTranslator.java     |  2 +-
 .../streams/translation/ImpulseTranslator.java     |  8 +-
 .../KafkaStreamsPipelineTranslator.java            | 64 +++++++++++++++-
 .../KafkaStreamsTranslationContext.java            | 15 +++-
 7 files changed, 246 insertions(+), 10 deletions(-)

diff --git a/.github/workflows/beam_KafkaStreamsRunner_FeatureBranch.yml 
b/.github/workflows/beam_KafkaStreamsRunner_FeatureBranch.yml
index 4ec7b0778cc..e290c9beee1 100644
--- a/.github/workflows/beam_KafkaStreamsRunner_FeatureBranch.yml
+++ b/.github/workflows/beam_KafkaStreamsRunner_FeatureBranch.yml
@@ -70,3 +70,5 @@ jobs:
             ${{ runner.os }}-gradle-kafka-streams-
       - name: Build and test Kafka Streams runner
         run: ./gradlew :runners:kafka-streams:build --no-daemon --stacktrace
+      - name: Run ValidatesRunner suite
+        run: ./gradlew :runners:kafka-streams:validatesRunner --no-daemon 
--stacktrace
diff --git a/runners/kafka-streams/build.gradle 
b/runners/kafka-streams/build.gradle
index a794299cb60..81099560ba7 100644
--- a/runners/kafka-streams/build.gradle
+++ b/runners/kafka-streams/build.gradle
@@ -16,6 +16,8 @@
  * limitations under the License.
  */
 
+import groovy.json.JsonOutput
+
 plugins { id 'org.apache.beam.module' }
 
 def kafka_version = '3.9.0'
@@ -29,6 +31,10 @@ description = "Apache Beam :: Runners :: Kafka Streams"
 evaluationDependsOn(":sdks:java:core")
 evaluationDependsOn(":runners:core-java")
 
+configurations {
+  validatesRunner
+}
+
 configurations.configureEach {
   resolutionStrategy.eachDependency { details ->
     if (details.requested.group == "org.apache.kafka") {
@@ -68,4 +74,74 @@ dependencies {
   testImplementation library.java.junit
   testImplementation library.java.mockito_core
   testImplementation "org.apache.kafka:kafka-streams-test-utils:$kafka_version"
+
+  // 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
+  // output and test runtime classpath.
+  validatesRunner project(path: ":sdks:java:core", configuration: "shadowTest")
+  validatesRunner project(project.path)
+  validatesRunner sourceSets.test.output
+  validatesRunner sourceSets.test.runtimeClasspath
+}
+
+
+// Known-failing @ValidatesRunner tests, excluded until the feature they need 
lands.
+def sickbayTests = [
+  // Needs multi-output executable stages (output-tag dispatch in 
ExecutableStageProcessor).
+  
'org.apache.beam.sdk.transforms.FlattenTest.testFlattenMultiplePCollectionsHavingMultipleConsumers',
+]
+
+tasks.register("validatesRunner", Test) {
+  group = "Verification"
+  description = "Runs the subset of Beam's ValidatesRunner suite the Kafka 
Streams runner supports."
+  // Never consider up-to-date; the suite is the correctness gate.
+  outputs.upToDateWhen { false }
+  systemProperty "beamTestPipelineOptions",
+      
JsonOutput.toJson(["--runner=org.apache.beam.runners.kafka.streams.TestKafkaStreamsRunner"])
+  classpath = configurations.validatesRunner
+  testClassesDirs = 
files(project(":sdks:java:core").sourceSets.test.output.classesDirs)
+  maxParallelForks 2
+  useJUnit {
+    includeCategories 'org.apache.beam.sdk.testing.ValidatesRunner'
+    // Environment / harness features that need a properly configured external 
environment.
+    excludeCategories 'org.apache.beam.sdk.testing.UsesExternalService'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesSdkHarnessEnvironment'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesBundleFinalizer'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesJavaExpansionService'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesPythonExpansionService'
+    // Features the runner does not support yet.
+    excludeCategories 'org.apache.beam.sdk.testing.UsesSideInputs'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesStatefulParDo'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesTimersInParDo'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesTimerMap'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesLoopingTimer'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesStrictTimerOrdering'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesProcessingTimeTimers'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesOnWindowExpiration'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesTestStream'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesUnboundedPCollections'
+    excludeCategories 
'org.apache.beam.sdk.testing.UsesUnboundedSplittableParDo'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesBoundedSplittableParDo'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesCustomWindowMerging'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesMetricsPusher'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesCommittedMetrics'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesSystemMetrics'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesOrderedListState'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesMultimapState'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesMapState'
+    excludeCategories 'org.apache.beam.sdk.testing.UsesSetState'
+    excludeCategories 
'org.apache.beam.sdk.testing.FlattenWithHeterogeneousCoders'
+    excludeCategories 'org.apache.beam.sdk.testing.LargeKeys$Above100MB'
+  }
+  filter {
+    // The suites enabled so far, extended class by class as runner support 
grows. An explicit
+    // include list is needed because feature gaps like windowing beyond the 
global window have no
+    // JUnit category to exclude.
+    includeTestsMatching 'org.apache.beam.sdk.transforms.CreateTest'
+    includeTestsMatching 'org.apache.beam.sdk.transforms.FlattenTest'
+    for (String test : sickbayTests) {
+      excludeTestsMatching test
+    }
+    failOnNoMatchingTests = false
+  }
 }
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/EmptyBoundedSource.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/EmptyBoundedSource.java
new file mode 100644
index 00000000000..14bee0c3cfd
--- /dev/null
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/EmptyBoundedSource.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.runners.kafka.streams.translation;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.NoSuchElementException;
+import org.apache.beam.sdk.coders.ByteArrayCoder;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.io.BoundedSource;
+import org.apache.beam.sdk.options.PipelineOptions;
+
+/**
+ * A {@link BoundedSource} with no elements. The runner substitutes it for a 
{@code Flatten} of zero
+ * PCollections (see {@link KafkaStreamsPipelineTranslator}): reading it 
produces no data and a
+ * terminal watermark — exactly the semantics of an empty PCollection. The 
element type is never
+ * observed since no element is ever produced.
+ */
+class EmptyBoundedSource extends BoundedSource<byte[]> {
+
+  @Override
+  public List<? extends BoundedSource<byte[]>> split(
+      long desiredBundleSizeBytes, PipelineOptions options) {
+    return Collections.singletonList(this);
+  }
+
+  @Override
+  public long getEstimatedSizeBytes(PipelineOptions options) {
+    return 0;
+  }
+
+  @Override
+  public BoundedReader<byte[]> createReader(PipelineOptions options) {
+    return new EmptyReader(this);
+  }
+
+  @Override
+  public Coder<byte[]> getOutputCoder() {
+    return ByteArrayCoder.of();
+  }
+
+  /** A reader that is exhausted from the start. */
+  private static final class EmptyReader extends BoundedReader<byte[]> {
+    private final EmptyBoundedSource source;
+
+    EmptyReader(EmptyBoundedSource source) {
+      this.source = source;
+    }
+
+    @Override
+    public boolean start() {
+      return false;
+    }
+
+    @Override
+    public boolean advance() {
+      return false;
+    }
+
+    @Override
+    public byte[] getCurrent() throws NoSuchElementException {
+      throw new NoSuchElementException("EmptyBoundedSource has no elements");
+    }
+
+    @Override
+    public void close() throws IOException {}
+
+    @Override
+    public BoundedSource<byte[]> getCurrentSource() {
+      return source;
+    }
+  }
+}
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 8f6ce2b546d..3ac4c1304da 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
@@ -53,6 +53,7 @@ class FlattenTranslator implements PTransformTranslator {
     // Flatten produces exactly one output PCollection, fed by all of its 
input PCollections.
     String outputPCollectionId = 
Iterables.getOnlyElement(transform.getOutputsMap().values());
 
+    Topology topology = context.getTopology();
     Set<String> seenInputs = new HashSet<>();
     List<String> parentProcessors = new ArrayList<>();
     Set<String> upstreamTransformIds = new HashSet<>();
@@ -71,7 +72,6 @@ class FlattenTranslator implements PTransformTranslator {
       upstreamTransformIds.add(parentProcessor);
     }
 
-    Topology topology = context.getTopology();
     topology.addProcessor(
         transformId,
         () -> new FlattenProcessor(transformId, upstreamTransformIds),
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java
index a90987ba638..79d8c3cf577 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java
@@ -30,9 +30,9 @@ import org.apache.kafka.streams.state.Stores;
  * <p>Adds three nodes to the Kafka Streams {@link Topology}:
  *
  * <ul>
- *   <li>A {@code byte[]} source bound to a dedicated per-application 
bootstrap topic (see {@link
- *       KafkaStreamsTranslationContext#getImpulseBootstrapTopic()}). Kafka 
Streams refuses to start
- *       a topology that has no real source topic, so the bootstrap topic 
exists purely to satisfy
+ *   <li>A {@code byte[]} source bound to a dedicated per-transform bootstrap 
topic (see {@link
+ *       KafkaStreamsTranslationContext#getImpulseBootstrapTopic}). Kafka 
Streams refuses to start a
+ *       topology that has no real source topic, so the bootstrap topic exists 
purely to satisfy
  *       that requirement — records published to it are ignored by {@link 
ImpulseProcessor}.
  *   <li>The {@link ImpulseProcessor} itself, which schedules a one-shot 
wall-clock punctuator on
  *       {@code init} and emits a single empty data {@link KStreamsPayload} 
followed by a terminal
@@ -71,7 +71,7 @@ class ImpulseTranslator implements PTransformTranslator {
     Topology topology = context.getTopology();
     String sourceNodeName = transformId + SOURCE_SUFFIX;
     String stateStoreName = transformId + STATE_STORE_SUFFIX;
-    String bootstrapTopic = context.getImpulseBootstrapTopic();
+    String bootstrapTopic = context.getImpulseBootstrapTopic(transformId);
 
     topology.addSource(
         sourceNodeName,
diff --git 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
index a9e26ecd641..a71434bfd8d 100644
--- 
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
+++ 
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslator.java
@@ -23,6 +23,7 @@ import java.util.Set;
 import org.apache.beam.model.pipeline.v1.RunnerApi;
 import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
 import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
+import org.apache.beam.sdk.util.SerializableUtils;
 import org.apache.beam.sdk.util.construction.NativeTransforms;
 import org.apache.beam.sdk.util.construction.PTransformTranslation;
 import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
@@ -30,8 +31,10 @@ import 
org.apache.beam.sdk.util.construction.graph.GreedyPipelineFuser;
 import org.apache.beam.sdk.util.construction.graph.PipelineNode;
 import org.apache.beam.sdk.util.construction.graph.QueryablePipeline;
 import 
org.apache.beam.sdk.util.construction.graph.TrivialNativeTransformExpander;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
 
 /**
  * Translates a portable Beam pipeline into a Kafka Streams {@link
@@ -75,7 +78,14 @@ public class KafkaStreamsPipelineTranslator {
    * yet (e.g. GroupByKey).
    */
   public Set<String> knownUrns() {
-    return urnToTranslator.keySet();
+    // Flatten is deliberately not exposed: the fuser has its own handling for 
Flatten (unzipping
+    // it into producer stages and re-introducing a runner Flatten via the 
OutputDeduplicator), and
+    // declaring it native makes TrivialNativeTransformExpander interact badly 
with multi-branch
+    // flattens whose inputs come from composite expansions (their producing 
stages get dropped
+    // from the fused pipeline, failing "consumed but never produced" 
validation). Mirrors the
+    // Flink runner, which likewise keeps Read out of its known URNs for 
expander reasons.
+    return Sets.difference(
+        urnToTranslator.keySet(), 
ImmutableSet.of(PTransformTranslation.FLATTEN_TRANSFORM_URN));
   }
 
   /**
@@ -98,10 +108,60 @@ public class KafkaStreamsPipelineTranslator {
     if (alreadyFused) {
       return pipeline;
     }
-    RunnerApi.Pipeline trimmed = 
TrivialNativeTransformExpander.forKnownUrns(pipeline, knownUrns());
+    RunnerApi.Pipeline withoutEmptyFlattens = 
replaceEmptyFlattensWithEmptyReads(pipeline);
+    RunnerApi.Pipeline trimmed =
+        TrivialNativeTransformExpander.forKnownUrns(withoutEmptyFlattens, 
knownUrns());
     return GreedyPipelineFuser.fuse(trimmed).toPipeline();
   }
 
+  /**
+   * Rewrites every {@code Flatten} of zero PCollections into a primitive Read 
of an {@link
+   * EmptyBoundedSource}. A zero-input Flatten produces an empty PCollection, 
but it is also a root
+   * of the pipeline graph, and {@link GreedyPipelineFuser} only accepts 
Impulse or Read roots. The
+   * Read of an empty source has exactly the right semantics — no elements, 
then the terminal
+   * watermark — and reuses the existing {@link ReadTranslator} path.
+   */
+  private static RunnerApi.Pipeline replaceEmptyFlattensWithEmptyReads(
+      RunnerApi.Pipeline pipeline) {
+    RunnerApi.Pipeline.Builder pipelineBuilder = null;
+    for (Map.Entry<String, RunnerApi.PTransform> entry :
+        pipeline.getComponents().getTransformsMap().entrySet()) {
+      RunnerApi.PTransform transform = entry.getValue();
+      if 
(!PTransformTranslation.FLATTEN_TRANSFORM_URN.equals(transform.getSpec().getUrn())
+          || !transform.getInputsMap().isEmpty()) {
+        continue;
+      }
+      if (pipelineBuilder == null) {
+        pipelineBuilder = pipeline.toBuilder();
+      }
+      RunnerApi.ReadPayload emptyReadPayload =
+          RunnerApi.ReadPayload.newBuilder()
+              .setIsBounded(RunnerApi.IsBounded.Enum.BOUNDED)
+              .setSource(
+                  RunnerApi.FunctionSpec.newBuilder()
+                      .setUrn(JAVA_SERIALIZED_BOUNDED_SOURCE_URN)
+                      .setPayload(
+                          ByteString.copyFrom(
+                              SerializableUtils.serializeToByteArray(new 
EmptyBoundedSource()))))
+              .build();
+      pipelineBuilder
+          .getComponentsBuilder()
+          .putTransforms(
+              entry.getKey(),
+              transform
+                  .toBuilder()
+                  .setSpec(
+                      RunnerApi.FunctionSpec.newBuilder()
+                          .setUrn(PTransformTranslation.READ_TRANSFORM_URN)
+                          .setPayload(emptyReadPayload.toByteString()))
+                  .build());
+    }
+    return pipelineBuilder == null ? pipeline : pipelineBuilder.build();
+  }
+
+  /** The URN {@code ReadTranslation} uses for a Java-serialized {@code 
BoundedSource} payload. */
+  private static final String JAVA_SERIALIZED_BOUNDED_SOURCE_URN = 
"beam:java:boundedsource:v1";
+
   /**
    * Walks the pipeline in topological order and translates each transform 
whose URN is supported.
    * Throws {@link UnsupportedOperationException} on the first unsupported URN.
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 89a2d9825cb..ec1b3f26ade 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
@@ -122,9 +122,18 @@ public class KafkaStreamsTranslationContext {
     return name;
   }
 
-  /** Returns the dedicated bootstrap topic name used by Impulse for this 
application. */
-  public String getImpulseBootstrapTopic() {
-    return IMPULSE_BOOTSTRAP_TOPIC_PREFIX + pipelineOptions.getApplicationId();
+  /**
+   * Returns the dedicated bootstrap topic name for one Impulse transform. 
Keyed by transform id
+   * (sanitized to Kafka's legal topic-name character set) because a pipeline 
can contain several
+   * Impulses (e.g. an empty {@code Create} plus the dummy branch {@code 
PAssert} adds), and Kafka
+   * Streams rejects registering the same topic on two source nodes.
+   */
+  public String getImpulseBootstrapTopic(String transformId) {
+    String sanitizedTransformId = 
ILLEGAL_TOPIC_CHARS.matcher(transformId).replaceAll("_");
+    return IMPULSE_BOOTSTRAP_TOPIC_PREFIX
+        + pipelineOptions.getApplicationId()
+        + "_"
+        + sanitizedTransformId;
   }
 
   /**

Reply via email to