Skip to content

[RLC] Case 4 — Capture & persist compaction OPTIMIZE source composition (write side) - #12

Open
sezruby wants to merge 7 commits into
row-level-concurrency-dv-pocfrom
optimize-dv-remap-capture
Open

[RLC] Case 4 — Capture & persist compaction OPTIMIZE source composition (write side)#12
sezruby wants to merge 7 commits into
row-level-concurrency-dv-pocfrom
optimize-dv-remap-capture

Conversation

@sezruby

@sezruby sezruby commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Part of the row-level-concurrency umbrella (#3) — the shared write side of Case 4 (OPTIMIZE ⟂ DML reconciliation; full design #7). This PR only captures and persists where each compacted source's rows landed. The two reconcile directions that consume it stack on top: forward — OPTIMIZE loses (PR #9) — and reverse — DML loses (PR #11).

What

When OPTIMIZE conflict reconciliation is enabled, record on each removed source file's RemoveFile tombstone where that source's live rows landed in the compacted output. A later conflict check can then remap a concurrent deletion vector between the source and the output instead of aborting the loser. This PR adds only the capture + the persisted tag — no conflict-time logic.

Capture: (file, count) from InputFileBlockHolder — no _metadata

The run layout can't be derived from the read plan (Spark sorts scan splits size-descending before packing; a non-Spark engine's read order is opaque), so it's observed at write time — but cheaply:

  • SourceCompositionCaptureExec — a write-stage operator injected like DeltaOptimizedWriterExec — reads the current source file from InputFileBlockHolder (the thread-local input_file_name() uses) and counts rows per file, in write order, on both the row and vectorized execution paths. No helper column, no _metadata.row_index, no _metadata.file_path — rows pass through unchanged (no strip, no copy).
  • On the coalesce (no-shuffle) compaction path each source file's live rows land in one contiguous output segment, so recording (outputPath, outputStart, liveCount) per source fully describes the layout.
  • Overhead ≈ 0% (+1 ms / 4 M rows, within noise). Avoiding _metadata.row_index is the win: requesting it forces the DV-aware position-tracking scan path on every file, and _metadata.file_path materializes a per-row path string — together the bulk of the double-digit end-to-end overhead an earlier _metadata-observation prototype paid.

The persisted tags

On each removed source's RemoveFile tombstone:

compactedInto  = ["<outputPath>"]
compactionInfo = [{"rowOffsetInTarget": <outputStart>, "sourceNumPhysicalRecords": <physical count>}]
  • rowOffsetInTarget — the source's contiguous run-start in the compacted output (running sum in write order, evaluated on the driver at tag time).
  • sourceNumPhysicalRecords — the source's physical row count. A reader derives the live run length as sourceNumPhysicalRecords − |read-time DV|, where the read-time DV is carried on the same tombstone (RemoveFile.deletionVector). Storing the physical (not live) count keeps the entry O(#source files) regardless of DV cardinality — a fragmented read-time DV never bloats the tag.
  • Persisted on the tombstone (not stripped before commit, not on the output AddFile): snapshot reconstruction replays every AddFile on every read, whereas a removed file's tombstone is never materialized into the live snapshot, so the read hot path stays clean; tombstone retention (default ~7 days) comfortably outlives the conflict window. The Delta protocol ignores unknown RemoveFile tags, so persisting is free and forward-compatible — an ignored tag only forgoes a reconcile, never affects correctness.
  • The tag pair is modeled on the format Databricks Runtime records; interoperating with a DBR-written OPTIMIZE on a shared table is best-effort, not a verified guarantee. CompactionInfoEntry tolerates unknown fields for forward-compatible schema drift.

Scope / guards (coalesce-only)

Captured only for order-preserving compaction on the coalesce path. Otherwise nothing is recorded and a reader falls back to today's abort:

  • repartition path — a shuffle reorders rows across files and InputFileBlockHolder is empty after it.
  • reclustering / ZORDER — row-permuting, no offset mapping exists.
  • multi-file output, speculation, oneRunPerFile violated, or a source liveCount mismatch → not captured.

Safe because opt-in: losing the reconcile only restores current behavior, never wrong data.

Dependency

Stacked on the same-file DV-merge PoC (base branch row-level-concurrency-dv-poc, Case 2 — #5 / PR #2).

Test — SourceCompositionCaptureExecSuite

  • the vectorized path folds one run per source file across batches, in write order;
  • the operator stays columnar-transparent (mirrors its child).

End-to-end capture-and-reconcile behavior is exercised by the stacked reconcile PRs (#9 forward, #11 reverse).

🤖 Generated with Claude Code

sezruby and others added 5 commits August 4, 2026 12:56
When OPTIMIZE conflict reconciliation is enabled, record on each removed
source file's tombstone where that source's live rows landed in the
compacted output, so a later conflict check can remap a deletion vector
between the source and the output instead of aborting.

  - SourceCompositionCaptureExec: a write-stage operator that observes,
    per output partition, each source file's contiguous run of rows via
    the input-file identity and a per-file row count (row and vectorized
    execution paths), accumulating (sourceFile, outputStart, liveCount)
    runs without imposing a sort.
  - The OptimizeExecutor coalesce/compaction path injects the capture
    (compaction only: repartition and a clustering pass permute rows, so
    no contiguous offset mapping exists) and writes the composition as the
    RemoveFile compactedInto / compactionInfo tombstone tags.
  - RemoveFile.Tags.COMPACTED_INTO / COMPACTION_INFO + CompactionInfoEntry
    define the on-disk tag format (modeled on what Databricks Runtime
    records; cross-engine interop is best-effort, not a verified
    guarantee). Persisted, not stripped: an ignored tag only forgoes a
    reconcile, never affects correctness.

This is the capture/persist layer only; the conflict-time reconcile that
consumes the tags lands in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…les overload

Address review feedback on the OPTIMIZE source-composition capture/write path:

- buildRemoveFilesWithCompactionCompositionTags now returns the tagged
  Seq[RemoveFile] directly (inner `untagged` fallback), dropping the Map.empty
  fast/slow-branch plumbing at the call site.
- Keep the public 4-arg writeFiles signature undisturbed; add a 5-arg overload
  (no default arg) carrying sourceCompositionCapture and the
  SourceCompositionCaptureExec injection.
- Drop the vestigial `val writeOutput = output` alias; revert
  normalizeData/normalizeSchema signatures to their original single-line form.
- Add a columnar-path unit test for interleaved source batches (the "mixed"
  shape the one-run-per-file gate rejects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e tests

Extract the reconcile-capture read (clone session, pin FILES_MAX_PARTITION_BYTES
to the compaction target + FILES_MIN_PARTITION_NUM=1, run createDataFrame under
the pinned active session) into readCompactionSourceWithWholeFilePins so the
vanilla OPTIMIZE route keeps its plain createDataFrame inline. The non-RLC
tombstone branch likewise skips the reconcile helper and writes plain untagged
RemoveFiles.

Strengthen SourceCompositionCaptureExecSuite (now on DeltaSQLCommandTest) with
two end-to-end tests over a real compaction OPTIMIZE:
- multi-row-group sources under a hostile ambient split size still yield
  contiguous per-source compactedInto/compactionInfo tags tiling the output
  from offset 0;
- a source with no row-count stats fails the trustworthiness gate and falls
  back to plain untagged tombstones (a losing DML aborts as today), data intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The capture gate excludes the repartition compaction path (useRepartition):
repartition(1) shuffles rows into the output, so no source keeps a contiguous
row range and an offset composition would be meaningless -- a DV remapped by it
would corrupt data. Add a regression test asserting that with reconcile on and
optimize.repartition.enabled on, the removed sources carry plain untagged
tombstones (no compactedInto / compactionInfo), and the data is intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-by no-tag tests

Make the multi-row-group capture test a genuine regression guard for the
read pins in readCompactionSourceWithWholeFilePins. It now sets an ambient
FILES_MAX_PARTITION_BYTES (8 KiB) that would break each source into a full
split plus a row-bearing remainder and interleave the sources under
coalesce(1)'s descending-length packing. With the pins present the command
overrides this to the compaction target so each source reads whole and stays
one contiguous run (tags written); drop the pins and the sources interleave,
the one-run-per-file gate declines, and the test fails. (The earlier 512 B
value never interleaved -- many equal splits plus a footer-sized remainder --
so it could not catch pin removal.)

Also add two isMultiDimClustering-path contract tests asserting that ZORDER
and CLUSTER BY OPTIMIZE write no composition tags, since a clustering/z-order
pass permutes rows and no contiguous offset composition exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sezruby and others added 2 commits August 7, 2026 22:39
buildRemoveFilesWithCompactionCompositionTags could throw while building
the composition tags (an unmappable source path, a JSON serialization
error) after the OPTIMIZE output was already written, turning a
best-effort optimization enabler into a hard failure of the OPTIMIZE
commit.

Wrap the whole tag-building match in try/catch(NonFatal): on any failure
fall back to plain untagged tombstones -- exactly what vanilla OPTIMIZE
writes -- so the already-written OPTIMIZE still commits and a concurrent
loser aborts exactly as it does today. This upholds the method's own
documented contract that capture is never required for OPTIMIZE
correctness.

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