Skip to content

[RLC] Case 2 — same-file deletion-vector merge (concurrent DML ⟂ DML) - #2

Open
sezruby wants to merge 17 commits into
masterfrom
row-level-concurrency-dv-poc
Open

sezruby wants to merge 17 commits into
masterfrom
row-level-concurrency-dv-poc

Conversation

@sezruby

@sezruby sezruby commented Jul 7, 2026 •

Copy link
Copy Markdown
Owner

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 ConflictChecker decodes 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

  • New phase resolveRowLevelConflicts() in ConflictChecker, run before the file-level checks.
  • For a file both transactions modified with a DV, decode dv_win, dv_cur, and their common base dv_base (from the current txn's pre-image RemoveFile).
  • Disjointness rule: (dv_win ∩ dv_cur) \ dv_base empty → merge the DVs, persist a new DV file, and rebase the current AddFile(P) onto the winner's post-image (its RemoveFile(P) now tombstones the winner's AddFile(P)). Non-empty → genuine same-row conflict, aborts as before.
  • The delete/read, delete/delete, and append checks skip the resolved same-file paths (rowLevelResolvedPaths) — re-checking the winner's re-added AddFile(P) (whose DV we already folded in) would be a false conflict.
  • DV-only — row tracking is not required. Physical DV positions index one immutable Parquet file, so they are version-independent; when row tracking is enabled, the existing reassignOverlappingRowIds phase (already run) keeps IDs consistent since the merged file is the same physical file with an unchanged base row ID.
  • Gated by 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 UPDATE produces 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 UPDATE can move a row into the loser's predicate — winner UPDATE SET x = 15 WHERE id = r, loser DELETE WHERE x > 10, where r started at x = 5. Under a winner→loser serial order the loser would delete r, so this is a genuine write-skew conflict. The DV union cannot detect it (r is 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 with r surviving at x = 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

Testing

RowLevelConcurrencySuite (new), using the phase-locking concurrency harness:

  • DELETE/DELETE: disjoint → both commit (merged DV cardinality 2); overlapping → still conflict.
  • UPDATE: DELETE (loser) vs UPDATE (winner) → winner's image file conservatively conflicts, loser aborts cleanly (one-way safe; Case 1 refines this to reconcile the disjoint case); overlapping UPDATE/UPDATE on the same row → still conflict.
  • Edge cases: winner fully removes a file → conflict; 3-way concurrent disjoint DELETEs → all commit; partitioned table → both commit.
  • Gating: feature flag off → conflict; deletion vectors disabled (flag on) → gate off, conflict.
  • Coverage: non-empty base-DV (exercises .andNot(base)), 3-way overlap → abort, explicit Serializable, row-tracking _metadata.row_id stability, CDF one-delete-per-version.

Data correctness is asserted end-to-end (final row set) plus merged-DV cardinalities.

🤖 Generated with Claude Code

@sezruby
sezruby force-pushed the row-level-concurrency-dv-poc branch from 001b81e to 73c7bc7 Compare July 8, 2026 03:11
…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
sezruby force-pushed the row-level-concurrency-dv-poc branch from 73c7bc7 to e356283 Compare July 8, 2026 03:19
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>
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>
@sezruby sezruby changed the title [Spark] Row-level concurrency for concurrent DML via deletion-vector merge (POC) [RLC] Case 2 — same-file deletion-vector merge (concurrent DML ⟂ DML) Aug 3, 2026
sezruby and others added 8 commits August 4, 2026 11:25
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>
@sezruby

sezruby commented Oct 7, 2026

Copy link
Copy Markdown
Owner Author

Cross-link: the out-of-scope item "MERGE with net-new inserts still conflicts" is now handled by #20 (Case 1 follow-up, opt-in merge.sourceKeyReadPredicate.enabled).

  • MERGE records a source-key read predicate. The value-exact tiers evaluate it as an exact semi-join against the source keys, so a concurrent insert of the same new key aborts and disjoint inserts commit.
  • For disjoint keys in the same file, the write/write side still needs this PR's DV union. With both PRs, MERGE ⟂ MERGE on disjoint keys reconciles like the reference implementation.

sezruby and others added 4 commits October 14, 2026 22:38
Reconciling a shared file hides the winner's deletes in it from the
delete-read check. Require that none of the rows the winner newly deleted
match the current transaction's read predicates (one signed-weight scan of
the base-DV and winner-DV images), refuse whole-table reads, and accept a
MERGE without a scan only when it has no NOT MATCHED clause or has an
unconditional MATCHED clause. Previously a MERGE with a conditional matched
clause and an insert could commit after a winner deleted a row it matched
but left alone, skipping the insert that row's deletion requires.

Merged DV files are now written only once the whole resolution is known to
succeed, and the deletionVectorsMerged event is recorded after all checks
against the winning commit pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cy-dv-poc

Adapt to the onDiskWithRelativePath -> onDiskWithUuidRelativePath rename (delta-io#7473).
A deletion vector only grows under DELETE/UPDATE/MERGE, and both the overlap
test and the read check rely on that. RESTORE can bring deleted rows back
(e.g. Remove(P, {1,2}) + Add(P, {1})): the DV union would delete row 2 again,
and the signed-weight read check could cancel a newly deleted read row against
a restored one.

Skip row-level resolution when either the current or the winning transaction
is a RESTORE, and only reconcile a shared file when both the winner's and the
current DV contain the base DV, so any commit that brings rows back is left to
the file-level checks regardless of its operation name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Eunjin Song <sezruby@gmail.com>
The shortcut proves a MERGE unaffected by the winner's deletes from its
clauses alone, which assumes every target row's fate depends only on that row
and the source. A MERGE whose source reads the target table breaks that: two
crossed self-MERGEs (A reads row 20 and deletes 5, B reads row 5 and deletes
20) both committed, deleting rows that no serial order deletes together.

Source reads of the target are recorded as extra read predicates (a normal
MERGE records one, the target scan; the crossed self-MERGE records four), so
take the shortcut only when there is exactly one and otherwise fall back to
the read-check scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Eunjin Song <sezruby@gmail.com>
sezruby and others added 2 commits October 20, 2026 23:53
…ondition

A DELETE or UPDATE marks every row of the shared file that matches its
condition, so when that condition is its only read the disjointness test
already shows that none of the winner's newly deleted rows match it. Return
early instead of scanning both images of every shared file, which could reread
the predicate columns of large files for single-row DMLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Eunjin Song <sezruby@gmail.com>
Row-level resolution used to decode deletion vectors, scan reads and write a
merged DV file per shared file before the remaining file-level checks ran, so a
transaction that then aborted (e.g. on a winner's UPDATE image file) had done
that work for nothing and left the merged DV files orphaned. It also held every
shared file's bitmaps in driver memory at once, and wrote one DV file per file.

Split it in two. `prepareRowLevelConflictResolution` only compares actions: it
sets aside the files whose DV both transactions updated and drops them from the
winning summary and from the current read files. The file-level checks then
run against everything else. `resolveRowLevelConflicts` runs last: it decodes
the DVs, runs the read check and writes the merged DVs, and throws a
ConcurrentDeleteDelete/DeleteRead exception if any file cannot be reconciled.

Merged DVs are written through one PackedDeletionVectorWriter, packed up to
deletionVectors.packing.targetSize like the DML write path. Decoded bitmaps
are kept between the check and the merge only up to the new internal conf
rowLevelConcurrency.maxRetainedDeletionVectorBytes (64MB); the rest are read
again when merged, in bounded batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Eunjin Song <sezruby@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant