Skip to content

[Spark 4] Streaming runner proof of concept, unbounded sources, windowed GBK and stateful ParDo on transformWithState - #39576

Draft
tkaymak wants to merge 4 commits into
apache:masterfrom
tkaymak:spark4-streaming-poc
Draft

[Spark 4] Streaming runner proof of concept, unbounded sources, windowed GBK and stateful ParDo on transformWithState#39576
tkaymak wants to merge 4 commits into
apache:masterfrom
tkaymak:spark4-streaming-poc

Conversation

@tkaymak

@tkaymak tkaymak commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Draft, opened for design discussion rather than for merging as one lump. See "How this is being split" at the bottom. Rebased 2026-09-03 onto the reworked micro-batch source of #39971, the branch is now one commit on top of that PR.

Addresses #36841.

What this is

The Spark 4 runner merged in #38255 is batch only. SparkStructuredStreamingRunner rejects streaming outright. This branch is the proof that Spark 4's transformWithState can host the Beam streaming model, including several chained stateful operators inside a single Structured Streaming query with a watermark that propagates correctly across them. It is the end to end evidence behind the slice PRs.

Approach

Dispatch seam. Merged in #39906. The shared base gains translation/PipelineTranslatorFactory.create(boolean streaming), which throws for streaming and names the Spark 4 module. runners/spark/4/src shadows that one file and returns a streaming translator instead. Every piece of transformWithState, StreamingQuery and DataSourceV2 streaming code lives only under runners/spark/4/src.

Source. In review as #39971. A DataSourceV2 micro-batch source over any UnboundedSource, built like BoundedDatasetFactory with the real objects in a Table wrapped in StreamingRelationV2. Offsets are opaque epoch counters. Per source state lives under the location Spark passes to toMicroBatchStream, written through CheckpointFileManager: a pinned split list and, per split, the checkpoint mark coded with getCheckpointMarkCoder() at the end of every micro-batch. Executors cache live readers and reuse one only when it is positioned at the batch's start offset, and finalize the mark taken there at that moment, since Spark only starts a batch at an offset already in its commit log. Every other case restores the reader from the durable mark at the start offset. commit(end) purges marks below end. Delivery is at least once. The row schema is payload BINARY plus eventTimestamp TIMESTAMP, no Catalyst encoder work was needed.

Watermark. Declared exactly once, in the read translator, on the raw rows before the typed decode. Spark forbids re-declaring it. The EventTimeWatermark plan node survives the projection and the typed map, asserted at plan level and at runtime.

End of stream. When a reader's watermark reaches the end of the global window the source emits one sentinel row with an empty payload and TIMESTAMP_MAX_VALUE, in a micro-batch of its own after the last data batch. The translators filter it before user code. It pushes Spark's watermark to the end so every window finalizes, which is what makes PAssert work on the streaming path.

Stateful execution. One generic transformWithState operator, StatefulProcessor<byte[], byte[], byte[]> with Encoders.BINARY() throughout, hosting any DoFn through DoFnRunners. Stateful ParDo goes through simpleRunner plus defaultStatefulDoFnRunner, GBK through GroupAlsoByWindowViaWindowSetNewDoFn plus SystemReduceFn.buffering and KeyedWorkItems, the Flink WindowDoFnOperator recipe. State is a port of the legacy SparkStateInternals onto a single MapState<String, byte[]> keyed by namespace plus tag, the only layout that can host ReduceFnRunner's dynamically created system tags. Per @StateId column families are an obvious follow up. Allowed lateness, late firings after the end of window and accumulating panes are supported.

Lifecycle. StreamingEvaluationContext starts one query per leaf against the noop sink and blocks until all are terminal. cancel() reaches it through an AtomicReference set by the async translate task. Tests terminate through an idle stop listener that gracefully stops a query after N consecutive empty micro-batches.

Results

Suite Tests Failures Skipped
:runners:spark:3:test 227 0 7
:runners:spark:4:test 288 0 5

84 of the Spark 4 tests are structured streaming, 25 of them the source protocol tests from #39971.

Asserted end to end: stateless ParDo, fixed and sliding window GBK, late data dropping and late firings, accumulating panes, stateful dedup with an event time timer, chained stateful then windowed sum, PAssert in global and non global windows, restart from a previous run's checkpoint, and pipeline lifecycle through RUNNING, DONE and CANCELLED.

The chained case is the one that matters, and it is asserted against Spark's own StreamingQueryProgress rather than only against the computed values: one query id across every batch, exactly two transformWithStateExec operators per batch, and four strictly increasing watermarks.

One finding worth flagging on its own

Spark expires a transformWithState wake up at expiry <= batchWatermark. Beam fires an event time timer only once the watermark is strictly past it, and AfterWatermark.pastEndOfWindow is strict as well. Handing Beam a timer one millisecond early is destructive: the trigger declines, the runner is entitled to assume the timer will not be redelivered, and the on time pane is silently lost with no error anywhere. The end of window timer of a fixed window sits at exactly window.maxTimestamp(), so this hits most windows.

Fixed by bounding the fire set at min(firedExpiry, watermark - 1) and re-arming the withheld timer at firedExpiry + 1. Anyone else bridging Beam timers onto transformWithState will hit this.

Rejected at translation time, with a named message

Merging windows, triggers other than the default, AfterWatermark.pastEndOfWindow with an optional late firing and Never, processing time timers, @OnWindowExpiration, @RequiresTimeSortedInput, side inputs on a stateful ParDo, non deterministic key coders.

Known limitations

  1. One micro-batch of timer latency. The watermark inside a stateful operator is the batch start watermark, so an end of window timer fires one micro-batch after the data crossed the window end. A latency floor, not a correctness issue.
  2. stop() does not drain. An in flight micro-batch finishes, anything not yet pulled is left unprocessed.
  3. At least once. A crash between the end of a micro-batch and its commit replays that batch. spark.speculation is not supported for sources with non deterministic reads.
  4. Out of scope by design: session windows, custom triggers, streaming side inputs, Kafka, continuous mode, portability, streaming Combine.PerKey.

Not done yet, deliberately

  • Four tests in the shared base still carry @Ignore("TODO: Reactivate with streaming."). They cannot be un-ignored in place, since the shared base test tree is compiled by both modules and Spark 3 still correctly throws. Reactivating them means moving them into the Spark 4 override tree, planned with slice 5.

How this is being split

  1. Dispatch seam, shared base only. Merged, [#36841] Add streaming dispatch seam to the Spark Structured Streaming runner #39906.
  2. Kryo registrations for Spark's streaming internals. Merged, [Spark][#36841] Register Spark's streaming internals with Kryo for the Structured Streaming runner #39939. The maxRecordsPerBatch option reuse merged as [Spark][#36841] Reuse the legacy maxRecordsPerBatch option in the Structured Streaming runner #39952.
  3. DataSourceV2 micro-batch source plus tests. In review, [Spark][#36841] Add the DataSourceV2 unbounded source for the Spark 4 streaming runner #39971, redesigned after the first round to follow Spark's commit lifecycle.
  4. State and timer bridge plus unit tests. Carries the timer fix described above.
  5. Translators, streaming evaluation context, end to end tests, the shared base test move.

Slices 4 and 5 would benefit from a reviewer who knows Spark's Structured Streaming internals, not only Beam.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@Eliaaazzz

Copy link
Copy Markdown
Contributor

Hi @tkaymak, I found this PR while looking into Spark streaming work.

I had been planning to work on Spark portable streaming state and side inputs via #20396 and
#20395, but it looks like new streaming work is moving toward the Spark 4 Structured Streaming
runner rather than the older DStream path.

I’m reading through this POC now so I do not duplicate work or build against the wrong runner
path. If this approach is still the intended direction, is there a smaller slice where outside
help would be useful?

My initial interest was state/side-input support, but I see this POC already covers stateful
ParDo and deliberately leaves streaming side inputs out of scope. I’d be happy to help with
tests, extracting one of the proposed smaller PRs, or another piece that would be useful.

@tkaymak

tkaymak commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Eliaaazzz, happy to share where this is heading.

The thinking behind the POC: Spark 4 added transformWithState, and it turned out flexible enough to host the Beam streaming model directly. The POC proves the core claim, two chained Beam stateful operators run inside a single Structured Streaming query with a watermark that propagates correctly between them. So yes, this is the intended direction, and I would not build new work on the DStream path.

Once there is agreement on the approach, the plan is to split this branch into five smaller PRs: the dispatch seam and options, the Kryo registrations, the DataSourceV2 unbounded source, the state and timer bridge, and the translators with the end to end tests.

Concrete places where help would be useful right now:

  1. Tests. The shared base has four tests marked "TODO: Reactivate with streaming." plus SimpleSourceTest.testUnboundedSource. They cannot be enabled in place because Spark 3 compiles the same test tree and still correctly rejects streaming. Moving them into the Spark 4 override tree is a nice contained task.
  2. The PAssert story. No source emits a final infinity watermark, so panes never finalize and PAssert does not work on this path. All streaming tests currently assert against a static collector. Reviewers will ask about this, so ideas or a prototype here would be valuable.
  3. Checkpoint recovery. Readers currently resume from a per JVM cache, so restarting from a checkpoint written by an earlier run is not supported. This is the biggest gap between the POC and something shippable.

Streaming side inputs, your original interest, are deliberately out of scope for the POC, but they are a natural follow up phase once this foundation is agreed. Session windows and full trigger support are planned as later phases too. If you want to start somewhere, I would suggest number 1, it is contained and it teaches you the test setup you would need for anything bigger.

@tkaymak

tkaymak commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

R: @Abacn for the runner and build mechanics, you reviewed the Spark 4 batch runner PRs this builds on.

R: @kennknowles for the Beam model side, watermark propagation, trigger semantics and the state and timer bridge on transformWithState. The most interesting finding for you is probably the timer off by one described in the PR, Spark fires a wake up at expiry equal to the watermark while the Beam model requires the watermark to be strictly past the timer, which silently dropped the on time pane until we bounded the fire set.

(This is a draft to get agreement on the direction first, the plan is to split it into five smaller PRs afterwards.)

@github-actions

Copy link
Copy Markdown
Contributor

Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment assign set of reviewers

@tkaymak

tkaymak commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Update for reviewers, two rounds of changes since the review request.

First, the four remarks from my own review pass are addressed: the Kryo registrations for Spark's streaming internals moved to the end of the registrator so auto assigned ids stay identical across Spark 3 and 4, the fallback temporary checkpoint directory is cleaned up after evaluation, the throwing checks in the stateful streaming translator now live in a named rejectUnsupported method with the contract documented, and awaitTermination polls the leaf queries round robin so a failing query surfaces immediately and stops its siblings.

Second, the biggest known gap is closed: checkpoint recovery is now durable. The source id is derived deterministically from the read transform's full name, the first run pins its split list under the checkpoint location (Beam sources do not guarantee deterministic splitting), each partition reader persists its checkpoint mark per batch epoch atomically through the Hadoop filesystem, and a restarted run resumes from the newest mark at or before its batch start epoch. Semantics are at least once, a mark is written when a batch finishes reading rather than transactionally with Spark's commit, that caveat is documented on BeamReaderCache. A new end to end test restarts a pipeline against the same checkpoint location with a cleared reader cache and proves it resumes instead of re reading.

@tkaymak
tkaymak force-pushed the spark4-streaming-poc branch from aa1c70e to be7e3ce Compare August 20, 2026 20:16
@tkaymak

tkaymak commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

PAssert update. Streaming PAssert now works in non global windows too, commit e991c5d.

The earlier commit made PAssert resolve in the global window by emitting an end of stream sentinel that advances the watermark to TIMESTAMP_MAX_VALUE. Non global windows still failed with an empty assertion iterable. The cause was not the fixed windows themselves, PAssert rewindows everything into the global window and chains two GroupByKeys, and the late data filter in front of the second one judged the global window expired because LateDataUtils truncates garbage collection times to the end of the global window while the sentinel watermark sits one day past it. The pane flushed by the first GroupByKey was dropped on arrival at the second.

The fix clamps the watermark used for arrival side expiry decisions to the end of the global window, through a delegating TimerInternals view in the step context. Timer firing and the ReduceFnRunner triggers keep the real watermark. Late data semantics for finite windows are unchanged, the clamp only takes effect once the watermark is already past the end of the global window, which only the sentinel can cause.

Tests: the fixed windows PAssert case is restored, and both window modes now have negative tests proving a wrong expectation genuinely fails the pipeline.

Known remaining gap: chained groupings in finite windows that are flushed only by the final sentinel would still drop their in flight panes. PAssert never builds that shape, it always rewindows to global first. The clean general solution I would choose is per operator output watermark holds, which is on the roadmap for the productionization phase.

@tkaymak

tkaymak commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Run Java PreCommit

@tkaymak

tkaymak commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

The runner now covers allowed lateness, late firings via AfterPane.elementCountAtLeast(1), accumulating panes, durable checkpoint restart and PAssert in global and non global windows, each guarded by tests that prove the unsupported shapes still fail loudly.

Next step is the split announced in the description. The first slice, the streaming dispatch seam, changes nothing for Spark 3 and is coming as a regular PR shortly. I will link it here. This draft stays open as the end to end evidence for the discussion.

@tkaymak

tkaymak commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

The first slice is out: #39906, the streaming dispatch seam. No behavior change for either Spark version, full local gates green. The remaining slices follow in the order listed there once it lands.

@tkaymak

tkaymak commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Slice 1 is merged, thank you @Abacn, and good to see the roadmap page now naming complete Structured Streaming support. Slice 2 is out: #39939, the Kryo registrations for Spark's streaming internals, including the id parity reasoning. Slice 3, the DataSourceV2 unbounded source, is next.

Exposes any Beam UnboundedSource as a Spark 4 DataSourceV2 streaming
table with a fixed two column schema, encoded payload plus event
timestamp. Offsets are opaque, strictly increasing epoch counters, so
Spark keeps scheduling micro-batches and termination stays with the
lifecycle owner.

Recovery is durable under the query's checkpoint location: the source id
derives deterministically from the read transform's full name, the first
run pins its split list (Beam sources do not guarantee deterministic
splitting), and every split persists its CheckpointMark per epoch with a
retention of two, written atomically via temp file and rename. Executors
cache live readers between micro-batches and fall back to the newest
durable mark at or before the replayed epoch after a restart. Semantics
are at least once, a crash between finishing a read and Spark's commit
replays the last micro-batch.

The batch cutoff honors maxRecordsPerBatch, values below 1, including
the default, mean no limit and the batch ends on the duration deadline.
@tkaymak

tkaymak commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Slice 3 is out: #39971, the DataSourceV2 unbounded source with durable checkpoint recovery. It consumes the maxRecordsPerBatch option from #39952 with values below 1 meaning no limit. Two slices remain, the state and timer bridge and the translators with the end to end tests.

Marks are no longer finalized when a partition reader closes. A reader
finalizes the mark taken at its start offset when the next micro-batch for
that split is scheduled, because Spark only starts a batch at the initial
offset or at the end offset of a batch already in its commit log. A reader
whose position does not match the start offset, or that moved without
completing its batch (task retry, killed attempt, executor change, restart),
is dropped without finalizing and recreated from the durable mark at that
offset.

Per source state lives under the checkpoint location Spark hands to
toMicroBatchStream, written through CheckpointFileManager with the session
Hadoop configuration broadcast to executors. Marks are coded with the
source's checkpoint mark coder. commit(end) purges marks below end on a
background thread, nothing is retained by a fixed count.

The dataset is built like BoundedDatasetFactory, a Table holding the real
objects wrapped in StreamingRelationV2, no string options, no Base64. Splits
travel as objects in the InputPartition, options and Hadoop configuration as
broadcasts. maxRecordsPerBatch is divided across splits like the legacy
MicrobatchSource, defaultParallelism decides the split count, idle readers
back off with FluentBackoff, offsets serialize as the bare epoch like
LongOffset with the base class equality. A new option
readerIdleTimeoutMillis bounds how long an executor keeps an idle reader.

Tests drive the reader cache protocol directly and prove restart recovery,
finalization only after commit, and mark purging against Spark's real
offsets and commits logs. The JUnit per test timeout is removed from the
streaming test, its throwaway thread group poisoned Spark's static pools for
later batch tests in the same JVM.
…atch source

Brings the final file states of the spark4-streaming-poc branch onto the
head of the slice 3 rework (apache#39971) as one commit: the streaming pipeline
translator and evaluation context, the Read, Impulse, GroupByKey and
stateful ParDo translators, the transformWithState state and timer bridge,
and the end to end streaming tests. The io/streaming package of the rework
is kept as is, the POC's own version of it is dropped.

The end of stream sentinel the POC had added to the old source is
re-applied on the reworked BeamPartitionReader and BeamReaderCache: a
batch that holds data ends at the first empty poll so its watermark is
declared first, and an exhausted reader whose watermark reached the end of
the global window emits one empty payload row at the maximum timestamp
once per cached reader. The translators filter that row.

Callers of the removed int maxRecordsPerMicroBatch option now use the
long maxRecordsPerBatch option of master, whose per batch quota is split
across the splits with at least one record per split, which is what the
tests relied on.

StreamingCheckpointRestartTest asserts the reworked checkpoint layout,
splits and marks under the per source location Spark hands the stream,
instead of the old beam-source-<id> directory found by a recursive
search.

The JUnit method timeouts are removed from every streaming test. Its
timeout thread group leaks into Spark's static pools and breaks later
tests in the same JVM. StreamingTestUtils gains run and waitUntilFinish
helpers with a five minute deadline that cancel the pipeline and fail the
test instead.

SparkSessionFactory, build.gradle, the pipeline options, result, runner,
evaluation context and pipeline translator of the shared base need no
change, the merged slices already carry the POC's deltas including the
RocksDB state store default and the Kryo registrations.
@tkaymak
tkaymak force-pushed the spark4-streaming-poc branch from c30d670 to 800c9bd Compare September 3, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants