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 faefc95e9f0 [GSoC 2026] Kafka Streams runner — translation framework +
Impulse translator (#38689)
faefc95e9f0 is described below
commit faefc95e9f05115b19d16a974c1310c594d4250d
Author: M Junaid Shaukat <[email protected]>
AuthorDate: Thu May 28 10:39:43 2026 +0500
[GSoC 2026] Kafka Streams runner — translation framework + Impulse
translator (#38689)
* Add Impulse translator and URN-dispatch framework
- KafkaStreamsPipelineTranslator now walks the pipeline in topological
order via QueryablePipeline and dispatches each transform to a
PTransformTranslator keyed by URN. Unknown URNs still fail fast with
a clear "No translator registered for URN ..." message.
- ImpulseTranslator implements beam:transform:impulse:v1 per design doc
§4.1: a per-application bootstrap topic source (__beam_impulse_<app>)
satisfies Kafka Streams' real-source requirement, ImpulseProcessor
emits exactly one WindowedValue<byte[]> in the GlobalWindow via a
one-shot wall-clock punctuator scheduled on init, and a persistent
state store records a "fired" flag so task restarts do not duplicate.
Bootstrap-topic auto-creation is deferred to a follow-up sub-issue
(design doc §12.1); the topic is expected to pre-exist in production.
- KafkaStreamsTranslationContext now holds the Topology being built and
a PCollection-id -> processor-name map so downstream translators can
wire to their parent nodes.
- KafkaStreamsPipelineRunner.run now translates and starts the
KafkaStreams application, returning a KafkaStreamsPortablePipelineResult
that maps KafkaStreams.State to Beam's PipelineResult.State. Forces
processing.guarantee=exactly_once_v2.
- Tests:
* KafkaStreamsPipelineTranslatorTest also covers the Impulse success
path; the unsupported-URN check now uses GroupByKey.
* ImpulseTranslatorTest exercises the topology via TopologyTestDriver:
exactly one empty byte[] in GlobalWindow is emitted, and a second
wall-clock advance does not re-emit.
* Address review feedback on translation framework + Impulse PR
- KafkaStreamsPortablePipelineResult: close the race where KafkaStreams
could transition to a terminal state before the state listener was
registered, leaving waitUntilFinish() to block forever. Also add a
volatile cancelled flag so that getState() returns State.CANCELLED
after a user cancel(), instead of mapping NOT_RUNNING to State.DONE.
- ImpulseProcessor: capture the Cancellable returned by context.schedule
and cancel the wall-clock punctuator once the impulse has fired (or
if the state store already records a prior emission), so the
processor stops doing periodic state-store lookups for the lifetime
of the task.
- KafkaStreamsPipelineRunner.run: invoke PipelineOptionsValidator.validate
on the pipeline options at the start of run() so a missing required
option (e.g. applicationId) fails with a clear IllegalArgumentException
rather than a raw NullPointerException on Properties.put further down.
- ImpulseTranslatorTest: wrap CapturingProcessor.received in
Collections.synchronizedList for best-practice thread-safety even
though TopologyTestDriver runs single-threaded.
* Address review feedback on Impulse translator
---
runners/kafka-streams/build.gradle | 3 +-
.../kafka/streams/KafkaStreamsPipelineRunner.java | 38 ++++-
.../KafkaStreamsPortablePipelineResult.java | 135 +++++++++++++++++
.../streams/translation/ImpulseProcessor.java | 140 ++++++++++++++++++
.../streams/translation/ImpulseTranslator.java | 90 ++++++++++++
.../kafka/streams/translation/KStreamsPayload.java | 126 ++++++++++++++++
.../KafkaStreamsPipelineTranslator.java | 65 ++++++---
.../KafkaStreamsTranslationContext.java | 62 +++++++-
.../streams/translation/PTransformTranslator.java | 41 ++++++
.../kafka/streams/KafkaStreamsRunnerTest.java | 161 +++++++++++++++++++++
.../streams/translation/ImpulseTranslatorTest.java | 143 ++++++++++++++++++
.../KafkaStreamsPipelineTranslatorTest.java | 73 ++++++----
12 files changed, 1020 insertions(+), 57 deletions(-)
diff --git a/runners/kafka-streams/build.gradle
b/runners/kafka-streams/build.gradle
index 9204fef4e76..3f34a3ca76b 100644
--- a/runners/kafka-streams/build.gradle
+++ b/runners/kafka-streams/build.gradle
@@ -44,6 +44,7 @@ dependencies {
implementation project(path: ":sdks:java:core", configuration: "shadow")
implementation project(path: ":model:pipeline", configuration: "shadow")
+ implementation project(path: ":model:job-management", configuration:
"shadow")
implementation project(":runners:core-java")
permitUnusedDeclared project(":runners:core-java")
implementation project(":runners:java-fn-execution")
@@ -58,10 +59,10 @@ dependencies {
implementation "org.apache.kafka:kafka-clients:$kafka_version"
implementation "org.apache.kafka:kafka-streams:$kafka_version"
permitUnusedDeclared "org.apache.kafka:kafka-clients:$kafka_version"
- permitUnusedDeclared "org.apache.kafka:kafka-streams:$kafka_version"
testImplementation project(path: ":sdks:java:core", configuration:
"shadowTest")
testImplementation library.java.hamcrest
testImplementation library.java.junit
testImplementation library.java.mockito_core
+ testImplementation "org.apache.kafka:kafka-streams-test-utils:$kafka_version"
}
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 7c78d3a751b..3e97638695e 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
@@ -17,16 +17,25 @@
*/
package org.apache.beam.runners.kafka.streams;
+import java.util.Properties;
import org.apache.beam.model.pipeline.v1.RunnerApi;
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-/** Executes a portable pipeline by translating it to Kafka Streams. */
+/** Executes a portable pipeline by translating it to a Kafka Streams {@link
Topology}. */
public class KafkaStreamsPipelineRunner implements PortablePipelineRunner {
+ private static final Logger LOG =
LoggerFactory.getLogger(KafkaStreamsPipelineRunner.class);
+
private final KafkaStreamsPipelineOptions pipelineOptions;
public KafkaStreamsPipelineRunner(KafkaStreamsPipelineOptions
pipelineOptions) {
@@ -34,12 +43,35 @@ public class KafkaStreamsPipelineRunner implements
PortablePipelineRunner {
}
@Override
- public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo
jobInfo) throws Exception {
+ 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);
+
KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
KafkaStreamsTranslationContext context =
translator.createTranslationContext(jobInfo, pipelineOptions);
RunnerApi.Pipeline prepared = translator.prepareForTranslation(pipeline);
translator.translate(context, prepared);
- throw new IllegalStateException("Translation unexpectedly completed
without an executor");
+
+ Topology topology = context.getTopology();
+ LOG.info(
+ "Translated pipeline {} into Kafka Streams topology:\n{}",
+ jobInfo.jobId(),
+ topology.describe());
+
+ KafkaStreams kafkaStreams = new KafkaStreams(topology,
streamsConfig(jobInfo));
+ kafkaStreams.start();
+ return new KafkaStreamsPortablePipelineResult(kafkaStreams);
+ }
+
+ private Properties streamsConfig(JobInfo jobInfo) {
+ Properties props = new Properties();
+ props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
pipelineOptions.getBootstrapServers());
+ props.put(StreamsConfig.APPLICATION_ID_CONFIG,
pipelineOptions.getApplicationId());
+ props.put(StreamsConfig.STATE_DIR_CONFIG, pipelineOptions.getStateDir());
+ props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
StreamsConfig.EXACTLY_ONCE_V2);
+ props.put(StreamsConfig.CLIENT_ID_CONFIG, jobInfo.jobId());
+ return props;
}
}
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
new file mode 100644
index 00000000000..817746bf002
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/KafkaStreamsPortablePipelineResult.java
@@ -0,0 +1,135 @@
+/*
+ * 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.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.jobsubmission.PortablePipelineResult;
+import org.apache.beam.sdk.metrics.MetricResults;
+import org.apache.kafka.streams.KafkaStreams;
+import org.joda.time.Duration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Result of executing a portable pipeline as a {@link KafkaStreams}
application.
+ *
+ * <p>Translates the underlying {@link KafkaStreams.State} into Beam's {@link
+ * org.apache.beam.sdk.PipelineResult.State} and forwards {@link #cancel()} /
{@link
+ * #waitUntilFinish()} to the {@code KafkaStreams} instance.
+ */
+class KafkaStreamsPortablePipelineResult implements PortablePipelineResult {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(KafkaStreamsPortablePipelineResult.class);
+
+ private final KafkaStreams kafkaStreams;
+ private final CountDownLatch terminated = new CountDownLatch(1);
+ private volatile boolean cancelled = false;
+
+ KafkaStreamsPortablePipelineResult(KafkaStreams kafkaStreams) {
+ this.kafkaStreams = kafkaStreams;
+ kafkaStreams.setStateListener(
+ (newState, oldState) -> {
+ if (newState == KafkaStreams.State.NOT_RUNNING || newState ==
KafkaStreams.State.ERROR) {
+ terminated.countDown();
+ }
+ });
+ // Guard against the race where the KafkaStreams instance transitions to a
terminal state
+ // (e.g. immediate startup failure) before the state listener is
registered above. Without
+ // this check, the latch would never be counted down and waitUntilFinish()
would block forever.
+ KafkaStreams.State current = kafkaStreams.state();
+ if (current == KafkaStreams.State.NOT_RUNNING || current ==
KafkaStreams.State.ERROR) {
+ terminated.countDown();
+ }
+ }
+
+ @Override
+ public State getState() {
+ if (cancelled) {
+ return State.CANCELLED;
+ }
+ return mapState(kafkaStreams.state());
+ }
+
+ @Override
+ public State cancel() throws IOException {
+ cancelled = true;
+ kafkaStreams.close();
+ terminated.countDown();
+ return getState();
+ }
+
+ @Override
+ public State waitUntilFinish(Duration duration) {
+ try {
+ boolean reachedTerminal = terminated.await(duration.getMillis(),
TimeUnit.MILLISECONDS);
+ if (!reachedTerminal) {
+ return getState();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return State.UNKNOWN;
+ }
+ return getState();
+ }
+
+ @Override
+ public State waitUntilFinish() {
+ try {
+ terminated.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return State.UNKNOWN;
+ }
+ return getState();
+ }
+
+ @Override
+ public MetricResults metrics() {
+ throw new UnsupportedOperationException(
+ "Metrics are not yet implemented in the Kafka Streams runner.");
+ }
+
+ @Override
+ public JobApi.MetricResults portableMetrics() throws
UnsupportedOperationException {
+ LOG.debug("portableMetrics() not yet implemented in the Kafka Streams
runner");
+ return JobApi.MetricResults.newBuilder().build();
+ }
+
+ private static State mapState(KafkaStreams.State state) {
+ switch (state) {
+ case CREATED:
+ case REBALANCING:
+ return State.RUNNING;
+ case RUNNING:
+ return State.RUNNING;
+ case PENDING_SHUTDOWN:
+ return State.CANCELLED;
+ case PENDING_ERROR:
+ case ERROR:
+ return State.FAILED;
+ case NOT_RUNNING:
+ return State.DONE;
+ default:
+ return State.UNKNOWN;
+ }
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
new file mode 100644
index 00000000000..e9fc8ddb36a
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseProcessor.java
@@ -0,0 +1,140 @@
+/*
+ * 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.time.Duration;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.sdk.values.WindowedValues;
+import org.apache.kafka.streams.processor.Cancellable;
+import org.apache.kafka.streams.processor.PunctuationType;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.apache.kafka.streams.state.KeyValueStore;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Kafka Streams {@link Processor} implementing Beam's {@code Impulse}
transform.
+ *
+ * <p>For each task instance, emits exactly two {@link KStreamsPayload}s
downstream:
+ *
+ * <ol>
+ * <li>A {@link KStreamsPayload#data data} payload wrapping a {@link
WindowedValue} of an empty
+ * {@code byte[]} in the {@link
org.apache.beam.sdk.transforms.windowing.GlobalWindow}, with
+ * event-time {@link BoundedWindow#TIMESTAMP_MIN_VALUE}.
+ * <li>A {@link KStreamsPayload#watermark watermark} payload at {@link
+ * BoundedWindow#TIMESTAMP_MAX_VALUE} that tells downstream transforms
the source is done.
+ * </ol>
+ *
+ * <p>A persistent state store records whether the data element has already
been emitted so that
+ * task restarts do not duplicate the data. The terminal watermark, on the
other hand, is re-emitted
+ * on every restart so downstream watermark holds release correctly after
recovery (per Jan's review
+ * on PR #38689).
+ *
+ * <p>The trigger comes from a wall-clock punctuator scheduled on {@link
#init} — this lets the
+ * processor fire even when the dedicated bootstrap source topic is empty,
which is the expected
+ * production state.
+ *
+ * <p>Kafka Streams disallows negative record timestamps, so the forwarded
{@link Record} carries
+ * the Unix epoch ({@code 0L}). The Beam event-time lives inside the {@link
KStreamsPayload}
+ * variant: inside the {@link WindowedValue} for data, or as the explicit
watermark millis.
+ */
+class ImpulseProcessor implements Processor<byte[], byte[], byte[],
KStreamsPayload<byte[]>> {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ImpulseProcessor.class);
+
+ /** Sole entry in the state store; the value tracks whether this processor
has already emitted. */
+ static final String FIRED_KEY = "fired";
+
+ /** How soon after {@link #init} the punctuator first fires. */
+ private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50);
+
+ private final String stateStoreName;
+ private final String transformId;
+
+ private @Nullable ProcessorContext<byte[], KStreamsPayload<byte[]>> context;
+ private @Nullable KeyValueStore<String, Boolean> firedStore;
+ private @Nullable Cancellable scheduledPunctuator;
+
+ ImpulseProcessor(String stateStoreName, String transformId) {
+ this.stateStoreName = stateStoreName;
+ this.transformId = transformId;
+ }
+
+ @Override
+ public void init(ProcessorContext<byte[], KStreamsPayload<byte[]>> context) {
+ this.context = context;
+ this.firedStore = context.getStateStore(stateStoreName);
+ this.scheduledPunctuator =
+ context.schedule(PUNCTUATION_DELAY, PunctuationType.WALL_CLOCK_TIME,
ts -> maybeFire());
+ }
+
+ @Override
+ public void process(Record<byte[], byte[]> record) {
+ // Records that happen to land on the bootstrap topic are not actual data;
they just provide an
+ // extra opportunity to fire the impulse on restart. The state store still
gates the emit.
+ maybeFire();
+ }
+
+ private void maybeFire() {
+ ProcessorContext<byte[], KStreamsPayload<byte[]>> ctx = context;
+ KeyValueStore<String, Boolean> store = firedStore;
+ if (ctx == null || store == null) {
+ return;
+ }
+ if (Boolean.TRUE.equals(store.get(FIRED_KEY))) {
+ // Data was already emitted in a previous task lifetime, but downstream
watermark holds may
+ // still need to be released after the restart — re-emit the terminal
watermark and stop the
+ // punctuator.
+ forwardWatermarkMax(ctx);
+ cancelPunctuator();
+ return;
+ }
+ WindowedValue<byte[]> impulse = WindowedValues.valueInGlobalWindow(new
byte[0]);
+ // The output PCollection is not keyed (PCollection<byte[]>); use an empty
byte[] as a
+ // placeholder key so downstream processors that adopt the byte[]-key
convention see a
+ // consistent shape.
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<byte[]>>(
+ new byte[0], KStreamsPayload.data(impulse), 0L));
+ forwardWatermarkMax(ctx);
+ store.put(FIRED_KEY, Boolean.TRUE);
+ cancelPunctuator();
+ LOG.debug("Impulse {} emitted single element and terminal watermark",
transformId);
+ }
+
+ /** Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to
downstream processors. */
+ private static void forwardWatermarkMax(ProcessorContext<byte[],
KStreamsPayload<byte[]>> ctx) {
+ long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
+ ctx.forward(
+ new Record<byte[], KStreamsPayload<byte[]>>(
+ new byte[0], KStreamsPayload.watermark(maxMillis), 0L));
+ }
+
+ /** Cancels the wall-clock punctuator after the impulse has fired to stop
periodic wakeups. */
+ private void cancelPunctuator() {
+ Cancellable handle = scheduledPunctuator;
+ if (handle != null) {
+ handle.cancel();
+ scheduledPunctuator = null;
+ }
+ }
+}
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
new file mode 100644
index 00000000000..a90987ba638
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslator.java
@@ -0,0 +1,90 @@
+/*
+ * 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 org.apache.beam.model.pipeline.v1.RunnerApi;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.state.KeyValueBytesStoreSupplier;
+import org.apache.kafka.streams.state.Stores;
+
+/**
+ * Translates the {@code beam:transform:impulse:v1} URN.
+ *
+ * <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
+ * 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
+ * watermark payload at {@link
+ *
org.apache.beam.sdk.transforms.windowing.BoundedWindow#TIMESTAMP_MAX_VALUE}.
+ * <li>A per-processor {@link KeyValueBytesStoreSupplier persistent state
store} that records
+ * whether the impulse has already fired so task restarts do not
duplicate it.
+ * </ul>
+ *
+ * <p>The processor's output PCollection is registered with the translation
context so subsequent
+ * translators can wire themselves to this node by id.
+ *
+ * <p><b>Bootstrap topic lifecycle:</b> this translator does <em>not</em>
auto-create the bootstrap
+ * topic. The topic is expected to exist on the broker before the job starts;
otherwise Kafka
+ * Streams raises {@code MissingSourceTopicException} on startup. The
auto-create-vs-pre-create
+ * decision (design doc §12.1) is deferred to a follow-up sub-issue along with
the {@code
+ * AdminClient} wiring; pre-creation is sufficient for the {@code
TopologyTestDriver}-based unit
+ * tests in this PR.
+ */
+class ImpulseTranslator implements PTransformTranslator {
+
+ static final String SOURCE_SUFFIX = "-source";
+ static final String STATE_STORE_SUFFIX = "-state";
+
+ @Override
+ public void translate(
+ String transformId, RunnerApi.Pipeline pipeline,
KafkaStreamsTranslationContext context) {
+ RunnerApi.PTransform transform =
pipeline.getComponents().getTransformsOrThrow(transformId);
+ // Impulse produces exactly one output PCollection. This is the
produced-outputs map on the
+ // transform, not the consumer count — downstream transforms that consume
this PCollection are
+ // modeled as separate PTransforms whose `inputs` reference the same
PCollection id, and they
+ // are wired up by their own translators. Iterables.getOnlyElement throws
a clear
+ // IllegalArgumentException if the proto is malformed.
+ String outputPCollectionId =
Iterables.getOnlyElement(transform.getOutputsMap().values());
+
+ Topology topology = context.getTopology();
+ String sourceNodeName = transformId + SOURCE_SUFFIX;
+ String stateStoreName = transformId + STATE_STORE_SUFFIX;
+ String bootstrapTopic = context.getImpulseBootstrapTopic();
+
+ topology.addSource(
+ sourceNodeName,
+ Serdes.ByteArray().deserializer(),
+ Serdes.ByteArray().deserializer(),
+ bootstrapTopic);
+ topology.addProcessor(
+ transformId, () -> new ImpulseProcessor(stateStoreName, transformId),
sourceNodeName);
+ topology.addStateStore(
+ Stores.keyValueStoreBuilder(
+ Stores.persistentKeyValueStore(stateStoreName), Serdes.String(),
Serdes.Boolean()),
+ transformId);
+
+ context.registerPCollectionProducer(outputPCollectionId, transformId);
+ }
+}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
new file mode 100644
index 00000000000..47c94eea6ef
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/KStreamsPayload.java
@@ -0,0 +1,126 @@
+/*
+ * 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.util.Objects;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Sum-type envelope flowing between Kafka Streams processors in the Beam
Kafka Streams runner.
+ *
+ * <p>Every record value emitted by a runner-introduced processor is one of:
+ *
+ * <ul>
+ * <li>A {@link #isData() data} element wrapping a {@link WindowedValue}, or
+ * <li>A {@link #isWatermark() watermark} signal carrying an event-time
milliseconds value.
+ * </ul>
+ *
+ * <p>The envelope lets a single Kafka Streams output channel carry both Beam
data and the watermark
+ * / synchronization primitives that Kafka Streams does not natively support.
Future control
+ * messages (e.g. the {@code (epoch, assigned_partitions)} propagation from
design doc §5) can be
+ * added here as additional variants.
+ *
+ * <p>This class is intentionally in-JVM only for now; serialization across
topic boundaries
+ * (repartition or sink topics) will be introduced when the first translator
that emits to a topic
+ * lands, at which point a corresponding Kafka {@link
org.apache.kafka.common.serialization.Serde}
+ * will be added.
+ *
+ * @param <T> element type carried by data variants
+ */
+public final class KStreamsPayload<T> {
+
+ private enum Kind {
+ DATA,
+ WATERMARK
+ }
+
+ private final Kind kind;
+ private final @Nullable WindowedValue<T> data;
+ private final long watermarkMillis;
+
+ private KStreamsPayload(Kind kind, @Nullable WindowedValue<T> data, long
watermarkMillis) {
+ this.kind = kind;
+ this.data = data;
+ this.watermarkMillis = watermarkMillis;
+ }
+
+ /** Returns a data payload wrapping the given {@link WindowedValue}. */
+ public static <T> KStreamsPayload<T> data(WindowedValue<T> value) {
+ return new KStreamsPayload<>(Kind.DATA, value, 0L);
+ }
+
+ /** Returns a watermark payload carrying the given event-time milliseconds.
*/
+ public static <T> KStreamsPayload<T> watermark(long watermarkMillis) {
+ return new KStreamsPayload<>(Kind.WATERMARK, null, watermarkMillis);
+ }
+
+ public boolean isData() {
+ return kind == Kind.DATA;
+ }
+
+ public boolean isWatermark() {
+ return kind == Kind.WATERMARK;
+ }
+
+ /**
+ * Returns the wrapped data element. Caller must check {@link #isData()}
first; calling this on a
+ * watermark payload throws.
+ */
+ public WindowedValue<T> getData() {
+ if (kind != Kind.DATA || data == null) {
+ throw new IllegalStateException("Payload is not a data element: kind=" +
kind);
+ }
+ return data;
+ }
+
+ /**
+ * Returns the watermark event-time milliseconds. Caller must check {@link
#isWatermark()} first;
+ * calling this on a data payload throws.
+ */
+ public long getWatermarkMillis() {
+ if (kind != Kind.WATERMARK) {
+ throw new IllegalStateException("Payload is not a watermark: kind=" +
kind);
+ }
+ return watermarkMillis;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof KStreamsPayload)) {
+ return false;
+ }
+ KStreamsPayload<?> that = (KStreamsPayload<?>) o;
+ return kind == that.kind
+ && watermarkMillis == that.watermarkMillis
+ && Objects.equals(data, that.data);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(kind, data, watermarkMillis);
+ }
+
+ @Override
+ public String toString() {
+ return kind == Kind.DATA ? "Data{" + data + "}" : "Watermark{" +
watermarkMillis + "}";
+ }
+}
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 cc915d604b6..eb856714614 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
@@ -18,19 +18,38 @@
package org.apache.beam.runners.kafka.streams.translation;
import java.util.Map;
-import java.util.TreeMap;
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.construction.PTransformTranslation;
+import org.apache.beam.sdk.util.construction.graph.PipelineNode;
+import org.apache.beam.sdk.util.construction.graph.QueryablePipeline;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
/**
- * Translates a portable Beam pipeline into a Kafka Streams {@code Topology}.
+ * Translates a portable Beam pipeline into a Kafka Streams {@link
+ * org.apache.kafka.streams.Topology}.
*
- * <p>The initial implementation only validates the graph and fails fast with
an explicit message
- * for transforms that are not yet supported.
+ * <p>Walks the pipeline in topological order via {@link QueryablePipeline}
and dispatches each
+ * transform to a {@link PTransformTranslator} keyed by URN. Transforms whose
URN has no registered
+ * translator fail fast with a clear {@link UnsupportedOperationException} so
the failure points at
+ * the exact transform that is not yet supported.
*/
public class KafkaStreamsPipelineTranslator {
+ private final Map<String, PTransformTranslator> urnToTranslator;
+
+ public KafkaStreamsPipelineTranslator() {
+ this(
+ ImmutableMap.<String, PTransformTranslator>builder()
+ .put(PTransformTranslation.IMPULSE_TRANSFORM_URN, new
ImpulseTranslator())
+ .build());
+ }
+
+ KafkaStreamsPipelineTranslator(Map<String, PTransformTranslator>
urnToTranslator) {
+ this.urnToTranslator = urnToTranslator;
+ }
+
public KafkaStreamsTranslationContext createTranslationContext(
JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions) {
return KafkaStreamsTranslationContext.create(jobInfo, pipelineOptions);
@@ -42,29 +61,27 @@ public class KafkaStreamsPipelineTranslator {
}
/**
- * Translates the pipeline. Throws {@link UnsupportedOperationException}
with a clear URN message
- * for the first unsupported primitive encountered.
+ * Walks the pipeline in topological order and translates each transform
whose URN is supported.
+ * Throws {@link UnsupportedOperationException} on the first unsupported URN.
*/
public void translate(KafkaStreamsTranslationContext context,
RunnerApi.Pipeline pipeline) {
- Map<String, RunnerApi.PTransform> transforms =
pipeline.getComponents().getTransformsMap();
- TreeMap<String, RunnerApi.PTransform> ordered = new TreeMap<>(transforms);
- for (Map.Entry<String, RunnerApi.PTransform> entry : ordered.entrySet()) {
- RunnerApi.PTransform transform = entry.getValue();
- if (!transform.hasSpec()) {
- continue;
- }
- String urn = transform.getSpec().getUrn();
- if (urn.isEmpty()) {
- continue;
+ QueryablePipeline queryable =
+ QueryablePipeline.forTransforms(
+ pipeline.getRootTransformIdsList(), pipeline.getComponents());
+ for (PipelineNode.PTransformNode node :
queryable.getTopologicallyOrderedTransforms()) {
+ String urn = node.getTransform().getSpec().getUrn();
+ PTransformTranslator translator = urnToTranslator.get(urn);
+ if (translator == null) {
+ throw new UnsupportedOperationException(
+ "No translator registered for URN "
+ + urn
+ + " (transformId="
+ + node.getId()
+ + ", jobId="
+ + context.getJobInfo().jobId()
+ + ")");
}
- throw new UnsupportedOperationException(
- "No translator registered for URN "
- + urn
- + " (jobId="
- + context.getJobInfo().jobId()
- + ")");
+ translator.translate(node.getId(), pipeline, context);
}
- throw new UnsupportedOperationException(
- "No translator registered for pipeline (no transform URNs found)");
}
}
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 7c6d3d07915..3d95eabafed 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
@@ -17,24 +17,44 @@
*/
package org.apache.beam.runners.kafka.streams.translation;
+import java.util.HashMap;
+import java.util.Map;
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
+import org.apache.kafka.streams.Topology;
-/** Mutable state shared while translating a portable pipeline into a Kafka
Streams topology. */
+/**
+ * Mutable state shared while translating a portable pipeline into a Kafka
Streams {@link Topology}.
+ *
+ * <p>Holds the topology being built and a {@code PCollection-id →
processor-node-name} map so that
+ * downstream transforms can wire themselves to the right parent node.
+ */
public class KafkaStreamsTranslationContext {
+ /** Prefix for the per-job bootstrap topic Impulse reads from. */
+ private static final String IMPULSE_BOOTSTRAP_TOPIC_PREFIX =
"__beam_impulse_";
+
private final JobInfo jobInfo;
private final KafkaStreamsPipelineOptions pipelineOptions;
+ private final Topology topology;
+ private final Map<String, String> pCollectionIdToProcessorName;
public static KafkaStreamsTranslationContext create(
JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions) {
- return new KafkaStreamsTranslationContext(jobInfo, pipelineOptions);
+ return new KafkaStreamsTranslationContext(jobInfo, pipelineOptions, new
Topology());
+ }
+
+ static KafkaStreamsTranslationContext createWithTopology(
+ JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions, Topology
topology) {
+ return new KafkaStreamsTranslationContext(jobInfo, pipelineOptions,
topology);
}
private KafkaStreamsTranslationContext(
- JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions) {
+ JobInfo jobInfo, KafkaStreamsPipelineOptions pipelineOptions, Topology
topology) {
this.jobInfo = jobInfo;
this.pipelineOptions = pipelineOptions;
+ this.topology = topology;
+ this.pCollectionIdToProcessorName = new HashMap<>();
}
public JobInfo getJobInfo() {
@@ -44,4 +64,40 @@ public class KafkaStreamsTranslationContext {
public KafkaStreamsPipelineOptions getPipelineOptions() {
return pipelineOptions;
}
+
+ /** Returns the {@link Topology} being built by the translation. */
+ public Topology getTopology() {
+ return topology;
+ }
+
+ /**
+ * Registers the processor node that produces the given Beam PCollection.
Downstream translators
+ * resolve their parent processor names by looking up the input PCollection
id.
+ */
+ public void registerPCollectionProducer(String pCollectionId, String
processorName) {
+ String existing = pCollectionIdToProcessorName.putIfAbsent(pCollectionId,
processorName);
+ if (existing != null && !existing.equals(processorName)) {
+ throw new IllegalStateException(
+ "PCollection "
+ + pCollectionId
+ + " already produced by processor "
+ + existing
+ + "; cannot reassign to "
+ + processorName);
+ }
+ }
+
+ /** Returns the processor node name producing the given PCollection. */
+ public String getProcessorNameForPCollection(String pCollectionId) {
+ String name = pCollectionIdToProcessorName.get(pCollectionId);
+ if (name == null) {
+ throw new IllegalStateException("No processor registered for PCollection
" + pCollectionId);
+ }
+ return name;
+ }
+
+ /** Returns the dedicated bootstrap topic name used by Impulse for this
application. */
+ public String getImpulseBootstrapTopic() {
+ return IMPULSE_BOOTSTRAP_TOPIC_PREFIX + pipelineOptions.getApplicationId();
+ }
}
diff --git
a/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/PTransformTranslator.java
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/PTransformTranslator.java
new file mode 100644
index 00000000000..c6bd44b8049
--- /dev/null
+++
b/runners/kafka-streams/src/main/java/org/apache/beam/runners/kafka/streams/translation/PTransformTranslator.java
@@ -0,0 +1,41 @@
+/*
+ * 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 org.apache.beam.model.pipeline.v1.RunnerApi;
+
+/**
+ * Translates a single Beam {@link RunnerApi.PTransform} into one or more
nodes in the Kafka Streams
+ * {@link org.apache.kafka.streams.Topology} held by the {@link
KafkaStreamsTranslationContext}.
+ */
+@FunctionalInterface
+public interface PTransformTranslator {
+
+ /**
+ * Translates the transform identified by {@code transformId} into nodes on
the topology held by
+ * {@code context}.
+ *
+ * @param transformId the id of the transform to translate, as keyed in
{@code
+ * pipeline.getComponents().getTransformsMap()}
+ * @param pipeline the full pipeline proto, in case the translator needs to
walk subcomponents
+ * (coders, windowing strategies, etc.)
+ * @param context the shared translation context (topology under
construction, producer map, etc.)
+ */
+ void translate(
+ String transformId, RunnerApi.Pipeline pipeline,
KafkaStreamsTranslationContext context);
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
new file mode 100644
index 00000000000..30000d77d86
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/KafkaStreamsRunnerTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
+import org.apache.beam.runners.kafka.streams.translation.KStreamsPayload;
+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.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.testing.CrashingRunner;
+import org.apache.beam.sdk.transforms.Impulse;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import org.apache.beam.sdk.util.construction.PipelineTranslation;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.ProcessorSupplier;
+import org.apache.kafka.streams.processor.api.Record;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.Test;
+
+/**
+ * Pipeline-level integration tests that build a Beam {@link Pipeline} via the
high-level Java SDK
+ * ({@code Pipeline.create().apply(Impulse.create())}), translate it via the
runner, and execute the
+ * resulting Kafka Streams topology under {@link TopologyTestDriver}.
+ *
+ * <p>This is the test layer Jan requested on PR #38689: rather than building
hand-rolled {@link
+ * RunnerApi.Pipeline} protos, drive translation from the same surface a user
would write. The tests
+ * stop short of calling {@code pipeline.run()} because that would require a
real Kafka broker —
+ * {@code TopologyTestDriver} replaces the broker for unit-test purposes.
+ */
+public class KafkaStreamsRunnerTest {
+
+ private static final String JOB_ID = "kafka-streams-runner-test";
+ private static final String APPLICATION_ID = "ks-runner-test";
+
+ @Test
+ public void impulseOnlyPipelineEmitsDataAndTerminalWatermark() {
+ Pipeline pipeline = Pipeline.create(pipelineOptions());
+ pipeline.apply("impulse", Impulse.create());
+
+ RunnerApi.Pipeline pipelineProto = PipelineTranslation.toProto(pipeline);
+
+ KafkaStreamsPipelineOptions options =
+ pipeline.getOptions().as(KafkaStreamsPipelineOptions.class);
+ KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
+ JobInfo jobInfo =
+ JobInfo.create(
+ JOB_ID, options.getJobName(), "",
PipelineOptionsTranslation.toProto(options));
+ KafkaStreamsTranslationContext context =
translator.createTranslationContext(jobInfo, options);
+ translator.translate(context,
translator.prepareForTranslation(pipelineProto));
+
+ CapturingProcessor capture = new CapturingProcessor();
+ Topology topology = context.getTopology();
+ // Wire a downstream test sink to every translated transform node so we
can capture emissions.
+ // Impulse is the only transform here, so we attach to "impulse" (the
processor name registered
+ // by ImpulseTranslator).
+ topology.addProcessor("capture", capture,
expectedImpulseProcessorName(pipelineProto));
+
+ try (TopologyTestDriver driver = new TopologyTestDriver(topology,
streamsConfig())) {
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ }
+
+ assertThat(capture.received.size(), is(2));
+ assertThat(capture.received.get(0).isData(), is(true));
+ assertThat(capture.received.get(0).getData().getValue().length, is(0));
+ assertThat(capture.received.get(1).isWatermark(), is(true));
+ assertThat(
+ capture.received.get(1).getWatermarkMillis(),
+ is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
+ }
+
+ /**
+ * Finds the transform id that {@link Impulse} got assigned by the SDK so
the test can attach a
+ * capturing processor to the matching Kafka Streams processor node (the
translator names the
+ * processor after the transform id).
+ */
+ private static String expectedImpulseProcessorName(RunnerApi.Pipeline
pipelineProto) {
+ for (java.util.Map.Entry<String, RunnerApi.PTransform> entry :
+ pipelineProto.getComponents().getTransformsMap().entrySet()) {
+ if
("beam:transform:impulse:v1".equals(entry.getValue().getSpec().getUrn())) {
+ return entry.getKey();
+ }
+ }
+ throw new IllegalStateException("Impulse transform not found in pipeline
proto");
+ }
+
+ private static PipelineOptions pipelineOptions() {
+ PipelineOptions options =
+ PipelineOptionsFactory.fromArgs("--applicationId=" +
APPLICATION_ID).create();
+ // Pipeline.create() requires a runner; CrashingRunner is the conventional
"this pipeline is
+ // not going to be run() directly" choice used by other portable-runner
tests.
+ options.setRunner(CrashingRunner.class);
+
options.as(KafkaStreamsPipelineOptions.class).setApplicationId(APPLICATION_ID);
+ return options;
+ }
+
+ private static Properties streamsConfig() {
+ Properties props = new Properties();
+ props.put(StreamsConfig.APPLICATION_ID_CONFIG, APPLICATION_ID);
+ props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
+ props.put(
+ StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ props.put(
+ StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ return props;
+ }
+
+ private static class CapturingProcessor
+ implements ProcessorSupplier<
+ byte[], KStreamsPayload<byte[]>, byte[], KStreamsPayload<byte[]>> {
+
+ final List<KStreamsPayload<byte[]>> received =
Collections.synchronizedList(new ArrayList<>());
+
+ @Override
+ public Processor<byte[], KStreamsPayload<byte[]>, byte[],
KStreamsPayload<byte[]>> get() {
+ return new Processor<byte[], KStreamsPayload<byte[]>, byte[],
KStreamsPayload<byte[]>>() {
+ @Override
+ public void init(@Nullable ProcessorContext<byte[],
KStreamsPayload<byte[]>> context) {
+ // no-op
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<byte[]>> record) {
+ received.add(record.value());
+ }
+ };
+ }
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
new file mode 100644
index 00000000000..a092b10e02c
--- /dev/null
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/ImpulseTranslatorTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.ProcessorSupplier;
+import org.apache.kafka.streams.processor.api.Record;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.junit.Test;
+
+/**
+ * Behavioural tests for {@link ImpulseTranslator} using {@link
TopologyTestDriver}.
+ *
+ * <p>The translator builds a topology with a real source + processor pair.
The tests sit a {@link
+ * CapturingProcessor} downstream so emitted {@link KStreamsPayload} elements
can be inspected
+ * directly without going through a Kafka sink topic (the runner does not
produce one because no
+ * downstream PCollections exist yet).
+ */
+public class ImpulseTranslatorTest {
+
+ @Test
+ public void impulseEmitsDataElementFollowedByTerminalWatermark() {
+ KafkaStreamsTranslationContext context =
KafkaStreamsPipelineTranslatorTest.newContext();
+ new KafkaStreamsPipelineTranslator()
+ .translate(context,
KafkaStreamsPipelineTranslatorTest.singleImpulsePipeline());
+
+ CapturingProcessor capture = new CapturingProcessor();
+ Topology topology = context.getTopology();
+ topology.addProcessor("capture", capture, "impulse");
+
+ try (TopologyTestDriver driver = new TopologyTestDriver(topology,
baseProps())) {
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ }
+
+ assertThat(capture.received.size(), is(2));
+
+ KStreamsPayload<byte[]> dataPayload = capture.received.get(0);
+ assertThat(dataPayload, is(notNullValue()));
+ assertThat(dataPayload.isData(), is(true));
+ WindowedValue<byte[]> data = dataPayload.getData();
+ assertThat(data.getValue().length, is(0));
+ assertThat(data.getWindows().size(), is(1));
+ assertThat(data.getTimestamp().getMillis(),
is(BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis()));
+
+ KStreamsPayload<byte[]> watermarkPayload = capture.received.get(1);
+ assertThat(watermarkPayload.isWatermark(), is(true));
+ assertThat(
+ watermarkPayload.getWatermarkMillis(),
is(BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis()));
+ }
+
+ @Test
+ public void impulseDoesNotReEmitDataOnRepeatedPunctuation() {
+ KafkaStreamsTranslationContext context =
KafkaStreamsPipelineTranslatorTest.newContext();
+ new KafkaStreamsPipelineTranslator()
+ .translate(context,
KafkaStreamsPipelineTranslatorTest.singleImpulsePipeline());
+
+ CapturingProcessor capture = new CapturingProcessor();
+ Topology topology = context.getTopology();
+ topology.addProcessor("capture", capture, "impulse");
+
+ try (TopologyTestDriver driver = new TopologyTestDriver(topology,
baseProps())) {
+ driver.advanceWallClockTime(Duration.ofSeconds(1));
+ // Trigger again — the data element is gated by the state store; the
punctuator should also
+ // have been cancelled after the first emission, so no further events
should be captured.
+ driver.advanceWallClockTime(Duration.ofSeconds(5));
+ }
+
+ assertThat(capture.received.size(), is(2));
+ assertThat(capture.received.get(0).isData(), is(true));
+ assertThat(capture.received.get(1).isWatermark(), is(true));
+ }
+
+ private static Properties baseProps() {
+ Properties props = new Properties();
+ props.put(StreamsConfig.APPLICATION_ID_CONFIG, "ks-translator-test");
+ props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
+ props.put(
+ StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ props.put(
+ StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.ByteArray().getClass().getName());
+ return props;
+ }
+
+ /**
+ * Captures {@link KStreamsPayload} records forwarded by {@link
ImpulseProcessor}. The supplier
+ * returns a fresh forwarder each call (required by Kafka Streams) but all
forwarders write into
+ * the shared {@link #received} list so the test can read the captured
elements after the topology
+ * is closed.
+ */
+ private static class CapturingProcessor
+ implements ProcessorSupplier<
+ byte[], KStreamsPayload<byte[]>, byte[], KStreamsPayload<byte[]>> {
+
+ final List<KStreamsPayload<byte[]>> received =
Collections.synchronizedList(new ArrayList<>());
+
+ @Override
+ public Processor<byte[], KStreamsPayload<byte[]>, byte[],
KStreamsPayload<byte[]>> get() {
+ return new Processor<byte[], KStreamsPayload<byte[]>, byte[],
KStreamsPayload<byte[]>>() {
+ @Override
+ public void init(@Nullable ProcessorContext<byte[],
KStreamsPayload<byte[]>> context) {
+ // no-op
+ }
+
+ @Override
+ public void process(Record<byte[], KStreamsPayload<byte[]>> record) {
+ received.add(record.value());
+ }
+ };
+ }
+ }
+}
diff --git
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
index 86e9cf87849..44cc00bcebb 100644
---
a/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
+++
b/runners/kafka-streams/src/test/java/org/apache/beam/runners/kafka/streams/translation/KafkaStreamsPipelineTranslatorTest.java
@@ -18,6 +18,7 @@
package org.apache.beam.runners.kafka.streams.translation;
import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertThrows;
@@ -27,18 +28,14 @@ import
org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.util.construction.PTransformTranslation;
import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation;
+import org.apache.kafka.streams.TopologyDescription;
import org.junit.Test;
-/**
- * Tests for {@link KafkaStreamsPipelineTranslator}.
- *
- * <p>The skeleton translator does not yet handle any transforms; these tests
pin the current
- * "fail-fast with a clear URN" contract so that follow-up sub-issues can
replace the assertions as
- * real translators are added.
- */
+/** Tests for {@link KafkaStreamsPipelineTranslator}. */
public class KafkaStreamsPipelineTranslatorTest {
private static final String JOB_ID = "kafka-streams-test-job";
+ private static final String OUTPUT_PCOLLECTION_ID = "impulse.out";
@Test
public void translateRejectsUnknownTransformWithUrnInMessage() {
@@ -47,15 +44,16 @@ public class KafkaStreamsPipelineTranslatorTest {
RunnerApi.Pipeline pipeline =
RunnerApi.Pipeline.newBuilder()
+ .addRootTransformIds("gbk")
.setComponents(
RunnerApi.Components.newBuilder()
.putTransforms(
- "impulse",
+ "gbk",
RunnerApi.PTransform.newBuilder()
- .setUniqueName("Impulse")
+ .setUniqueName("GroupByKey")
.setSpec(
RunnerApi.FunctionSpec.newBuilder()
-
.setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
+
.setUrn(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN))
.build()))
.build();
@@ -65,33 +63,31 @@ public class KafkaStreamsPipelineTranslatorTest {
() -> translator.translate(context,
translator.prepareForTranslation(pipeline)));
assertThat(ex.getMessage(), containsString("No translator registered for
URN"));
- assertThat(ex.getMessage(),
containsString(PTransformTranslation.IMPULSE_TRANSFORM_URN));
+ assertThat(ex.getMessage(),
containsString(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN));
+ assertThat(ex.getMessage(), containsString("gbk"));
assertThat(ex.getMessage(), containsString(JOB_ID));
}
@Test
- public void translateRejectsEmptyPipeline() {
+ public void translateImpulsePipelineAddsSourceAndProcessorNodes() {
KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
KafkaStreamsTranslationContext context = newContext();
- RunnerApi.Pipeline pipeline =
- RunnerApi.Pipeline.newBuilder()
- .setComponents(RunnerApi.Components.newBuilder().build())
- .build();
+ RunnerApi.Pipeline pipeline = singleImpulsePipeline();
+ translator.translate(context, translator.prepareForTranslation(pipeline));
- UnsupportedOperationException ex =
- assertThrows(
- UnsupportedOperationException.class,
- () -> translator.translate(context,
translator.prepareForTranslation(pipeline)));
+ TopologyDescription description = context.getTopology().describe();
+ String describeText = description.toString();
- assertThat(ex.getMessage(), containsString("No translator registered"));
+ assertThat(describeText, containsString("impulse-source"));
+ assertThat(describeText, containsString("impulse"));
+ assertThat(context.getProcessorNameForPCollection(OUTPUT_PCOLLECTION_ID),
is("impulse"));
}
@Test
public void createTranslationContextExposesJobInfoAndOptions() {
KafkaStreamsPipelineTranslator translator = new
KafkaStreamsPipelineTranslator();
- KafkaStreamsPipelineOptions options =
- PipelineOptionsFactory.create().as(KafkaStreamsPipelineOptions.class);
+ KafkaStreamsPipelineOptions options = testOptions();
JobInfo jobInfo =
JobInfo.create(
JOB_ID, options.getJobName(), "",
PipelineOptionsTranslation.toProto(options));
@@ -102,12 +98,37 @@ public class KafkaStreamsPipelineTranslatorTest {
assertThat(context.getPipelineOptions().getBootstrapServers(),
containsString("localhost"));
}
- private static KafkaStreamsTranslationContext newContext() {
- KafkaStreamsPipelineOptions options =
- PipelineOptionsFactory.create().as(KafkaStreamsPipelineOptions.class);
+ static RunnerApi.Pipeline singleImpulsePipeline() {
+ return RunnerApi.Pipeline.newBuilder()
+ .addRootTransformIds("impulse")
+ .setComponents(
+ RunnerApi.Components.newBuilder()
+ .putTransforms(
+ "impulse",
+ RunnerApi.PTransform.newBuilder()
+ .setUniqueName("Impulse")
+ .putOutputs("out", OUTPUT_PCOLLECTION_ID)
+ .setSpec(
+ RunnerApi.FunctionSpec.newBuilder()
+
.setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
+ .build())
+ .putPcollections(
+ OUTPUT_PCOLLECTION_ID,
+
RunnerApi.PCollection.newBuilder().setUniqueName(OUTPUT_PCOLLECTION_ID).build())
+ .build())
+ .build();
+ }
+
+ static KafkaStreamsTranslationContext newContext() {
+ KafkaStreamsPipelineOptions options = testOptions();
JobInfo jobInfo =
JobInfo.create(
JOB_ID, options.getJobName(), "",
PipelineOptionsTranslation.toProto(options));
return KafkaStreamsTranslationContext.create(jobInfo, options);
}
+
+ static KafkaStreamsPipelineOptions testOptions() {
+ return
PipelineOptionsFactory.fromArgs("--applicationId=ks-translator-test")
+ .as(KafkaStreamsPipelineOptions.class);
+ }
}