[spark] Use the self-merge shortcut for conditional data evolution UPDATE - #10037
Merged
Merged
Conversation
…DATE A data-evolution V1 UPDATE is executed as a MERGE INTO of the table with itself on _ROW_ID. The WHERE condition used to be kept as a Filter on the source side, which disqualified the statement from the self-merge shortcut: it paid a full scan plus shuffle to find the touched files, a self-join of those files with the filtered scan, and a repartition and sort of every row in the touched files before writing the column files. Carry the condition as the WHEN MATCHED condition instead. The source is then a plain projection of the table, so MergeIntoPaimonDataEvolutionTable takes the shortcut: one scan of the files the condition can touch, pruned by file statistics through the action predicate, with no join, shuffle or sort. Rows failing the condition are copied through unchanged, which is the UPDATE ... WHERE semantics. The Filter shape is kept when the condition cannot run inside MergeRows: a subquery, attributes that do not belong to the relation (the read-side CHAR padding projection), or a condition without column references.
JingsongLi
reviewed
Sep 21, 2026
JingsongLi
left a comment
Contributor
There was a problem hiding this comment.
Requirement fit: SUPPORTED (triage: GO)
Implementation: CLEAN
Conditional data-evolution UPDATE has a concrete end-to-end benefit here: the tested Spark plan removes the self-join/shuffle/sort and still prunes touched files. The changed condition placement preserves unchanged rows and NULL-condition behavior; the tests also keep the general path for subqueries and avoid a no-op rewrite for a constant-false condition. I found no blocking issue in the changed path.
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
Performance: a conditional V1
UPDATEon a data-evolution table currently pays a self-join, a shuffle and a sort that the unconditional form does not, because theWHEREcondition keeps it off the self-merge shortcut.Why.
UpdatePaimonDataEvolutionTableCommandruns anUPDATEas aMERGE INTO t USING t ON t._ROW_ID = s._ROW_ID.MergeIntoPaimonDataEvolutionTablehas a self-merge shortcut for exactly this shape —Scan → MergeRows → Write, no join, no shuffle, no sort — but the shortcut only recognises a source that is a plain projection of the table. TheWHEREcondition was placed as aFilteron the source side (USING (SELECT _ROW_ID FROM t WHERE cond)), so every conditionalUPDATEfell through to the general join path. What that costs, taken from the physical plans Spark actually ran forUPDATE t SET v = v + 1 WHERE id > 4on a two-file table:The shuffle in job 1 carries every row of every touched file, not only the rows the condition selects, because a data-evolution column file must cover the whole row-id range of its base file. With a large source side the broadcast join becomes a sort-merge join and adds two more shuffles.
What. Carry the condition as the
WHEN MATCHEDcondition of the self-merge instead of as a sourceFilter:The source is then
Project(PaimonRelation)and the shortcut applies. Rows that fail the condition go through the shortcut's keep-copy instruction and are written back unchanged, which is theUPDATE ... WHEREsemantics; a NULL condition value counts as not matched, as in aFilter. The same statement now runs as one job:The
Filtershape is kept, and the general path used, when the condition cannot be evaluated insideMergeRows:WHERE id IN (SELECT ...)), which can only be planned on a regular scan;CHARpadding projection the analyzer inserts on top of it, which would be unresolved in the merge plan;rand() < 0.1): file pruning has nothing to work with, and a constant-false condition would otherwise rewrite every file as a no-op.Benefit.
selfMergeActionPredicatepushes the action condition to the snapshot reader, so files whose statistics rule out the condition (including partition pruning) are never read. Before, the set of touched files was only known after job 0 scanned the table and collected the distinct first row ids on the driver.Two things noticed while testing that are pre-existing and out of scope here: a V1
UPDATEon a data-evolution table with aCHARcolumn fails at analysis on master (the padded attribute leaks into the aligned assignments inPaimonUpdateTable), and a non-deterministicWHEREis rejected byCheckAnalysison the command node itself. Both behave identically before and after this change.Tests
RowTrackingTestBase, run asRowTrackingTeston Spark 3.5 (66 tests) together withBlobUpdateTest,DataEvolutionDeletionTestandDataEvolutionUpdateSnapshotTest:V1 update table with data-evolution— the existing conditional case; its assertion that aJoinis present is flipped toassertSelfMergeShortcut(noJoin,SortorRepartitionByExpression).V1 update with condition prunes files through the self-merge shortcut— two files,WHERE b >= 30 SET b = b + 1: shortcut taken,RESULTED_TABLE_FILES == 1, the condition sees the pre-update values, and a condition that matches nothing in the surviving file copies it through unchanged.V1 update with partition condition prunes partitions through the shortcut— partitioned table,WHERE dt = 'p2' AND id = 4scans one file.V1 update condition on metadata column and NULL values—WHERE b > 10leaves the NULL row alone;WHERE _ROW_ID = 1takes the shortcut.V1 update with constant false condition changes nothing—WHERE 1 = 0produces no snapshot.V1 conditional update with user-specified snapshot uses the shortcut—scan.snapshot-idpin plusWHERE, rows inserted after the pin are untouched.V1 update with subquery condition keeps the source filter—WHERE id IN (SELECT ...)still uses the general join path and returns the right rows.V1 update retries concurrent update conflicts(WHERE id = 1under 4 concurrent writers) andDataEvolutionUpdateSnapshotTest(conflict detected after the snapshot is pinned) now exercise the shortcut and still pass.spotless:checkandcheckstyle:checkpass on both modules.API and Format
No changes.
Documentation
No changes.