RFC 0008: Maintenance operations — compaction and delete-file consolidation
- Date: 2026-07-09 (revised 2026-07-13 against DuckLake’s implementation)
Summary
Section titled “Summary”Frequent commits and inline flushes (RFC 0005) leave a table with many small
Parquet data files; merge-on-read deletes leave data files shadowed by
delete files. Both slow scans. DuckLake’s answer is compaction —
ducklake_merge_adjacent_files combines small adjacent files,
ducklake_rewrite_data_files rewrites a file with its deletions applied —
and, source-verified (DuckLakeCompactor,
DuckLakeMetadataManager::WriteMergeAdjacent / WriteDeleteRewrites),
DuckLake does all of it itself: it reads and rewrites the Parquet,
mints an ordinary snapshot, and authors the catalog row changes through
the metadata connection. moraine’s job is translating two row-operation
shapes the ordinary write path does not produce — hard deletes of
superseded file rows (merge) and a SET begin_snapshot rebase of the
replacement file (rewrite) — plus serving the deletion schedule the
superseded bytes flow into (RFC 0007). The load-bearing invariant is
unchanged: compaction never allocates row ids — outputs carry their
input rows’ ids, next_row_id is untouched, and row lineage holds.
- Row-id lineage preserved exactly. Rewritten rows keep their row ids
(DuckLake preserves them across compaction for UPDATE and time-travel
row identity), so the per-table
next_row_idintstatis read-only for the whole operation. - Compaction is an ordinary snapshot-minting commit. DuckLake writes
the new Parquet first, then one metadata transaction that lands as one
moraine
WriteBatch(RFC 0002 atomicity): the new file row, the superseded rows’ fates, the schedule inserts, the snapshot record, the head advance. - Conflict semantics faithful to DuckLake. Compaction’s
snapshot_changescarrymerge_adjacent:<t>/rewrite_delete:<t>; moraine’s conflict classifier arbitrates them per DuckLake’s own matrix (DuckLakeTransactionState::CheckForConflicts): conflicting with drops, delete-froms, and other compactions of the same table — and with nothing else, so appends never abort a compaction. - Time-travel safe, by DuckLake’s own construction (below): a snapshot older than the compaction still resolves the same rows.
Non-goals:
- Physical deletion of superseded files — RFC 0007’s schedule and DuckDB’s cleanup functions. Compaction schedules (merge) or dead-ends (rewrite) the old rows; it deletes no bytes.
- Auto-compaction policy (which files, when, target size) — DuckLake parameters; moraine computes nothing.
- Inline flush — RFC 0005, itself a compaction-shaped op that does allocate row ids (it materializes new inserts). Kept strictly separate: reusing flush’s id allocation here would renumber rows and break lineage.
- Key layout — RFC 0002.
Source-verified mechanics
Section titled “Source-verified mechanics”Merge (merge_adjacent_files) — one snapshot-minting metadata
transaction per batch:
- DuckLake writes the merged Parquet, carrying per-row
row_idandsnapshot_idcolumns (DuckLakeCompactorsetswrite_snapshot_idfor merges): time travel within the merged file is by row filtering, not by file selection. - The new
ducklake_data_filerow is inserted through the ordinary path with backdatedbegin_snapshot— the first source file’sbegin_snapshot— so every retained snapshot resolves the merged file. - The source rows are hard-deleted:
DELETE FROM ducklake_data_file / ducklake_file_column_stats / ducklake_delete_file / ducklake_file_partition_value / ducklake_file_variant_stats WHERE data_file_id IN (…)— current and history rows alike, no history mirror. Old snapshots resolve the merged file thereafter; the original rows cease to exist. A partitioned source’sfile_partition_valuerows are embedded in itsfilerecord (RFC 0013), so one batch hard-deletes a parent and its embedded children, in no guaranteed order. Whether an embedded row’s parent survives is therefore a question about the batch, not about state accumulated so far: a hard delete leaves the working state alone by design, so the parent still reads as live there. - Each source path is inserted into
ducklake_files_scheduled_for_deletion— merge schedules its superseded bytes directly, without waiting for snapshot expiry. - A source carrying live delete files is not plainly merged; its deletes are materialized into the output (the rewrite shape below) — a delete file must never outlive its data file.
- Merge groups by partition and never crosses a boundary. Four files across two partition values merge to two — one per value, as two independent merges — so a merged file always carries exactly one partition value and the governing spec stays satisfied by construction. The eligibility rule is DuckLake’s, applied before moraine sees the batch; moraine records the merge it is handed and enforces nothing.
Rewrite (rewrite_data_files) — also snapshot-minting:
- DuckLake writes the survivor rows (original row ids) to a new file, inserted through the ordinary path.
UPDATE ducklake_data_file SET end_snapshot = <rewrite snapshot> WHERE data_file_id = <source>and the same for the consumedducklake_delete_filerow — the standard lifecycle convention; the old rows move to history and stay resolvable for time travel.UPDATE ducklake_data_file SET begin_snapshot = <rewrite snapshot> WHERE data_file_id = <new file>— the one statement outside the ordinary write vocabulary: the replacement file’s visibility window is rebased after its insert, in the same transaction.- Nothing is scheduled here: the ended rows become dead history that RFC 0007’s expiry cascade later reclaims (and schedules) once no surviving snapshot references them.
moraine’s translation obligations
Section titled “moraine’s translation obligations”Both shapes ride the staged-row path (RFC 0006) inside an ordinary snapshot-minting commit:
- Hard deletes of file rows (merge step 3): a DELETE against a
versioned kind names the exact row by
(entity id …, end_snapshot);NULLdeletes thecurrentkey, a value deletes thathistorykey. Shared with RFC 0007’s cascade translation — one mechanism, two callers. - Schedule inserts (merge step 4):
current/gcfileputs keyed by the scheduled file’s id (RFC 0007). SET begin_snapshot(rewrite step 3): rebases the current data-file record’sbegin_snapshotin place. Legal only against a liveducklake_data_filerow; DuckLake only ever issues it against the file the same transaction just inserted.- Backdated inserts (merge step 2) already translate: the staged path
carries
begin_snapshotverbatim (inline flush backdates the same way). next_row_iduntouched: compaction stages noducklake_table_statschange that advances it; the translation carries whatever DuckLake authors, and DuckLake authors no allocation.- Equality-index entries untouched: entries name rows, not files, and compaction changes neither a row’s id nor its values. A commit whose change set is compaction alone stages no index work (RFC 0016); it reads the files it registers and consumes for their row-id columns alone, for the hold below.
- Row ids held to the sources. Before a compaction lands, the commit
reads the row ids of the files it registers (backdated for a merge,
rebased for a rewrite) and of the files it hard-deletes or ends, and
compares them: a merge’s files must hold exactly the multiset its
sources held, and a rewrite’s may hold fewer but none the sources
lacked. A difference is refused as corruption and the sources stay
live. The hold exists because DuckLake’s merge judges two files
adjacent from
row_id_start + record_countalone and then numbers the merged file by position, while a flush output’srow_id_startis merely its lowest embedded id: a flushed batch that straddled two partitions leaves one partition’s file with gaps the start hides, a dense predecessor chains onto it, and every row after a gap is renumbered onto ids the other partition holds. Compaction derives no index entries, so without the hold the index would keep naming the rows the files had just stopped holding. An update’s output has the same exposure from the other side: its rows keep their original ids while it is registered with a dense start at the next free id, so the file registered after it starts exactly where that start plus its row count ends. The bundled DuckLake carries the fix itself (patches/ducklake/0010): a merge always writes the row-id column, and adjacency no longer decides anything. The hold stays as the backstop, since it is the commit, not the planner, that owns the rows’ identity.
Conflict classification
Section titled “Conflict classification”Already in place: merge_adjacent / rewrite_delete change kinds parse
into per-table compaction markers, and the conflict matrix makes
compaction × {delete-from, drop, compaction} of one table a true
conflict while appends and disjoint commits stay benign. A compaction
that loses a race surfaces the conflict-substring error DuckLake’s own
retry loop scans for.
Conflict classification remains at table grain. DuckLake’s change records name the table, and no measured workload shows same-table compactions losing enough races to justify carrying and comparing file sets in the commit protocol. File-set-grain classification would be a replacement design backed by such a measurement, not latent behavior this protocol promises.
Test obligations
Section titled “Test obligations”Core, against real SlateDB on in-memory object_store:
- Merge-shaped commit. New file inserted backdated; source rows (and
their stats) hard-deleted including history rows; sources scheduled;
next_row_idunchanged; head advanced by exactly one; the changes parse as a compaction of the table. - Rewrite-shaped commit. Source file and delete file end into
history; the replacement’s
begin_snapshotrebases in place; nothing scheduled;next_row_idunchanged. SET begin_snapshotmisuse is rejected (a non-current target is a shape error).- Row ids held. A merge whose output numbers a sparse source by position is refused and keeps its sources; one carrying its sources’ ids lands. A rewrite dropping rows lands; one naming an id its source lacked is refused.
Live, via cargo xtask e2e:
merge_adjacent_files: several small files merge to fewer; rows and row ids identical before and after; time travel to a pre-merge snapshot returns the pre-merge answer; source paths scheduled and reclaimed bycleanup_old_files; an UPDATE after the merge still hits the right row.rewrite_data_files: after a DELETE, the rewrite leaves no live delete file; survivors keep their row ids; time travel to the pre-rewrite snapshot still shows the deleted rows.- A merge over a flush output whose batch straddled two partitions keeps every row id: the bundled DuckLake writes the row-id column rather than chaining the file, and the merged file carries no dense start. (The commit’s refusal of a merge that would renumber is pinned at the core level, where such a merge can be staged directly.)
- A merge over an update’s output and the file registered after it keeps every row id, with the commit’s hold live to refuse anything else.
Alternatives considered
Section titled “Alternatives considered”- A moraine-side compaction engine (the prior revision: moraine plans the rewrite, carries ids through, and stages an end-to-history commit itself). Rejected on source-verification, for RFC 0007’s reason: DuckLake already does the planning, the Parquet I/O, and the row authoring; moraine re-implementing it would be a second copy of DuckLake’s semantics to keep faithful forever.
- Translating merge’s source-row deletes as end-to-history
transitions (“time-travel safe because old files survive in
history”). Rejected — it contradicts what DuckLake writes. Merge
hard-deletes the source rows and keeps time travel correct by
backdating the merged file and filtering rows by the per-row
snapshot_idcolumn; mirroring rows into history that DuckLake deleted would desynchronize the projections from the catalog DuckLake believes it wrote. - Allocating fresh row ids for compacted rows (reuse the RFC 0005
flush id path). Rejected: breaks DuckLake row lineage — UPDATE and
time-travel row identity depend on stable ids — and bumps
next_row_id, turning a no-op into row-id churn. - Deleting superseded bytes inside the compaction commit. Rejected: couples object-store deletes to the catalog commit and destroys the grace the schedule provides; merge schedules, cleanup deletes (RFC 0007), exactly DuckLake’s own split.