Skip to content

Commit faefc95

Browse files
[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
1 parent 64ad482 commit faefc95

12 files changed

Lines changed: 1020 additions & 57 deletions

runners/kafka-streams/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies {
4444

4545
implementation project(path: ":sdks:java:core", configuration: "shadow")
4646
implementation project(path: ":model:pipeline", configuration: "shadow")
47+
implementation project(path: ":model:job-management", configuration: "shadow")
4748
implementation project(":runners:core-java")
4849
permitUnusedDeclared project(":runners:core-java")
4950
implementation project(":runners:java-fn-execution")
@@ -58,10 +59,10 @@ dependencies {
5859
implementation "org.apache.kafka:kafka-clients:$kafka_version"
5960
implementation "org.apache.kafka:kafka-streams:$kafka_version"
6061
permitUnusedDeclared "org.apache.kafka:kafka-clients:$kafka_version"
61-
permitUnusedDeclared "org.apache.kafka:kafka-streams:$kafka_version"
6262

6363
testImplementation project(path: ":sdks:java:core", configuration: "shadowTest")
6464
testImplementation library.java.hamcrest
6565
testImplementation library.java.junit
6666
testImplementation library.java.mockito_core
67+
testImplementation "org.apache.kafka:kafka-streams-test-utils:$kafka_version"
6768
}

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,61 @@
1717
*/
1818
package org.apache.beam.runners.kafka.streams;
1919

20+
import java.util.Properties;
2021
import org.apache.beam.model.pipeline.v1.RunnerApi;
2122
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
2223
import org.apache.beam.runners.jobsubmission.PortablePipelineResult;
2324
import org.apache.beam.runners.jobsubmission.PortablePipelineRunner;
2425
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsPipelineTranslator;
2526
import org.apache.beam.runners.kafka.streams.translation.KafkaStreamsTranslationContext;
27+
import org.apache.beam.sdk.options.PipelineOptionsValidator;
28+
import org.apache.kafka.streams.KafkaStreams;
29+
import org.apache.kafka.streams.StreamsConfig;
30+
import org.apache.kafka.streams.Topology;
31+
import org.slf4j.Logger;
32+
import org.slf4j.LoggerFactory;
2633

27-
/** Executes a portable pipeline by translating it to Kafka Streams. */
34+
/** Executes a portable pipeline by translating it to a Kafka Streams {@link Topology}. */
2835
public class KafkaStreamsPipelineRunner implements PortablePipelineRunner {
2936

37+
private static final Logger LOG = LoggerFactory.getLogger(KafkaStreamsPipelineRunner.class);
38+
3039
private final KafkaStreamsPipelineOptions pipelineOptions;
3140

3241
public KafkaStreamsPipelineRunner(KafkaStreamsPipelineOptions pipelineOptions) {
3342
this.pipelineOptions = pipelineOptions;
3443
}
3544

3645
@Override
37-
public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo jobInfo) throws Exception {
46+
public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo jobInfo) {
47+
// Surface a clear error if a required option (e.g. applicationId) is missing instead of
48+
// letting Properties.put fail with a raw NullPointerException further down.
49+
PipelineOptionsValidator.validate(KafkaStreamsPipelineOptions.class, pipelineOptions);
50+
3851
KafkaStreamsPipelineTranslator translator = new KafkaStreamsPipelineTranslator();
3952
KafkaStreamsTranslationContext context =
4053
translator.createTranslationContext(jobInfo, pipelineOptions);
4154
RunnerApi.Pipeline prepared = translator.prepareForTranslation(pipeline);
4255
translator.translate(context, prepared);
43-
throw new IllegalStateException("Translation unexpectedly completed without an executor");
56+
57+
Topology topology = context.getTopology();
58+
LOG.info(
59+
"Translated pipeline {} into Kafka Streams topology:\n{}",
60+
jobInfo.jobId(),
61+
topology.describe());
62+
63+
KafkaStreams kafkaStreams = new KafkaStreams(topology, streamsConfig(jobInfo));
64+
kafkaStreams.start();
65+
return new KafkaStreamsPortablePipelineResult(kafkaStreams);
66+
}
67+
68+
private Properties streamsConfig(JobInfo jobInfo) {
69+
Properties props = new Properties();
70+
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, pipelineOptions.getBootstrapServers());
71+
props.put(StreamsConfig.APPLICATION_ID_CONFIG, pipelineOptions.getApplicationId());
72+
props.put(StreamsConfig.STATE_DIR_CONFIG, pipelineOptions.getStateDir());
73+
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);
74+
props.put(StreamsConfig.CLIENT_ID_CONFIG, jobInfo.jobId());
75+
return props;
4476
}
4577
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams;
19+
20+
import java.io.IOException;
21+
import java.util.concurrent.CountDownLatch;
22+
import java.util.concurrent.TimeUnit;
23+
import org.apache.beam.model.jobmanagement.v1.JobApi;
24+
import org.apache.beam.runners.jobsubmission.PortablePipelineResult;
25+
import org.apache.beam.sdk.metrics.MetricResults;
26+
import org.apache.kafka.streams.KafkaStreams;
27+
import org.joda.time.Duration;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
30+
31+
/**
32+
* Result of executing a portable pipeline as a {@link KafkaStreams} application.
33+
*
34+
* <p>Translates the underlying {@link KafkaStreams.State} into Beam's {@link
35+
* org.apache.beam.sdk.PipelineResult.State} and forwards {@link #cancel()} / {@link
36+
* #waitUntilFinish()} to the {@code KafkaStreams} instance.
37+
*/
38+
class KafkaStreamsPortablePipelineResult implements PortablePipelineResult {
39+
40+
private static final Logger LOG =
41+
LoggerFactory.getLogger(KafkaStreamsPortablePipelineResult.class);
42+
43+
private final KafkaStreams kafkaStreams;
44+
private final CountDownLatch terminated = new CountDownLatch(1);
45+
private volatile boolean cancelled = false;
46+
47+
KafkaStreamsPortablePipelineResult(KafkaStreams kafkaStreams) {
48+
this.kafkaStreams = kafkaStreams;
49+
kafkaStreams.setStateListener(
50+
(newState, oldState) -> {
51+
if (newState == KafkaStreams.State.NOT_RUNNING || newState == KafkaStreams.State.ERROR) {
52+
terminated.countDown();
53+
}
54+
});
55+
// Guard against the race where the KafkaStreams instance transitions to a terminal state
56+
// (e.g. immediate startup failure) before the state listener is registered above. Without
57+
// this check, the latch would never be counted down and waitUntilFinish() would block forever.
58+
KafkaStreams.State current = kafkaStreams.state();
59+
if (current == KafkaStreams.State.NOT_RUNNING || current == KafkaStreams.State.ERROR) {
60+
terminated.countDown();
61+
}
62+
}
63+
64+
@Override
65+
public State getState() {
66+
if (cancelled) {
67+
return State.CANCELLED;
68+
}
69+
return mapState(kafkaStreams.state());
70+
}
71+
72+
@Override
73+
public State cancel() throws IOException {
74+
cancelled = true;
75+
kafkaStreams.close();
76+
terminated.countDown();
77+
return getState();
78+
}
79+
80+
@Override
81+
public State waitUntilFinish(Duration duration) {
82+
try {
83+
boolean reachedTerminal = terminated.await(duration.getMillis(), TimeUnit.MILLISECONDS);
84+
if (!reachedTerminal) {
85+
return getState();
86+
}
87+
} catch (InterruptedException e) {
88+
Thread.currentThread().interrupt();
89+
return State.UNKNOWN;
90+
}
91+
return getState();
92+
}
93+
94+
@Override
95+
public State waitUntilFinish() {
96+
try {
97+
terminated.await();
98+
} catch (InterruptedException e) {
99+
Thread.currentThread().interrupt();
100+
return State.UNKNOWN;
101+
}
102+
return getState();
103+
}
104+
105+
@Override
106+
public MetricResults metrics() {
107+
throw new UnsupportedOperationException(
108+
"Metrics are not yet implemented in the Kafka Streams runner.");
109+
}
110+
111+
@Override
112+
public JobApi.MetricResults portableMetrics() throws UnsupportedOperationException {
113+
LOG.debug("portableMetrics() not yet implemented in the Kafka Streams runner");
114+
return JobApi.MetricResults.newBuilder().build();
115+
}
116+
117+
private static State mapState(KafkaStreams.State state) {
118+
switch (state) {
119+
case CREATED:
120+
case REBALANCING:
121+
return State.RUNNING;
122+
case RUNNING:
123+
return State.RUNNING;
124+
case PENDING_SHUTDOWN:
125+
return State.CANCELLED;
126+
case PENDING_ERROR:
127+
case ERROR:
128+
return State.FAILED;
129+
case NOT_RUNNING:
130+
return State.DONE;
131+
default:
132+
return State.UNKNOWN;
133+
}
134+
}
135+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams.translation;
19+
20+
import java.time.Duration;
21+
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
22+
import org.apache.beam.sdk.values.WindowedValue;
23+
import org.apache.beam.sdk.values.WindowedValues;
24+
import org.apache.kafka.streams.processor.Cancellable;
25+
import org.apache.kafka.streams.processor.PunctuationType;
26+
import org.apache.kafka.streams.processor.api.Processor;
27+
import org.apache.kafka.streams.processor.api.ProcessorContext;
28+
import org.apache.kafka.streams.processor.api.Record;
29+
import org.apache.kafka.streams.state.KeyValueStore;
30+
import org.checkerframework.checker.nullness.qual.Nullable;
31+
import org.slf4j.Logger;
32+
import org.slf4j.LoggerFactory;
33+
34+
/**
35+
* Kafka Streams {@link Processor} implementing Beam's {@code Impulse} transform.
36+
*
37+
* <p>For each task instance, emits exactly two {@link KStreamsPayload}s downstream:
38+
*
39+
* <ol>
40+
* <li>A {@link KStreamsPayload#data data} payload wrapping a {@link WindowedValue} of an empty
41+
* {@code byte[]} in the {@link org.apache.beam.sdk.transforms.windowing.GlobalWindow}, with
42+
* event-time {@link BoundedWindow#TIMESTAMP_MIN_VALUE}.
43+
* <li>A {@link KStreamsPayload#watermark watermark} payload at {@link
44+
* BoundedWindow#TIMESTAMP_MAX_VALUE} that tells downstream transforms the source is done.
45+
* </ol>
46+
*
47+
* <p>A persistent state store records whether the data element has already been emitted so that
48+
* task restarts do not duplicate the data. The terminal watermark, on the other hand, is re-emitted
49+
* on every restart so downstream watermark holds release correctly after recovery (per Jan's review
50+
* on PR #38689).
51+
*
52+
* <p>The trigger comes from a wall-clock punctuator scheduled on {@link #init} — this lets the
53+
* processor fire even when the dedicated bootstrap source topic is empty, which is the expected
54+
* production state.
55+
*
56+
* <p>Kafka Streams disallows negative record timestamps, so the forwarded {@link Record} carries
57+
* the Unix epoch ({@code 0L}). The Beam event-time lives inside the {@link KStreamsPayload}
58+
* variant: inside the {@link WindowedValue} for data, or as the explicit watermark millis.
59+
*/
60+
class ImpulseProcessor implements Processor<byte[], byte[], byte[], KStreamsPayload<byte[]>> {
61+
62+
private static final Logger LOG = LoggerFactory.getLogger(ImpulseProcessor.class);
63+
64+
/** Sole entry in the state store; the value tracks whether this processor has already emitted. */
65+
static final String FIRED_KEY = "fired";
66+
67+
/** How soon after {@link #init} the punctuator first fires. */
68+
private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50);
69+
70+
private final String stateStoreName;
71+
private final String transformId;
72+
73+
private @Nullable ProcessorContext<byte[], KStreamsPayload<byte[]>> context;
74+
private @Nullable KeyValueStore<String, Boolean> firedStore;
75+
private @Nullable Cancellable scheduledPunctuator;
76+
77+
ImpulseProcessor(String stateStoreName, String transformId) {
78+
this.stateStoreName = stateStoreName;
79+
this.transformId = transformId;
80+
}
81+
82+
@Override
83+
public void init(ProcessorContext<byte[], KStreamsPayload<byte[]>> context) {
84+
this.context = context;
85+
this.firedStore = context.getStateStore(stateStoreName);
86+
this.scheduledPunctuator =
87+
context.schedule(PUNCTUATION_DELAY, PunctuationType.WALL_CLOCK_TIME, ts -> maybeFire());
88+
}
89+
90+
@Override
91+
public void process(Record<byte[], byte[]> record) {
92+
// Records that happen to land on the bootstrap topic are not actual data; they just provide an
93+
// extra opportunity to fire the impulse on restart. The state store still gates the emit.
94+
maybeFire();
95+
}
96+
97+
private void maybeFire() {
98+
ProcessorContext<byte[], KStreamsPayload<byte[]>> ctx = context;
99+
KeyValueStore<String, Boolean> store = firedStore;
100+
if (ctx == null || store == null) {
101+
return;
102+
}
103+
if (Boolean.TRUE.equals(store.get(FIRED_KEY))) {
104+
// Data was already emitted in a previous task lifetime, but downstream watermark holds may
105+
// still need to be released after the restart — re-emit the terminal watermark and stop the
106+
// punctuator.
107+
forwardWatermarkMax(ctx);
108+
cancelPunctuator();
109+
return;
110+
}
111+
WindowedValue<byte[]> impulse = WindowedValues.valueInGlobalWindow(new byte[0]);
112+
// The output PCollection is not keyed (PCollection<byte[]>); use an empty byte[] as a
113+
// placeholder key so downstream processors that adopt the byte[]-key convention see a
114+
// consistent shape.
115+
ctx.forward(
116+
new Record<byte[], KStreamsPayload<byte[]>>(
117+
new byte[0], KStreamsPayload.data(impulse), 0L));
118+
forwardWatermarkMax(ctx);
119+
store.put(FIRED_KEY, Boolean.TRUE);
120+
cancelPunctuator();
121+
LOG.debug("Impulse {} emitted single element and terminal watermark", transformId);
122+
}
123+
124+
/** Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to downstream processors. */
125+
private static void forwardWatermarkMax(ProcessorContext<byte[], KStreamsPayload<byte[]>> ctx) {
126+
long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
127+
ctx.forward(
128+
new Record<byte[], KStreamsPayload<byte[]>>(
129+
new byte[0], KStreamsPayload.watermark(maxMillis), 0L));
130+
}
131+
132+
/** Cancels the wall-clock punctuator after the impulse has fired to stop periodic wakeups. */
133+
private void cancelPunctuator() {
134+
Cancellable handle = scheduledPunctuator;
135+
if (handle != null) {
136+
handle.cancel();
137+
scheduledPunctuator = null;
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)