[RLC] Case 2 — same-file deletion-vector merge (concurrent DML ⟂ DML) - #2
Open
sezruby wants to merge 11 commits into
Open
[RLC] Case 2 — same-file deletion-vector merge (concurrent DML ⟂ DML)#2sezruby wants to merge 11 commits into
sezruby wants to merge 11 commits into
Conversation
sezruby
force-pushed
the
row-level-concurrency-dv-poc
branch
from
July 8, 2026 03:11
001b81e to
73c7bc7
Compare
…merge POC for issue delta-io#7057. Adds a config-gated conflict-resolution phase to ConflictChecker that resolves 'same physical file' conflicts between concurrent DML. It decodes both transactions' deletion vectors and, when the newly-deleted rows are disjoint, merges them (dv_win UNION dv_cur), writes a new DV file, and rebases the losing transaction onto the winner's post-image. The delete/read, delete/delete, and append checks skip resolved paths; rewrite-only DML (DELETE/UPDATE) winners also skip the append check. DV-only (row tracking not required). Gated by spark.databricks.delta.rowLevelConcurrency.enabled (default off). Adds RowLevelConcurrencySuite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sezruby
force-pushed
the
row-level-concurrency-dv-poc
branch
from
July 8, 2026 03:19
73c7bc7 to
e356283
Compare
sezruby
added a commit
that referenced
this pull request
Jul 27, 2026
… remap Opt-in (spark.databricks.delta.optimize.conflictReconciliation.enabled, internal, default off). When a compaction OPTIMIZE loses to a concurrent DELETE/UPDATE that added a deletion vector to a removed source file, the ConflictChecker remaps that DV onto the compacted output by offset arithmetic (outputPos = runOutStart + (sourceRow - runSourceStart)) and unions it into the output's DV, instead of aborting. The run layout (source_file, start, count) is observed at write time -- cheaply. SourceCompositionCaptureExec reads the source file from InputFileBlockHolder and counts rows per file; no helper column, no _metadata.row_index (avoiding the DV-aware scan cost), rows pass through unchanged (~0% overhead). The composition is persisted in the output AddFile's OPTIMIZE_SOURCE_COMPOSITION tag, so the reconcile is cross-cluster-safe (replayed from the commit, no in-memory state). DV'd source files supported: the driver splits their live rows into contiguous physical segments around the read-time DV gaps, and only the winner's incremental deletions (winnerDv \ readTimeDv) are remapped. Coalesce compaction only; repartition (shuffle empties the holder) and reclustering/ZORDER (row-permuting) are not tagged and abort as before. All-resolvable-or-abort. OPTIMIZE-loser direction only; DELETE-loser and MERGE are follow-ups. Stacked on the row-level-concurrency DV PoC (#5 / PR #2). Adds OptimizeConflictReconciliationSuite (6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 30, 2026
Remove the unsound "Layer 2" append-suppression for rewrite-only DML winners. Keying the append-check skip on the winner's op type (DELETE/UPDATE) is unsound: an UPDATE can move a row into the loser's predicate (winner `SET x=15`, loser `DELETE WHERE x>10`, row was x=5), a genuine write-skew that suppressing the winner's image file would hide. `canSkipAddedFileForRowLevelConcurrency` now skips only the same-file DV union paths (`rowLevelResolvedPaths`). A rewrite-only DML winner's new image files flow into the standard added-files check (conservative abort, one-way safe); reconciling the provably-disjoint case via conflict-time data skipping is owned by Case 1 (#4/#8). Tests reframed to the Layer-1 envelope (9 tests, all passing): the two "both commit" UPDATE cases move to Case 1; DELETE-vs-UPDATE now asserts the image file conservatively conflicts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sezruby
added a commit
that referenced
this pull request
Aug 2, 2026
… remap Opt-in (spark.databricks.delta.optimize.conflictReconciliation.enabled, internal, default off). When a compaction OPTIMIZE loses to a concurrent DELETE/UPDATE that added a deletion vector to a removed source file, the ConflictChecker remaps that DV onto the compacted output by offset arithmetic (outputPos = runOutStart + (sourceRow - runSourceStart)) and unions it into the output's DV, instead of aborting. The run layout (source_file, start, count) is observed at write time -- cheaply. SourceCompositionCaptureExec reads the source file from InputFileBlockHolder and counts rows per file; no helper column, no _metadata.row_index (avoiding the DV-aware scan cost), rows pass through unchanged (~0% overhead). The composition is persisted in the output AddFile's OPTIMIZE_SOURCE_COMPOSITION tag, so the reconcile is cross-cluster-safe (replayed from the commit, no in-memory state). DV'd source files supported: the driver splits their live rows into contiguous physical segments around the read-time DV gaps, and only the winner's incremental deletions (winnerDv \ readTimeDv) are remapped. Coalesce compaction only; repartition (shuffle empties the holder) and reclustering/ZORDER (row-permuting) are not tagged and abort as before. All-resolvable-or-abort. OPTIMIZE-loser direction only; DELETE-loser and MERGE are follow-ups. Stacked on the row-level-concurrency DV PoC (#5 / PR #2). Adds OptimizeConflictReconciliationSuite (6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…refs Add coverage for the deletion-vector-merge row-level concurrency path: - non-empty base DV: DELETE id=5 first, then two disjoint concurrent DELETEs, exercising the `(dv_win INTERSECT dv_cur) MINUS base` subtraction that every prior test left empty. - 3-way where the last (overlapping) txn aborts while the two disjoint ones commit. - disjoint DELETEs reconcile under explicit Serializable isolation. - row tracking: surviving rows keep their stable `_metadata.row_id` across the same-file DV merge. - change data feed: each deleted row is emitted once, attributed to the version that deleted it (winner at N, reconciled txn at N+1). Also scrub fork-internal taxonomy from the suite comments (drop "Case 1 / fork issue #4" and "Layer-1" wording) in favor of self-contained descriptions ("conflict-time reader-side data skipping (a separate change)", "same-file DV union"). No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the RLC deletion-vector merge (rowLevelConcurrencyEnabled,
winningOperationName, canSkipAddedFileForRowLevelConcurrency,
resolveRowLevelConflicts, the DV read/write helpers, and
rowLevelResolvedPaths) out of the 1862-line ConflictChecker into
RowLevelConcurrencyResolution -- a `self: ConflictChecker =>` trait mixed
into the checker. ConflictChecker keeps only the call sites; `spark` and
`winningCommitSummary` are widened to `protected val`, and the DV helpers
stay `protected` so the OPTIMIZE-vs-DML reconciliation (which mixes into
the same checker) can reuse them.
Also add a concrete worked example to the resolveRowLevelConflicts doc:
winner DELETEs {5,10}, current DELETEs {20,21}, disjoint over base {} ->
merge to {5,10,20,21}; had current deleted row 5, overlap {5} -> abort.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iled canSkipAddedFileForRowLevelConcurrency can only return true for a path in rowLevelResolvedPaths, so when that set is empty the filterNot is a no-op. Guard on rowLevelResolvedPaths.isEmpty to skip the traversal and its allocation on the common no-conflict path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the same-file deletion-vector union coverage to MERGE: - disjoint concurrent MERGE matched-deletes reconcile (DV union, like DELETE) - a MERGE matched-delete reconciles against a concurrent plain DELETE (the union is operation-agnostic) - overlapping MERGE matched-deletes still conflict - a MERGE matched-update (winner) writes an image file, so a concurrent loser conservatively conflicts, mirroring the standalone-UPDATE case MERGE inserts / WHEN NOT MATCHED BY SOURCE stay out of scope (they need per-file row-tracking classification) and are not exercised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the same review feedback from Case 1 (delta-io#7358) proactively to Case 2: - Fail-safe fallback: extract the per-file DV read/overlap/merge/write into reconcileFileDeletionVectors and wrap the per-path call in try/catch (NonFatal). If resolving one shared file throws (unreadable/corrupt DV, transient I/O), skip row-level resolution for it and let the standard file-level checks abort cleanly with a retryable Concurrent* exception, instead of surfacing an unexpected error out of conflict detection. Other shared files are still resolved independently. - Index the current transaction's AddFile/RemoveFile maps in a single pass over actions instead of two `.collect{}.toMap` traversals. No behavior change on the happy/genuine-conflict paths: RowLevelConcurrencySuite 18/18 still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveRowLevelConflicts reconciled each shared file's deletion vectors sequentially on the caller thread. That per-file work is driver-side object-store DV I/O (read + merge + write), so when a winning transaction conflicts on many files the reconciliation window grows with the file count. Reconcile each shared file independently on a bounded pool (cf. DeltaFileOperations footer reads, which use 8); a single conflicting file stays on the caller thread, so the common case is unchanged. Per-file results are collected and then applied on the caller thread, so there is no shared-state race across the pool. The existing per-file NonFatal fail-safe is preserved (moved into reconcileOnePath): a DV decode/merge/write failure for one file skips row-level resolution for it (the standard checks then abort cleanly) without affecting the others. No behavior change on the happy/genuine-conflict paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l path Every existing RowLevelConcurrencySuite conflict is single-file (sharedPaths.size == 1), which takes the caller-thread branch of resolveRowLevelConflicts. Add a two-file test: a DELETE whose predicate spans two data files DV-updates both in one commit, so the loser reconciles two shared paths at once (parallelism == 2), exercising the ThreadUtils.parmap branch. parmap preserves input order, so the result matches the sequential branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rializable read-op tests Expand reconcileFileDeletionVectors' comment into an explicit induction on the winner chain: after merging winner k the current txn's base advances to winner k's DV, so each step's overlap test reduces to the two ops' own new deletes -- precise for any number of winners and independent of winner order. Add two RowLevelConcurrencySuite tests: - three disjoint DELETEs reconciling over a non-empty base DV (the deepest chain the ordering helpers reach; combines the base-DV and N-way subtleties). - a Serializable MERGE matched-delete loser (a read-then-modify op) vs a concurrent disjoint DELETE, confirming reconciled paths drop from readFiles under the stricter isolation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…f guarding each check Row-level resolution previously threaded a `!rowLevelResolvedPaths.contains(...)` guard into four separate file-level checks (append, delete-read, whole-table, delete-delete) in ConflictChecker. Instead, rewrite both sides of the conflict once in resolveRowLevelConflicts: rebase the current transaction (as before) and prune the reconciled AddFile(P)/RemoveFile(P) pair from a single effective `winningCommitSummary` (now a `var`, mirroring `currentTransactionInfo`). The four checks revert to byte-identical-to-upstream, since they now see a winner that never touched the reconciled files. Removes the trait-level mutable `rowLevelResolvedPaths` set and `canSkipAddedFileForRowLevelConcurrency`; the "don't skip UPDATE image files" reasoning moves to pruneReconciledFiles (only the same-path pair is pruned). ConflictChecker footprint drops to a mixin + one `var` + one call site; all subtle DV logic stays confined to reconcileFileDeletionVectors. RowLevelConcurrencySuite 21/21, FeatureEnablementConcurrencySuite 40/40. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Part of the row-level-concurrency umbrella (#3) — Case 2. Full design: #5. Addresses delta-io#7057.
Summary
POC implementing deletion-vector-based row-level concurrency for OSS Delta (Spark). Two concurrent DML operations that modify the same physical file but touch disjoint rows no longer abort the loser. A new config-gated phase in
ConflictCheckerdecodes both transactions' deletion vectors, and when the newly-deleted rows are disjoint, merges them (dv_win ∪ dv_cur), writes a new DV file, and rebases the losing transaction onto the winner's post-image.This is the sound same-file DV union. It is deliberately narrow: it reconciles conflicts that are provable from DV positions alone, and falls back to today's abort for everything else — including a rewrite-only DML winner's new image files, which are handed to conflict-time data skipping (Case 1, #4 / #8). See Correctness: UPDATE image files below.
What it does
resolveRowLevelConflicts()inConflictChecker, run before the file-level checks.dv_win,dv_cur, and their common basedv_base(from the current txn's pre-imageRemoveFile).(dv_win ∩ dv_cur) \ dv_baseempty → merge the DVs, persist a new DV file, and rebase the currentAddFile(P)onto the winner's post-image (itsRemoveFile(P)now tombstones the winner'sAddFile(P)). Non-empty → genuine same-row conflict, aborts as before.rowLevelResolvedPaths) — re-checking the winner's re-addedAddFile(P)(whose DV we already folded in) would be a false conflict.reassignOverlappingRowIdsphase (already run) keeps IDs consistent since the merged file is the same physical file with an unchanged base row ID.spark.databricks.delta.rowLevelConcurrency.enabled(internal, default off). No protocol / action / reader changes — the output is a standard DV action.Correctness: UPDATE image files (deferred to Case 1 / #4)
An
UPDATEproduces two things per touched file: the same-path DV pair (handled here, soundly) and a new image file at a fresh path carrying the updated row values. This PR does not skip those image files in the append check. That is intentional:An
UPDATEcan move a row into the loser's predicate — winnerUPDATE SET x = 15 WHERE id = r, loserDELETE WHERE x > 10, whererstarted atx = 5. Under a winner→loser serial order the loser would deleter, so this is a genuine write-skew conflict. The DV union cannot detect it (ris a different row from the ones the loser touched), so any op-agnostic "suppress a rewrite-only DML winner's added files" shortcut would be unsound — it would let the loser commit withrsurviving atx = 15, unmatched by its own predicate, strictly weaker than base WriteSerializable.Instead, image files remain ordinary non-blind changed-data files and flow into the standard added-files check. On an unpartitioned table that conservatively conflicts (one-way safe: falls back to today's abort). Reconciling the provably-disjoint case — the loser's read predicate vs the image file's stats — is exactly conflict-time data skipping, owned by Case 1 (#4 / #8). Case 2's UPDATE image-file reconcile therefore depends on #8 being in the build; standalone, Case 2 is DV-union only.
Scope
DELETE-vs-DELETE(fully reconciled), and the same-file DV union forUPDATE/DELETEmixes.UPDATEimage files → conservative abort here; provably-disjoint reconcile owned by Case 1 ([RLC] Case 1 — conflict-time data skipping (disjoint data ranges) #4 / [RLC] Case 1 — conflict-time data skipping (disjoint data ranges) #8).MERGEwith net-new inserts still conflicts by design (umbrella backlog).OPTIMIZE-compaction-vs-DML needs a cross-file offset remap → Case 4 ([RLC] Case 4 — OPTIMIZE ⟂ DML reconciliation (compaction offset remap) #7 / [RLC] Case 4a — OPTIMIZE loses to DML (forward compaction offset remap) #9 · [RLC] Case 4b — DML loses to OPTIMIZE (reverse compaction offset remap) #11), which reuses this PR's DV helpers.Testing
RowLevelConcurrencySuite(new), using the phase-locking concurrency harness:DELETE(loser) vsUPDATE(winner) → winner's image file conservatively conflicts, loser aborts cleanly (one-way safe; Case 1 refines this to reconcile the disjoint case); overlappingUPDATE/UPDATEon the same row → still conflict..andNot(base)), 3-way overlap → abort, explicit Serializable, row-tracking_metadata.row_idstability, CDF one-delete-per-version.Data correctness is asserted end-to-end (final row set) plus merged-DV cardinalities.
🤖 Generated with Claude Code