Skip to content

Commit 27c4522

Browse files
[GSoC 2026] Kafka Streams runner #39249: Support Create
1 parent 4d5847f commit 27c4522

7 files changed

Lines changed: 555 additions & 0 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public KafkaStreamsPipelineTranslator() {
5050
this(
5151
ImmutableMap.<String, PTransformTranslator>builder()
5252
.put(PTransformTranslation.IMPULSE_TRANSFORM_URN, new ImpulseTranslator())
53+
.put(PTransformTranslation.READ_TRANSFORM_URN, new ReadTranslator())
5354
.put(PTransformTranslation.REDISTRIBUTE_ARBITRARILY_URN, new RedistributeTranslator())
5455
.put(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN, new GroupByKeyTranslator())
5556
.put(ExecutableStage.URN, new ExecutableStageTranslator())

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import java.util.HashMap;
2121
import java.util.Map;
22+
import java.util.regex.Pattern;
2223
import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
2324
import org.apache.beam.runners.kafka.streams.KafkaStreamsPipelineOptions;
2425
import org.apache.kafka.streams.Topology;
@@ -34,6 +35,12 @@ public class KafkaStreamsTranslationContext {
3435
/** Prefix for the per-job bootstrap topic Impulse reads from. */
3536
private static final String IMPULSE_BOOTSTRAP_TOPIC_PREFIX = "__beam_impulse_";
3637

38+
/** Prefix for the per-transform bootstrap topic a primitive Read reads from. */
39+
private static final String READ_BOOTSTRAP_TOPIC_PREFIX = "__beam_read_";
40+
41+
/** Characters not legal in a Kafka topic name; a topic's legal set is {@code [a-zA-Z0-9._-]}. */
42+
private static final Pattern ILLEGAL_TOPIC_CHARS = Pattern.compile("[^a-zA-Z0-9._-]");
43+
3744
private final JobInfo jobInfo;
3845
private final KafkaStreamsPipelineOptions pipelineOptions;
3946
private final Topology topology;
@@ -100,4 +107,17 @@ public String getProcessorNameForPCollection(String pCollectionId) {
100107
public String getImpulseBootstrapTopic() {
101108
return IMPULSE_BOOTSTRAP_TOPIC_PREFIX + pipelineOptions.getApplicationId();
102109
}
110+
111+
/**
112+
* Returns the dedicated bootstrap topic name a primitive Read reads from. Keyed by transform id
113+
* (sanitized to Kafka's legal topic-name character set) so multiple Reads — and Impulse — never
114+
* register the same topic on two source nodes, which Kafka Streams rejects.
115+
*/
116+
public String getReadBootstrapTopic(String transformId) {
117+
String sanitizedTransformId = ILLEGAL_TOPIC_CHARS.matcher(transformId).replaceAll("_");
118+
return READ_BOOTSTRAP_TOPIC_PREFIX
119+
+ pipelineOptions.getApplicationId()
120+
+ "_"
121+
+ sanitizedTransformId;
122+
}
103123
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.apache.beam.runners.kafka.streams.translation;
19+
20+
import java.io.IOException;
21+
import java.time.Duration;
22+
import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
23+
import org.apache.beam.sdk.coders.Coder;
24+
import org.apache.beam.sdk.coders.CoderException;
25+
import org.apache.beam.sdk.io.BoundedSource;
26+
import org.apache.beam.sdk.io.BoundedSource.BoundedReader;
27+
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
28+
import org.apache.beam.sdk.util.CoderUtils;
29+
import org.apache.beam.sdk.values.WindowedValue;
30+
import org.apache.beam.sdk.values.WindowedValues;
31+
import org.apache.kafka.streams.processor.Cancellable;
32+
import org.apache.kafka.streams.processor.PunctuationType;
33+
import org.apache.kafka.streams.processor.api.Processor;
34+
import org.apache.kafka.streams.processor.api.ProcessorContext;
35+
import org.apache.kafka.streams.processor.api.Record;
36+
import org.apache.kafka.streams.state.KeyValueStore;
37+
import org.checkerframework.checker.nullness.qual.Nullable;
38+
import org.slf4j.Logger;
39+
import org.slf4j.LoggerFactory;
40+
41+
/**
42+
* Kafka Streams {@link Processor} implementing Beam's deprecated primitive {@code Read}
43+
* (beam:transform:read:v1) over a {@link BoundedSource}.
44+
*
45+
* <p>For each task instance, reads the whole {@link BoundedSource} once and emits, in order:
46+
*
47+
* <ol>
48+
* <li>One {@link KStreamsPayload#data data} payload per source element, each wrapping a {@link
49+
* WindowedValue} in the {@link org.apache.beam.sdk.transforms.windowing.GlobalWindow} at the
50+
* element's own event time (from {@link BoundedReader#getCurrentTimestamp()}).
51+
* <li>A {@link KStreamsPayload#watermark watermark} payload at {@link
52+
* BoundedWindow#TIMESTAMP_MAX_VALUE} telling downstream transforms the source is done.
53+
* </ol>
54+
*
55+
* <p><b>Wire form.</b> Unlike Impulse (whose element is already an opaque {@code byte[]}), a Read
56+
* produces <em>decoded</em> Java objects. Downstream {@link ExecutableStageProcessor} feeds
57+
* whatever it receives straight into the SDK harness, whose main-input receiver expects each
58+
* element in the runner-side wire form — a raw object for a model coder, but a length-prefixed
59+
* {@code byte[]} for a coder the runner does not know (e.g. {@code VarIntCoder}). Stage-to-stage
60+
* edges already carry that wire form because harness outputs are decoded with the runner-side wire
61+
* coder; this processor reproduces it for the source edge by transcoding each element through the
62+
* SDK-side wire coder (encode) and back through the runner-side wire coder (decode). The two are
63+
* byte-compatible by construction, so the transcode yields exactly the object the receiver expects,
64+
* nesting and all.
65+
*
66+
* <p>This mirrors {@link ImpulseProcessor}: a persistent state store records whether the elements
67+
* have already been emitted so task restarts do not duplicate them, while the terminal watermark is
68+
* re-emitted on every restart so downstream watermark holds still release after recovery. The
69+
* trigger is a wall-clock punctuator scheduled on {@link #init} so the processor fires even though
70+
* its bootstrap source topic is empty.
71+
*
72+
* <p>The source is read in a single instance with no splitting — parallelism across the source's
73+
* splits arrives with the topic-based shuffle work (#18479). Kafka Streams disallows negative
74+
* record timestamps, so each forwarded {@link Record} carries the Unix epoch ({@code 0L}); the Beam
75+
* event time lives inside the {@link WindowedValue}.
76+
*/
77+
class ReadProcessor<T> implements Processor<byte[], byte[], byte[], KStreamsPayload<?>> {
78+
79+
private static final Logger LOG = LoggerFactory.getLogger(ReadProcessor.class);
80+
81+
/** Sole entry in the state store; the value tracks whether this processor has already emitted. */
82+
static final String FIRED_KEY = "fired";
83+
84+
/** How soon after {@link #init} the punctuator first fires. */
85+
private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50);
86+
87+
private final BoundedSource<T> source;
88+
// Held as SerializablePipelineOptions (Beam's idiom for a reader holding options) so the
89+
// processor's captured state is uniformly serializable alongside the BoundedSource and coders.
90+
private final SerializablePipelineOptions options;
91+
// Encodes a raw WindowedValue<T> as the SDK harness would on the wire (length-prefixing the
92+
// element coder if the runner does not know it); the runner-side coder then decodes it into the
93+
// wire form the downstream stage's input receiver expects. See the class javadoc.
94+
private final Coder<WindowedValue<T>> sdkWireCoder;
95+
private final Coder<WindowedValue<?>> runnerWireCoder;
96+
private final String stateStoreName;
97+
private final String transformId;
98+
99+
private @Nullable ProcessorContext<byte[], KStreamsPayload<?>> context;
100+
private @Nullable KeyValueStore<String, Boolean> firedStore;
101+
private @Nullable Cancellable scheduledPunctuator;
102+
103+
ReadProcessor(
104+
BoundedSource<T> source,
105+
SerializablePipelineOptions options,
106+
Coder<WindowedValue<T>> sdkWireCoder,
107+
Coder<WindowedValue<?>> runnerWireCoder,
108+
String stateStoreName,
109+
String transformId) {
110+
this.source = source;
111+
this.options = options;
112+
this.sdkWireCoder = sdkWireCoder;
113+
this.runnerWireCoder = runnerWireCoder;
114+
this.stateStoreName = stateStoreName;
115+
this.transformId = transformId;
116+
}
117+
118+
@Override
119+
public void init(ProcessorContext<byte[], KStreamsPayload<?>> context) {
120+
this.context = context;
121+
this.firedStore = context.getStateStore(stateStoreName);
122+
this.scheduledPunctuator =
123+
context.schedule(PUNCTUATION_DELAY, PunctuationType.WALL_CLOCK_TIME, ts -> maybeFire());
124+
}
125+
126+
@Override
127+
public void process(Record<byte[], byte[]> record) {
128+
// Records that happen to land on the bootstrap topic are not actual data; they just provide an
129+
// extra opportunity to fire the read on restart. The state store still gates the emit.
130+
maybeFire();
131+
}
132+
133+
private void maybeFire() {
134+
ProcessorContext<byte[], KStreamsPayload<?>> ctx = context;
135+
KeyValueStore<String, Boolean> store = firedStore;
136+
if (ctx == null || store == null) {
137+
return;
138+
}
139+
if (Boolean.TRUE.equals(store.get(FIRED_KEY))) {
140+
// Elements were already emitted in a previous task lifetime, but downstream watermark holds
141+
// may still need to release after the restart — re-emit the terminal watermark and stop.
142+
forwardWatermarkMax(ctx);
143+
cancelPunctuator();
144+
return;
145+
}
146+
int count = readAndForward(ctx);
147+
forwardWatermarkMax(ctx);
148+
store.put(FIRED_KEY, Boolean.TRUE);
149+
cancelPunctuator();
150+
LOG.debug("Read {} emitted {} elements and terminal watermark", transformId, count);
151+
}
152+
153+
/** Reads the whole bounded source and forwards each element, in wire form, as a data payload. */
154+
private int readAndForward(ProcessorContext<byte[], KStreamsPayload<?>> ctx) {
155+
int count = 0;
156+
try (BoundedReader<T> reader = source.createReader(options.get())) {
157+
for (boolean hasElement = reader.start(); hasElement; hasElement = reader.advance()) {
158+
WindowedValue<T> element =
159+
WindowedValues.timestampedValueInGlobalWindow(
160+
reader.getCurrent(), reader.getCurrentTimestamp());
161+
// The Read output PCollection is not keyed; use an empty byte[] as a placeholder key so
162+
// downstream processors that adopt the byte[]-key convention see a consistent shape.
163+
ctx.forward(
164+
new Record<byte[], KStreamsPayload<?>>(
165+
new byte[0], KStreamsPayload.data(toRunnerWire(element)), 0L));
166+
count++;
167+
}
168+
} catch (IOException e) {
169+
throw new RuntimeException("Failed to read bounded source for transform " + transformId, e);
170+
}
171+
return count;
172+
}
173+
174+
/** Transcodes a raw element into the runner-side wire form the SDK harness input expects. */
175+
private WindowedValue<?> toRunnerWire(WindowedValue<T> element) {
176+
try {
177+
byte[] wireBytes = CoderUtils.encodeToByteArray(sdkWireCoder, element);
178+
return CoderUtils.decodeFromByteArray(runnerWireCoder, wireBytes);
179+
} catch (CoderException e) {
180+
throw new RuntimeException(
181+
"Failed to transcode a read element to wire form for transform " + transformId, e);
182+
}
183+
}
184+
185+
/**
186+
* Forwards a terminal {@code TIMESTAMP_MAX_VALUE} watermark payload to downstream processors.
187+
*
188+
* <p>Read is a single-instance source, so the report is stamped as the only source partition:
189+
* {@code sourcePartition=0} of {@code totalSourcePartitions=1}. Real per-partition identities
190+
* arrive once the topology gains topic-based shuffle.
191+
*/
192+
private static void forwardWatermarkMax(ProcessorContext<byte[], KStreamsPayload<?>> ctx) {
193+
long maxMillis = BoundedWindow.TIMESTAMP_MAX_VALUE.getMillis();
194+
ctx.forward(
195+
new Record<byte[], KStreamsPayload<?>>(
196+
new byte[0], KStreamsPayload.<Object>watermark(maxMillis, 0, 1), 0L));
197+
}
198+
199+
/** Cancels the wall-clock punctuator after the read has fired to stop periodic wakeups. */
200+
private void cancelPunctuator() {
201+
Cancellable handle = scheduledPunctuator;
202+
if (handle != null) {
203+
handle.cancel();
204+
scheduledPunctuator = null;
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)