[GSoC 2026] Kafka Streams runner: bounded Read (primitive) + Create support#39249
Conversation
…upport Add a translator for the deprecated primitive Read (beam:transform:read:v1) over a BoundedSource, and force every Read.Bounded -- including the one that Create of two or more elements expands to -- into that primitive read instead of the default BoundedSourceAsSDFWrapperFn splittable-DoFn expansion the runner cannot execute yet (no SDF restriction protocol). - ReadTranslator: parses the ReadPayload, deserializes the BoundedSource, and adds a bootstrap source + ReadProcessor + fired-state store (mirrors Impulse). - ReadProcessor: reads the whole source single-instance on a one-shot wall-clock punctuator, emitting one data payload per element plus a terminal MAX watermark. Each element is transcoded into the runner-side wire form the SDK harness input expects -- a raw object for a model coder, a length-prefixed byte[] for a coder the runner does not know -- so decoded source objects bridge correctly into the fused ExecutableStage. - KafkaStreamsTestRunner.translate applies SplittableParDo.convertReadBasedSplittableDoFnsToPrimitiveReads unconditionally, so Create/Read translate as the primitive Read regardless of the use_deprecated_read experiment. Tests: ReadTest (CountingSource.upTo(5)) and CreateTest (Create.of(1, 2, 3)) run end to end through the in-process EMBEDDED harness.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces support for bounded primitive Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces support for translating the deprecated primitive Read transform over a BoundedSource in the Kafka Streams runner. It adds ReadProcessor and ReadTranslator to handle reading from bounded sources and emitting elements in the runner-side wire form, and updates KafkaStreamsTestRunner to force bounded reads into this path. The review feedback suggests wrapping PipelineOptions in SerializablePipelineOptions to avoid serialization issues in distributed environments, and creating a reusable EMPTY_BYTE_ARRAY constant to prevent allocating a new empty byte array for every element and watermark.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50); | ||
|
|
||
| private final BoundedSource<T> source; | ||
| private final PipelineOptions options; |
There was a problem hiding this comment.
PipelineOptions is not serializable. Since ReadProcessor is instantiated inside a serialized ProcessorSupplier lambda (or is itself serialized), storing PipelineOptions directly will cause a NotSerializableException in distributed environments. Use SerializablePipelineOptions instead.
| private final PipelineOptions options; | |
| private final org.apache.beam.sdk.options.SerializablePipelineOptions options; |
|
|
||
| ReadProcessor( | ||
| BoundedSource<T> source, | ||
| PipelineOptions options, |
There was a problem hiding this comment.
Done. One note, the class is org.apache.beam.runners.core.construction.SerializablePipelineOptions, not sdk.options. Worth flagging that KS builds the topology per instance and doesn't serialize processor suppliers, so this wasn't a live NotSerializableException, but the wrapper is cheap and keeps the captured state serializable.
| /** Reads the whole bounded source and forwards each element, in wire form, as a data payload. */ | ||
| private int readAndForward(ProcessorContext<byte[], KStreamsPayload<?>> ctx) { | ||
| int count = 0; | ||
| try (BoundedReader<T> reader = source.createReader(options)) { |
| String sourceNodeName = transformId + SOURCE_SUFFIX; | ||
| String stateStoreName = transformId + STATE_STORE_SUFFIX; | ||
| String bootstrapTopic = context.getReadBootstrapTopic(transformId); | ||
| PipelineOptions options = context.getPipelineOptions(); |
There was a problem hiding this comment.
Wrap the PipelineOptions in SerializablePipelineOptions before passing it to the ReadProcessor constructor to ensure serializability.
| PipelineOptions options = context.getPipelineOptions(); | |
| org.apache.beam.sdk.options.SerializablePipelineOptions options = | |
| new org.apache.beam.sdk.options.SerializablePipelineOptions(context.getPipelineOptions()); |
| static final String FIRED_KEY = "fired"; | ||
|
|
||
| /** How soon after {@link #init} the punctuator first fires. */ | ||
| private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50); |
There was a problem hiding this comment.
To avoid creating a new empty byte array for every single element in the source (and for watermarks), we should define a reusable static final constant for the empty byte array.
| private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50); | |
| private static final Duration PUNCTUATION_DELAY = Duration.ofMillis(50); | |
| private static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; |
| ctx.forward( | ||
| new Record<byte[], KStreamsPayload<?>>( | ||
| new byte[0], KStreamsPayload.data(toRunnerWire(element)), 0L)); |
There was a problem hiding this comment.
Use the reusable EMPTY_BYTE_ARRAY constant instead of allocating a new byte[0] for every element.
| ctx.forward( | |
| new Record<byte[], KStreamsPayload<?>>( | |
| new byte[0], KStreamsPayload.data(toRunnerWire(element)), 0L)); | |
| ctx.forward( | |
| new Record<byte[], KStreamsPayload<?>>( | |
| EMPTY_BYTE_ARRAY, KStreamsPayload.data(toRunnerWire(element)), 0L)); |
| ctx.forward( | ||
| new Record<byte[], KStreamsPayload<?>>( | ||
| new byte[0], KStreamsPayload.<Object>watermark(maxMillis, 0, 1), 0L)); |
There was a problem hiding this comment.
Use the reusable EMPTY_BYTE_ARRAY constant instead of allocating a new byte[0].
| ctx.forward( | |
| new Record<byte[], KStreamsPayload<?>>( | |
| new byte[0], KStreamsPayload.<Object>watermark(maxMillis, 0, 1), 0L)); | |
| ctx.forward( | |
| new Record<byte[], KStreamsPayload<?>>( | |
| EMPTY_BYTE_ARRAY, KStreamsPayload.<Object>watermark(maxMillis, 0, 1), 0L)); |
Wrap the options in SerializablePipelineOptions (the Beam idiom for a source reader that holds options) so ReadProcessor's captured state is uniformly serializable alongside the BoundedSource and coders. Kafka Streams builds the topology per instance and does not serialize processor suppliers, so this is not a live NotSerializableException, but the wrapper is cheap and matches how the other portable runners hold options in their source readers.
|
Assigning reviewers: R: @jrmccluskey added as fallback since no labels match configuration Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
je-ik
left a comment
There was a problem hiding this comment.
There is a bit misunderstanding how Read works. The flow is:
a) call source.split
b) shuffle the splits so that we read in parallel
c) call createReader only on the split sources
From the implementation point, the initial source.split can be driven by preceding Impulse. We can then fan-out all Impulses together so that we need only single "fake" topic.
| private static final String IMPULSE_BOOTSTRAP_TOPIC_PREFIX = "__beam_impulse_"; | ||
|
|
||
| /** Prefix for the per-transform bootstrap topic a primitive Read reads from. */ | ||
| private static final String READ_BOOTSTRAP_TOPIC_PREFIX = "__beam_read_"; |
There was a problem hiding this comment.
Do we need per-transform topic? Could we just fan-out the single element to all Impulse transforms? It would require analysis of the Pipeline DAG before translation and then maybe add s single ParDo that will do the fan-out? Or it could be done implicitly.
Ah! I see, we plan to add the splitting in follow-up issue. |
je-ik
left a comment
There was a problem hiding this comment.
Because we plan to add the splitting in follow up, we will address parallelism and the ability to seed the Pipeline from single topic there.
27c4522
into
apache:feat/18479-kafka-streams-runner-skeleton
Summary
Adds bounded primitive Read support to the Kafka Streams runner, which also unblocks
Create.of(...)with two or more elements.Part of #39192 (this is part b -- Create support -- implemented via the Read primitive, as discussed with @je-ik).
Background
Createof 2+ elements expands toRead.from(CreateSource), which in the Fn API path becomes a splittable-DoFn bounded read the runner can't run yet. Per the Slack discussion with je-ik, this uses the deprecated primitive Read instead, and forces the conversion so it doesn't depend on theuse_deprecated_readexperiment.What's here:
ReadTranslator-- parses the ReadPayload, deserializes the BoundedSource, andadds a bootstrap source +
ReadProcessor+ a fired-state store, same shape asthe Impulse translator.
ReadProcessor-- reads the whole source once on a one-shot wall-clockpunctuator, emitting one data payload per element and a terminal MAX watermark.
Single-instance, no splitting for now.
KafkaStreamsTestRunner.translateappliesSplittableParDo.convertReadBasedSplittableDoFnsToPrimitiveReadsunconditionally, so
Read.Bounded(and Create) translate as the primitive Readregardless of the experiment.
ReadTest(CountingSource) andCreateTest(Create.of(1, 2, 3)),both end to end through the in-process EMBEDDED harness.
One thing that bit me -- the wire form. A primitive Read hands the runner decoded Java objects, but the SDK harness input receiver expects each element in the runner-side wire form: a raw object for a coder the runner knows (VarLongCoder worked directly), but a length-prefixed byte[] for one it doesn't (VarIntCoder threw
ClassCastException: Integer cannot be cast to [B). Stage-to-stage edges already carry that form because harness outputs are decoded with the runner-side wire coder; the source edge doesn't. SoReadProcessortranscodes each element through the SDK-side wire coder (encode) and back through the runner-side wire coder (decode) -- they're byte-compatible by construction and this also handles nested coders.Follow-up, not in this PR: the force-conversion is wired into the test translate path (
KafkaStreamsTestRunner) only. The production runner's translate path still needs the same conversion (oruse_deprecated_readdefaulted in its options). I can do that here or as a separate change -- whichever you prefer.cc: @je-ik