Skip to content

[RLC] Case 4b — DML loses to OPTIMIZE (reverse compaction offset remap) - #11

Open
sezruby wants to merge 3 commits into
optimize-dv-remapfrom
optimize-delete-loser
Open

[RLC] Case 4b — DML loses to OPTIMIZE (reverse compaction offset remap)#11
sezruby wants to merge 3 commits into
optimize-dv-remapfrom
optimize-delete-loser

Conversation

@sezruby

@sezruby sezruby commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Part of the row-level-concurrency umbrella (#3) — Case 4b (reverse direction: a concurrent DML that loses to a compaction OPTIMIZE). Full design: #7. Stacked on PR #9 (Case 4a, forward OPTIMIZE-loser); both consume the composition captured & persisted by PR #12 (write side).

What

The mirror of #9's forward reconcile: a row-level DML that loses to a concurrent compaction OPTIMIZE which removed the files it touched. Opt-in via spark.databricks.delta.optimize.conflictReconciliation.reverse.enabled (internal, default off; must be on for both the OPTIMIZE and the DML sessions — it also arms the write-time capture in PR #12 so the winner persists its composition). Instead of aborting the DML, ConflictChecker.resolveReverseOptimizeConflicts reads the winning OPTIMIZE's persisted per-source composition and remaps the DML's newly-deleted rows onto the compacted output, so the DML commits.

Composition it reads (from PR #12)

The winner's compactedInto / compactionInfo tombstone tags, parsed by the shared ConflictChecker.parseOptimizeSourceComposition into (outputPath, outputStart, liveCount) per removed source — the same parser and format #9's forward direction uses. This PR adds no write-side or tag code; the capture, the persisted format, and the parser all live in #12 / #9. Multi-run / out-of-commit / unrecognized shapes → None → abort.

Reverse mechanism

The winner OPTIMIZE commits RemoveFile(F1..Fn) + AddFile(C, dataChange=false) — required to be a pure compaction (every removed source carries a composition into one of this commit's outputs, and every added output is a referenced target; otherwise abort). For each newly-deleted physical row i of the losing DML in a removed source F (only loserDv \ loserReadDv — rows already deleted at the DML's read time were dropped from C), compute outputStart + liveRank(i) where liveRank(i) = i − |read-time DV below i|, and union it into C's deletion vector (seeded from C's existing DV); then re-point the DML's RemoveFile(F) at the winner's post-image (files keyed by path and DV). All-resolvable-or-abort. A pure compaction is a dataChange=false relocation, so the DML's read stays invariant to it.

A DML-touched, winner-removed source that is left un-remapped (e.g. a full-file delete emitting a bare RemoveFile with no re-added DV'd AddFile) forces an abort rather than silently dropping the delete.

Safety

Opt-in, default off, both directions. No composition tag (repartition shuffle emptied InputFileBlockHolder, reclustering/ZORDER, a vectorized/native write backend bypassed the capture, multi-run, or an out-of-run row) → falls back to today's abort — never wrong data.

Tests — OptimizeConflictReconciliationSuite

  • forward (OPTIMIZE-loser) + parser behavior carried from [RLC] Case 4a — OPTIMIZE loses to DML (forward compaction offset remap) #9;
  • reverse (DML-loser): compaction winner ⟂ DELETE/UPDATE loser → both commit, read-back correct; correct remap across an OPTIMIZE-time read gap; reverse-off aborts; winner-recorded-no-composition aborts; ZORDER winner aborts; a losing full-file delete that can't be remapped aborts (mixed full/partial).

Green: OptimizeConflictReconciliationSuite (21) + OptimizeConflictSuite (1) + RowLevelConcurrencySuite (9).

Dependency

Stacked on #9 (Case 4a, base branch optimize-dv-remap), which stacks on the capture/persist write side #12 (optimize-dv-remap-capture) and the same-file DV-merge PoC (Case 2 — #5 / PR #2). Combined design: #7.

🤖 Generated with Claude Code

@sezruby
sezruby force-pushed the optimize-delete-loser branch from 9d06444 to 8029aaf Compare August 3, 2026 20:21
@sezruby
sezruby force-pushed the optimize-delete-loser branch from 8029aaf to e596626 Compare August 3, 2026 22:06
@sezruby sezruby changed the title [Spark] Reverse OPTIMIZE reconciliation (DML loser) + Databricks-compatible composition tags [RLC] Case 4b — DML loses to OPTIMIZE (reverse compaction offset remap) Aug 3, 2026
@sezruby
sezruby force-pushed the optimize-delete-loser branch 2 times, most recently from 7e30378 to 0396ab8 Compare August 4, 2026 01:16
@sezruby
sezruby force-pushed the optimize-dv-remap branch from e9c24cd to bf533fc Compare August 4, 2026 06:00
@sezruby
sezruby force-pushed the optimize-delete-loser branch from 0396ab8 to 312c6b5 Compare August 4, 2026 06:00
@sezruby
sezruby force-pushed the optimize-dv-remap branch from bf533fc to b0412f6 Compare August 4, 2026 06:43
@sezruby
sezruby force-pushed the optimize-delete-loser branch from 312c6b5 to 77c5d8a Compare August 4, 2026 06:43
@sezruby
sezruby force-pushed the optimize-dv-remap branch from b0412f6 to 1009bfc Compare August 4, 2026 19:01
@sezruby
sezruby force-pushed the optimize-delete-loser branch from 77c5d8a to b744f31 Compare August 4, 2026 19:01
The reverse of the forward reconcile: when a row-level DML (DELETE/UPDATE)
LOSES a commit race to a concurrent compaction OPTIMIZE that removed the
files it touched, remap the DML's deletion vector onto the winner's
compacted output instead of aborting.

  - ConflictChecker.resolveReverseOptimizeConflicts reads the winner's
    persisted compactedInto / compactionInfo tombstone tags (via the shared
    parseOptimizeSourceComposition) and rebases the DML's new deletions onto
    each compacted output by contiguous offset + live rank, tombstoning and
    re-adding the output with the merged DV.
  - Conservative and safe-by-abort: only a pure compaction (every removed
    file referenced by an output, every output referenced, dataChange=false);
    aborts if any DML-touched source is left un-remapped (e.g. a full-file
    delete), if a row falls outside its source's run, or if the output bound
    is exceeded.
  - Gated by optimize.conflictReconciliation.reverse.enabled (internal,
    default off); the flag also drives the write-time capture so the winner
    persists its composition.

Stacks on the forward reconcile (optimize.conflictReconciliation.enabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sezruby and others added 2 commits August 7, 2026 22:39
resolveReverseOptimizeConflicts remaps a losing DML's deletion vectors
onto the winning OPTIMIZE's compacted output via driver-side object-store
DV I/O during conflict detection, mirroring the forward direction. A
failure there (unreadable/corrupt DV, transient I/O) would surface an
unexpected error out of the conflict checker instead of the retryable
Concurrent* exception callers expect.

As in the forward direction, reconciliation is a pure optimization over
the conservative abort and mutates the transaction only on the success
path. Wrap the work in try/catch(NonFatal) so any DV read/merge/write
failure leaves the transaction untouched and the standard file-level
checks abort cleanly.

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