[spark] Commit a streaming micro-batch through the stream write API - #10105
Open
zhuxiangyi wants to merge 2 commits into
Open
zhuxiangyi wants to merge 2 commits into
zhuxiangyi wants to merge 2 commits into
Conversation
…is retried An overwrite of a chain table publishes its delta snapshot and only then clears, on the snapshot branch, the files the overwrite superseded. When that cleanup fails after the snapshot is published, the commit is retried as a replay: filterCommitted recognises the identifier and hands the committable to the callback's retry, which did nothing, so the superseded files stayed on the snapshot branch for good. retry now resolves the snapshot that identifier produced, reads the partitions of its delta manifest and clears the snapshot branch files of those partitions as of the time of that commit, which is exactly what the overwrite superseded: files written after it are left alone.
A Structured Streaming write went through the batch write API: every
micro-batch built its own write builder and committed through a one-shot
committer that it closed again. Such a committer cannot recognise a
micro-batch a previous run already committed, so a query that failed
between the sink returning from addBatch and Spark recording that batch in
its commit log wrote the whole batch a second time on restart.
The sink now has the shape a Flink job has:
- one commit user per query, stable across its runs. It is derived from
the query id Spark persists in the checkpoint, which is new when a
checkpoint is recreated and unchanged when a query resumes from one, or
set explicitly with the new 'write.stream.commit-user' option. It is
taken only from sources scoped to one query, never from a table
property that every writer of the table would share.
- one StreamTableCommit per run, created with the first micro-batch and
closed when the query terminates, so that the maintenance a commit
starts is not cancelled by a close after every batch.
- micro-batch n committed under identifier n + 1, the way Flink numbers
its checkpoints, which is also what a compacted-full scan recognises a
scheduled full compaction by. The executors write it with
prepareCommit(waitCompaction, identifier).
- the first micro-batch of a run goes through filterAndCommit, the ones
after it through commit: the same division of work a Flink committer
makes between restoring and its steady state.
- a write to a postpone bucket table writes the postpone bucket, as
FlinkSinkBuilder does for a stream, instead of the fixed-bucket paths
meant for a batch job that ends with its commit. Those rows become
readable once a compaction has sorted them into real buckets.
zhuxiangyi
force-pushed
the
spark-streaming-write-path
branch
from
September 22, 2026 12:25
b16121f to
5e96527
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Closes #10010. This is the implementation #9667 was asked to be compared against, so #9667 stays
open and untouched while the two are weighed; whichever lands, the other is closed.
A Spark Structured Streaming write goes through the batch write API: every micro-batch builds its
own write builder and commits through a one-shot committer that it closes again. Such a committer
cannot recognise a micro-batch a previous run already committed, so a query that fails between the
sink returning from
addBatchand Spark recording that batch in its commit log writes the wholebatch a second time on restart (#9666).
This gives the sink the shape a Flink job has:
in the checkpoint — new when a checkpoint is recreated, unchanged when a query resumes from one —
or set explicitly with the new
write.stream.commit-useroption. It is read only from sourcesscoped to one query (the writer's options, the session conf), never from a table property that
every writer of the table would share.
commit.user-prefixis read from the table as usual andprefixes the derived user, as it does the random user of a batch write, so a job upgrading to
this keeps the name its table was configured with.
StreamTableCommitper run, created with the first micro-batch and closed when the queryterminates, so the maintenance a commit starts (tag creation, partition and snapshot expiration)
is not cancelled by a close after every batch.
ncommitted under identifiern + 1, the way Flink numbers its checkpoints,which is also what a
compacted-fullscan recognises a scheduled full compaction by. Theexecutors write it with
prepareCommit(waitCompaction, identifier).filterAndCommit, the ones after it throughcommit— the division of work a Flink committer makes between restoring and its steady state.FlinkSinkBuilderdoes fora stream, instead of the fixed-bucket paths meant for a batch job that ends with its commit. Those
rows become readable once a compaction has sorted them into real buckets. This also closes the
hole where such a write went through the staged committer, which cannot skip a replay at all.
Relation to #9667
#9667 keeps the one-shot batch committer and makes it drive a filtered commit, which needed three
switches on
InnerTableCommit(checkFilesExistence,checkAppendFiles,inlineMaintenance) plusfilterCommittedIgnoresLastSafeSnapshotand awithCommitUseron two write builders. None of thatexists here: a long-lived stream committer needs no switch, because it does not file-list what it
just wrote on a steady-state commit, its maintenance has a next commit to report to, and it never
recomputes a last-safe-snapshot bound.
The only core change left is the chain-table overwrite callback (first commit), which is
independent of either approach: an overwrite of a chain table publishes its delta snapshot and only
then clears the snapshot-branch files it supersedes; when that cleanup fails after the snapshot is
published, the retried commit is recognised as a replay and its
retryhas to redo the cleanup forexactly the files that overwrite superseded.
Tests
PaimonSinkIdempotencyTest(15 cases) andPaimonSinkTest, plusChainTableFileStoreTableTestandSimpleTableTestBasein core. Most of the sink cases were written for #9667 against externallyvisible behaviour, so they carry over unchanged; what changed:
complete mode replay on a postpone bucket table ...now asserts the rows wait in bucket -2 andreads them after
sys.compact;one committer commits every micro-batch of a query and closes with it(new) counts committersthrough a
commit.callbacksimplementation: one per query, closed when the query stops;maintenance of a micro-batch runs while the query is alivereplaces the case that assertedmaintenance had to finish before a per-batch committer closed;
testFilterAndCommitAcrossRunsOfOneCommitUserreplaces the two cases covering theremoved switches: one committer commits two identifiers, a restarted run replays the last one and
commits the next.
Run locally:
paimon-corein full,paimon-spark-utin full on Spark 3.5, and the sink suites onSpark 3.2 / 3.4 / 3.5 / 4.0 / 4.1. Mutation checks: closing the committer after every micro-batch,
never closing it on termination, and never filtering a possible replay each fail the cases that
cover them (3, 1 and 9 cases).
Postpone write performance (asked for in #9667), local, one JVM, 5 micro-batches of 50k rows
into a primary-key postpone table with
postpone.default-bucket-num = 4, two runs:Committing a micro-batch gets about twice as cheap; the bucketing it no longer does is what the
compaction then does, as for a Flink streaming job.
API and Format
No format change. New Spark connector option
write.stream.commit-user. Behaviour change: astreaming write to a postpone bucket table now lands in the postpone bucket and becomes readable
after a compaction, as with Flink.
A query that upgrades to this keeps its Spark checkpoint: the sink stores nothing in it. Its commit
user changes from a random one per micro-batch to the stable one, which has no history yet, so the
first micro-batch after the upgrade is committed as usual, neither filtered nor duplicated.
Documentation
docs/docs/spark/structured-streaming.md: an "Exactly-once" section covering the commit user, thecommit identifier, the committer's lifetime and what a postpone bucket table does.