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 75adf49ee8d [GSoC 2026] Kafka Streams runner: surface SDK-harness 
metrics as MetricResults (#39341)
75adf49ee8d is described below

commit 75adf49ee8deb411bf8851a8e86e4c34fd420cbd
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Fri Jul 17 00:00:06 2026 +0500

    [GSoC 2026] Kafka Streams runner: surface SDK-harness metrics as 
MetricResults (#39341)
---
 runners/kafka-streams/build.gradle                 |  1 +
 .../kafka/streams/KafkaStreamsPipelineRunner.java  |  3 +-
 .../KafkaStreamsPortablePipelineResult.java        | 13 +++-
 .../translation/ExecutableStageProcessor.java      | 46 +++++++++++--
 .../translation/ExecutableStageTranslator.java     |  9 ++-
 .../KafkaStreamsTranslationContext.java            | 19 ++++++
 .../kafka/streams/KafkaStreamsTestRunner.java      | 12 +++-
 .../ExecutableStageProcessorWatermarkTest.java     |  4 +-
 .../kafka/streams/translation/MetricsTest.java     | 79 ++++++++++++++++++++++
 9 files changed, 173 insertions(+), 13 deletions(-)

diff --git a/runners/kafka-streams/build.gradle 
b/runners/kafka-streams/build.gradle
index 2ba55e61830..a794299cb60 100644
--- a/runners/kafka-streams/build.gradle
+++ b/runners/kafka-streams/build.gradle
@@ -45,6 +45,7 @@ dependencies {
   implementation project(path: ":sdks:java:core", configuration: "shadow")
   implementation project(path: ":runners:kafka-streams:proto", configuration: 
"shadow")
   implementation project(path: ":model:pipeline", configuration: "shadow")
+  implementation project(path: ":model:fn-execution", configuration: "shadow")
   implementation project(path: ":model:job-management", configuration: 
"shadow")
   implementation project(":runners:core-java")
   permitUnusedDeclared project(":runners:core-java")
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 3e97638695e..cdb59f67cce 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
@@ -62,7 +62,8 @@ public class KafkaStreamsPipelineRunner implements 
PortablePipelineRunner {
 
     KafkaStreams kafkaStreams = new KafkaStreams(topology, 
streamsConfig(jobInfo));
     kafkaStreams.start();
-    return new KafkaStreamsPortablePipelineResult(kafkaStreams);
+    return new KafkaStreamsPortablePipelineResult(
+        kafkaStreams, context.getMetricsContainerStepMap());
   }
 
   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 817746bf002..972afa15c35 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
@@ -21,6 +21,7 @@ import java.io.IOException;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import org.apache.beam.model.jobmanagement.v1.JobApi;
+import org.apache.beam.runners.core.metrics.MetricsContainerStepMap;
 import org.apache.beam.runners.jobsubmission.PortablePipelineResult;
 import org.apache.beam.sdk.metrics.MetricResults;
 import org.apache.kafka.streams.KafkaStreams;
@@ -41,11 +42,16 @@ class KafkaStreamsPortablePipelineResult implements 
PortablePipelineResult {
       LoggerFactory.getLogger(KafkaStreamsPortablePipelineResult.class);
 
   private final KafkaStreams kafkaStreams;
+  // The job's metrics accumulator, shared by reference with the topology's 
stage processors, which
+  // update it as the SDK harness reports bundle metrics.
+  private final MetricsContainerStepMap metricsContainerStepMap;
   private final CountDownLatch terminated = new CountDownLatch(1);
   private volatile boolean cancelled = false;
 
-  KafkaStreamsPortablePipelineResult(KafkaStreams kafkaStreams) {
+  KafkaStreamsPortablePipelineResult(
+      KafkaStreams kafkaStreams, MetricsContainerStepMap 
metricsContainerStepMap) {
     this.kafkaStreams = kafkaStreams;
+    this.metricsContainerStepMap = metricsContainerStepMap;
     kafkaStreams.setStateListener(
         (newState, oldState) -> {
           if (newState == KafkaStreams.State.NOT_RUNNING || newState == 
KafkaStreams.State.ERROR) {
@@ -104,8 +110,9 @@ class KafkaStreamsPortablePipelineResult implements 
PortablePipelineResult {
 
   @Override
   public MetricResults metrics() {
-    throw new UnsupportedOperationException(
-        "Metrics are not yet implemented in the Kafka Streams runner.");
+    // Attempted values only: the runner does not distinguish committed 
results yet (that needs
+    // metrics to be folded into the exactly-once commit, which lands with the 
durability work).
+    return 
MetricsContainerStepMap.asAttemptedOnlyMetricResults(metricsContainerStepMap);
   }
 
   @Override
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 38d3601e814..fb26e9e71f6 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
@@ -20,7 +20,10 @@ package org.apache.beam.runners.kafka.streams.translation;
 import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ConcurrentLinkedQueue;
+import 
org.apache.beam.model.fnexecution.v1.BeamFnApi.ProcessBundleProgressResponse;
+import org.apache.beam.model.fnexecution.v1.BeamFnApi.ProcessBundleResponse;
 import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.core.metrics.MetricsContainerImpl;
 import org.apache.beam.runners.fnexecution.control.BundleProgressHandler;
 import org.apache.beam.runners.fnexecution.control.ExecutableStageContext;
 import org.apache.beam.runners.fnexecution.control.OutputReceiverFactory;
@@ -32,6 +35,7 @@ import org.apache.beam.sdk.fn.data.FnDataReceiver;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
 import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.TextFormat;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import org.apache.kafka.streams.processor.api.Processor;
 import org.apache.kafka.streams.processor.api.ProcessorContext;
@@ -74,6 +78,10 @@ class ExecutableStageProcessor
   // This stage's own transform id, stamped on every watermark it forwards so 
downstream watermark
   // aggregators know which transform the report came from — regardless of who 
consumes it.
   private final String transformId;
+  // This stage's Beam metrics container, updated from the final 
MonitoringInfos the SDK harness
+  // reports as each bundle completes. The pipeline result reads the 
containing step map as
+  // MetricResults.
+  private final MetricsContainerImpl metricsContainer;
 
   // pendingOutputs is enqueued by SDK harness threads (inside the 
OutputReceiverFactory callback)
   // and drained by the Kafka Streams processing thread on bundle close; needs 
to be thread-safe.
@@ -98,16 +106,20 @@ class ExecutableStageProcessor
    * @param transformId this stage's own transform id, stamped on the 
watermarks it emits
    * @param upstreamTransformIds the transform ids feeding this stage (known 
from the pipeline
    *     graph), whose reports the {@link WatermarkAggregator} waits for
+   * @param metricsContainer this stage's container in the job's metrics step 
map, updated with the
+   *     harness's per-bundle MonitoringInfos
    */
   ExecutableStageProcessor(
       RunnerApi.ExecutableStagePayload stagePayload,
       JobInfo jobInfo,
       String transformId,
-      Set<String> upstreamTransformIds) {
+      Set<String> upstreamTransformIds,
+      MetricsContainerImpl metricsContainer) {
     this.stagePayload = stagePayload;
     this.jobInfo = jobInfo;
     this.transformId = transformId;
     this.watermarkAggregator = new WatermarkAggregator(upstreamTransformIds);
+    this.metricsContainer = metricsContainer;
   }
 
   @Override
@@ -179,11 +191,37 @@ class ExecutableStageProcessor
             };
           }
         };
+    // Fold the harness's reported metrics into this stage's container when 
each bundle completes.
+    // Only the completion response is applied: it carries the bundle's final 
cumulative values, and
+    // the container's update() adds counter values, so also applying 
mid-bundle progress snapshots
+    // would double-count them. Live mid-bundle metrics can come later if a 
use appears.
+    BundleProgressHandler progressHandler =
+        new BundleProgressHandler() {
+          @Override
+          public void onProgress(ProcessBundleProgressResponse progress) {
+            // Deliberately not folded into the container; see comment above.
+            if (LOG.isDebugEnabled()) {
+              LOG.debug(
+                  "Stage {} bundle progress: {}",
+                  transformId,
+                  TextFormat.printer().printToString(progress));
+            }
+          }
+
+          @Override
+          public void onCompleted(ProcessBundleResponse response) {
+            if (LOG.isDebugEnabled()) {
+              LOG.debug(
+                  "Stage {} bundle completed: {}",
+                  transformId,
+                  TextFormat.printer().printToString(response));
+            }
+            metricsContainer.update(response.getMonitoringInfosList());
+          }
+        };
     currentBundle =
         factory.getBundle(
-            outputReceiverFactory,
-            StateRequestHandler.unsupported(),
-            BundleProgressHandler.ignored());
+            outputReceiverFactory, StateRequestHandler.unsupported(), 
progressHandler);
   }
 
   private FnDataReceiver<WindowedValue<?>> mainInputReceiver() {
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 a2e6ed837c7..59c233a78bb 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
@@ -86,12 +86,17 @@ class ExecutableStageTranslator implements 
PTransformTranslator {
     Topology topology = context.getTopology();
     // The stage stamps its own transform id on the watermarks it emits, and 
aggregates its input
     // watermark from the reports of its single upstream transform (the 
producer of its input
-    // PCollection, whose node name is the upstream transform id).
+    // PCollection, whose node name is the upstream transform id). 
Harness-reported metrics land in
+    // this stage's container of the job's metrics step map.
     topology.addProcessor(
         transformId,
         () ->
             new ExecutableStageProcessor(
-                stagePayload, context.getJobInfo(), transformId, 
ImmutableSet.of(parentProcessor)),
+                stagePayload,
+                context.getJobInfo(),
+                transformId,
+                ImmutableSet.of(parentProcessor),
+                
context.getMetricsContainerStepMap().getContainer(transformId)),
         parentProcessor);
 
     if (!transform.getOutputsMap().isEmpty()) {
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 0a045ff7395..89a2d9825cb 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
@@ -20,6 +20,7 @@ package org.apache.beam.runners.kafka.streams.translation;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.regex.Pattern;
+import org.apache.beam.runners.core.metrics.MetricsContainerStepMap;
 import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
 import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
 import org.apache.kafka.streams.Topology;
@@ -45,6 +46,14 @@ public class KafkaStreamsTranslationContext {
   private final KafkaStreamsPipelineOptions pipelineOptions;
   private final Topology topology;
   private final Map<String, String> pCollectionIdToProcessorName;
+  // 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
+  // correct: the metric cells are thread-safe (atomic cells in concurrent 
maps) and the updates are
+  // per-bundle final values applied with add semantics, so concurrent tasks 
accumulate rather than
+  // overwrite. Aggregation across multiple runner JVMs is out of scope until 
the multi-instance
+  // work.
+  private final MetricsContainerStepMap metricsContainerStepMap = new 
MetricsContainerStepMap();
 
   public static KafkaStreamsTranslationContext create(
       JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions) {
@@ -77,6 +86,16 @@ public class KafkaStreamsTranslationContext {
     return topology;
   }
 
+  /**
+   * Returns the job's metrics accumulator: one {@link
+   * org.apache.beam.runners.core.metrics.MetricsContainerImpl container} per 
executable stage,
+   * updated by the stage processors as the SDK harness reports bundle 
metrics, and read by the
+   * pipeline result via {@link 
MetricsContainerStepMap#asAttemptedOnlyMetricResults}.
+   */
+  public MetricsContainerStepMap getMetricsContainerStepMap() {
+    return metricsContainerStepMap;
+  }
+
   /**
    * Registers the processor node that produces the given Beam PCollection. 
Downstream translators
    * resolve their parent processor names by looking up the input PCollection 
id.
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTestRunner.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTestRunner.java
index 4d73eee9581..8ab29182c28 100644
--- 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTestRunner.java
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsTestRunner.java
@@ -25,10 +25,12 @@ import java.util.Properties;
 import java.util.Set;
 import java.util.UUID;
 import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.core.metrics.MetricsContainerStepMap;
 import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
 import 
org.apache.beam.runners.kafka.streams.translation.KafkaStreamsPipelineTranslator;
 import 
org.apache.beam.runners.kafka.streams.translation.KafkaStreamsTranslationContext;
 import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.metrics.MetricResults;
 import org.apache.beam.sdk.options.PipelineOptions;
 import org.apache.beam.sdk.options.PipelineOptionsFactory;
 import org.apache.beam.sdk.options.PortablePipelineOptions;
@@ -107,8 +109,12 @@ public final class KafkaStreamsTestRunner {
     return context;
   }
 
-  /** Translates and drives the pipeline to quiescence through a {@link 
TopologyTestDriver}. */
-  public static void run(Pipeline pipeline) {
+  /**
+   * Translates and drives the pipeline to quiescence through a {@link 
TopologyTestDriver}. Returns
+   * the metrics the SDK harness reported while running (attempted values), so 
tests can assert on
+   * user counters — the same surface PAssert uses to verify its assertions 
ran.
+   */
+  public static MetricResults run(Pipeline pipeline) {
     KafkaStreamsTranslationContext context = translate(pipeline);
     Topology topology = context.getTopology();
     try (TopologyTestDriver driver = new TopologyTestDriver(topology, 
streamsConfig(pipeline))) {
@@ -117,6 +123,8 @@ public final class KafkaStreamsTestRunner {
       driver.advanceWallClockTime(Duration.ofSeconds(1));
       roundTripInternalTopics(driver, internalTopics(topology));
     }
+    return MetricsContainerStepMap.asAttemptedOnlyMetricResults(
+        context.getMetricsContainerStepMap());
   }
 
   /**
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
index 74169acb81c..de3a23911ad 100644
--- 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ExecutableStageProcessorWatermarkTest.java
@@ -21,6 +21,7 @@ import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 
 import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.core.metrics.MetricsContainerImpl;
 import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
 import org.apache.beam.sdk.options.PipelineOptionsFactory;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
@@ -57,7 +58,8 @@ public class ExecutableStageProcessorWatermarkTest {
         RunnerApi.ExecutableStagePayload.getDefaultInstance(),
         jobInfo,
         STAGE_ID,
-        ImmutableSet.of(UPSTREAM_ID));
+        ImmutableSet.of(UPSTREAM_ID),
+        new MetricsContainerImpl(STAGE_ID));
   }
 
   /** A report from the upstream transform's given partition. */
diff --git 
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/MetricsTest.java
 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/MetricsTest.java
new file mode 100644
index 00000000000..558a1bab91a
--- /dev/null
+++ 
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/MetricsTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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 org.apache.beam.runners.kafka.streams.KafkaStreamsTestRunner;
+import org.apache.beam.sdk.Pipeline;
+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.MetricResults;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.metrics.MetricsFilter;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.junit.Test;
+
+/**
+ * End-to-end test that user metrics reported by a {@link DoFn} in the SDK 
harness surface through
+ * the runner as {@link MetricResults}.
+ *
+ * <p>The harness reports metrics as Fn API MonitoringInfos with each bundle; 
the stage processor
+ * folds them into the job's metrics step map, and the runner exposes them as 
attempted {@link
+ * MetricResults}. This is the surface {@code PAssert} uses to verify its 
assertions actually ran,
+ * so it is a prerequisite for the {@code @ValidatesRunner} suite.
+ */
+public class MetricsTest {
+
+  private static final String NAMESPACE = "MetricsTest";
+  private static final String COUNTER_NAME = "elements";
+
+  /** Increments a user counter for every element the harness feeds it. */
+  private static class CountingFn extends DoFn<Integer, Integer> {
+    private final Counter counter = Metrics.counter(NAMESPACE, COUNTER_NAME);
+
+    @ProcessElement
+    public void processElement(@Element Integer input, OutputReceiver<Integer> 
out) {
+      counter.inc();
+      out.output(input);
+    }
+  }
+
+  @Test
+  public void userCounterFromHarnessSurfacesInMetricResults() {
+    Pipeline pipeline = Pipeline.create(KafkaStreamsTestRunner.testOptions());
+    pipeline.apply("create", Create.of(1, 2, 3)).apply("count", ParDo.of(new 
CountingFn()));
+
+    MetricResults metrics = KafkaStreamsTestRunner.run(pipeline);
+
+    MetricQueryResults query =
+        metrics.queryMetrics(
+            MetricsFilter.builder()
+                .addNameFilter(MetricNameFilter.named(NAMESPACE, COUNTER_NAME))
+                .build());
+    MetricResult<Long> counter = Iterables.getOnlyElement(query.getCounters());
+    // Create.of(1, 2, 3) feeds the DoFn exactly three elements.
+    assertThat(counter.getAttempted(), is(3L));
+  }
+}

Reply via email to