Skip to content

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

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

[RLC] Case 2 — same-file deletion-vector merge (concurrent DML ⟂ DML)#2
sezruby wants to merge 11 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>
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