Skip to content

Row-level concurrency for OSS Delta: reconcile the writes that provably cannot conflict (umbrella) #3

Description

@sezruby

Background

Delta's documented row-level concurrency states that several concurrent-write pairs on a table with
deletion vectors cannot conflict — two DML statements editing disjoint rows, a compaction
OPTIMIZE vs a concurrent DELETE/UPDATE, a blind INSERT against anything. OSS Delta today
still aborts the loser in most of these: ConflictChecker conservatively treats a file that a
concurrent commit removed, or added a DV to, as a conflict — even when the two commits are logically
independent.

This umbrella tracks closing that gap without any protocol change and without row tracking, as a
series of small, independently reviewable, opt-in (default-off) additions to ConflictChecker.

Organizing principle

Rather than enumerating special cases, the work is driven by one property:

Reconcile a concurrent pair whenever the loser's outcome is provably reconstructable from
deletion-vector positions, reader statistics, or a derivable output offset — never from a
whole-table read, a row permutation, or row lineage.

Everything reconstructable from that information is in scope (Phase 1); anything needing a
whole-table read, a row permutation, or row tracking is backlog. This lands exactly at parity with
the documented contract, because the deferred cases are the ones the reference implementation does
not reconcile either.

Phase 1 — opt-in, no row tracking

# Concurrent pair Reconcile mechanism Extra input
3 Compaction OPTIMIZE (dataChange=false) ⟂ non-blind append exclude dataChange=false (relocation) adds from the added-files conflict check
1 Writers touching different files / disjoint data ranges conflict-time data skipping: prune the loser's read set with reader stats before the file-level checks reader stats
2 Same file, disjoint rows — DML ⟂ DML union the two deletion vectors when the newly-deleted row sets are disjoint DVs
4a Compaction OPTIMIZE loses to a concurrent DML forward offset remap: translate the winning DML's newly-deleted rows to their positions in the compacted output and union a DV onto it derivable offsets
4b A concurrent DML loses to a compaction OPTIMIZE reverse offset remap: read the composition the OPTIMIZE persisted and remap the losing DML's deletions onto the compacted output derivable offsets

Tracked issues / PRs

Each case carries its own design in its own issue.

Dependencies. Case 4 reuses Case 2's deletion-vector helpers and splits into a shared write side
(PR #12) that captures & persists the composition, plus two reconcile readers that consume it —
forward (4a) and reverse (4b) — giving the stacking chain Case 2 → 4 (capture) → 4a → 4b. Cases
1 and 3 are independent. Suggested review order: this issue → 1 → 2 → 4 (capture) → 4a → 4b.

How the compaction cases stay cheap (Case 4)

Compaction concatenates each source file's live rows into one contiguous run in the output without
permuting within a file, so the whole problem reduces to where each source's run starts in the
output
. That offset isn't derivable from the read plan (scan splits are sorted before packing, a
shuffle reorders rows, a non-Spark engine's order is opaque), so it is observed at write time: a
write-stage operator reads the per-row source-file identity that input_file_name() already exposes
and records, on each removed source's RemoveFile tombstone, the run it contributed —
compactedInto (the output path) and compactionInfo ({rowOffsetInTarget, sourceNumPhysicalRecords}).
Rows pass through unchanged: no _metadata.row_index, no helper column, no row copy.

sourceNumPhysicalRecords is a physical count, kept self-consistent with the tombstone's own DV;
the live run length is sourceNumPhysicalRecords − |sourceDV|, so a source that carried a read-time
DV has its non-contiguous live rows reconstructed lazily at conflict time. The tags live on the
short-lived tombstone rather than the output AddFile (which snapshot reconstruction replays on
every read), so the read hot path is untouched; tombstone retention (~7 days default) comfortably
outlives the conflict window (a concurrent transaction's runtime), so the tag is still present when a
losing DML reads it.

Safety model

  • Opt-in, default off. Every case is behind its own flag; losing a reconcile only restores
    today's abort, so a bug can never produce wrong data — only a spurious (but correct) abort.
  • Abort on any doubt. A reconcile proceeds only when the layout is fully captured and every
    affected row maps within its source's run; otherwise the loser aborts exactly as it does today.
  • Genuine conflicts preserved. Same file, same rows still aborts; ZORDER / reclustering still
    aborts.
  • Each PR is reviewable and revertable in isolation.

Backlog — needs row tracking, not part of this proposal

# Concurrent pair Why deferred
5 Reclustering / ZORDER ⟂ DML rows are permuted across files, so no derivable offset exists; a correct reconcile needs identity remap via row tracking plus a scan in the conflict path
6 MERGE net-new inserts / non-matched-by-source (full parity) needs per-file row-tracking classification

The reference contract does not reconcile these either, so Phase 1 already reaches full parity;
closing them would require load-bearing row tracking and a scan inside the otherwise-cheap,
synchronous conflict path.

Related work

Notes

Clean-room: this proposal cites only Delta's public row-level-concurrency contract and our own
black-box observations of its published behavior; it copies no proprietary source. Case 4's
composition carrier uses the compactedInto / compactionInfo tombstone-tag format so an OPTIMIZE
written by another engine can be reconciled against on a shared table; that interop is modeled on the
published format and is best-effort, not a verified guarantee (an unrecognized tag only forgoes the
reconcile — never wrong data).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions