feat(compaction): repack a fragment's columns as a compact_files task - #9604
LuciferYang wants to merge 27 commits into
Conversation
Repack a fragment's per-column data files into fewer files without moving rows. Adds CompactionExecutor/CompactionCommitter traits (vertical reuses the existing rewrite path), a horizontal RewriteColumns path committing Operation::Update with an empty fields_modified (indices and overlays preserved), column_layout_stats, and a compaction column_groups option.
Add a generic run_compaction_pipeline<E,C> that both vertical and horizontal compaction run through, so the traits are a real plug point. Route Dataset::rewrite_columns through it (bounded concurrency, no bespoke cleanup). Surface a missing fragment and an uncovered indexed column as errors instead of silently miscounting or dropping a seed. Add tests for column_groups seed routing (ZoneMap), split-preserved-across-compaction, config parsing, and index/overlay preservation.
|
ACTION NEEDED The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification. For details on the error please inspect the "PR Title Check" action. |
|
@wjones127 @Xuanwo, picking up the horizontal-compaction thread (#4286, discussion #3995, and the stalled #9291). I built the whole thing out on a branch and split it into reviewable PRs; here's where it stands. The gap, restated: repeated Two things you each asked for shaped the design:
Correctness rests on committing horizontal compaction as Layout of the stack:
A couple of things I'd rather settle with you than alone:
Review of #9606 and #9605 would be much appreciated; #9604 has the full context for the direction question. |
…paction # Conflicts: # rust/lance/src/dataset/optimize.rs
RewriteColumnsCommitter committed against the current manifest version, so in the distributed path a fragment changed between its read and the commit would slip past the conflict check. Carry each rewrite's read version in RewriteColumnsResult and commit against the minimum, mirroring vertical compaction's RewriteResult::read_version. Single-machine rewrite_columns is unaffected (read version == commit version).
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The read-version change closes the stale distributed-commit data-loss path from the previous review. The remaining acceptance step is a checked-in horizontal rewrite regression test: execute at an older snapshot, delete before commit, and verify the commit conflicts while the delete remains. That protects the new result-to-transaction contract in distributed use.
Please mark this PR with the breaking-change label.
| // Commit against the earliest version any rewritten fragment was read | ||
| // at, not the current one, so the conflict check catches a fragment | ||
| // changed between the read and this commit (as vertical compaction does). | ||
| let read_version = results |
There was a problem hiding this comment.
The new read-version path has no horizontal regression test, leaving this data-integrity fix unguarded. A stale rewrite committed after a delete previously restored the deleted row, and the repository requires a corresponding test for bugfixes. I ran the scenario at this head: execute RewriteColumnsExecutor at version V, delete a = 1 at V+1, commit its RewriteColumnsResult, then assert a conflict, seven remaining rows, and no a = 1; all assertions pass. Please add this scenario to this module's tests so the version binding cannot silently regress.
There was a problem hiding this comment.
Fixed in b15e1e9: the checked-in test executes a fragment rewrite before a delete, verifies the stale commit fails, and confirms the deleted row stays absent. I ran the focused test at this head; it passed.
Regression test for the read-version binding: a rewrite read at V, committed after a delete lands on the same fragment at V+1, must conflict rather than resurrect the deleted row. Guards the fix so the distributed commit path cannot silently regress.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The read-version binding and new regression test close the stale distributed-rewrite path: a delete between execution and commit now causes the rewrite to fail, and the deleted row remains absent. Horizontal rewriting reduces per-fragment file fan-out while preserving row addresses and index coverage.
Please mark this PR with the breaking-change label.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
Horizontal rewriting still reduces column-file fan-out while preserving row addresses, indexes, overlays, and row lineage. This revision protects distributed commits against deletes and column drops, and keeps grouped compaction's file alignment and index seed lookup consistent.
Please mark this PR with the breaking-change label.
|
Important Format specification voteThis PR modifies the Lance format specification, so it requires 3 binding +1 votes from PMC members (excluding the proposer), at least one of them on the latest commit, and a minimum 72-hour voting period, weekends excluded, before it can merge. Vote by approving this PR (+1) or requesting changes (−1, a veto). See the voting process. Approvals carry over across pushes, so a rebase or a typo fix does not send everyone back to re-vote. Whoever approves the latest commit is vouching that nothing substantive has changed since the earlier approvals; if something has, ask for fresh votes. Status: ❌ Blocked — 0 of 3 required approvals
Updated automatically by the format-spec vote gate, which re-checks every 15 minutes — just voted? Re-check now (press Run workflow; leave the input blank to re-check every open format PR). A PMC member may apply the |
There was a problem hiding this comment.
✅ Gate recommendation: approve.
Integrating column repacks into compact_files follows the pluggable planner/executor direction requested in #9291. The replacement commit preserves row addresses, deletion vectors, overlays, index coverage, and row lineage while detecting overlapping base changes.
Mixed rewrite/repack runs commit two versions. If the repack phase fails, refresh and replan; the completed rewrite remains committed.
Please mark this PR with the breaking-change label.
| expected = dataset.to_table() | ||
|
|
||
| plan = Compaction.plan( | ||
| dataset, options=dict(column_groups=[["d"]], scope="repack_columns") |
There was a problem hiding this comment.
This test fails before exercising pickling or commit: _backfilled already stores d alone, so this group matches the existing layout and the planner correctly emits no tasks. Use column_groups=[["c", "d"]] to force a repack. I ran that variant: two tasks survive both pickle round trips, commit to two files per fragment, and preserve the data and fragment IDs.
Reproduced from python/ with uv run pytest python/tests/test_optimize.py::test_distributed_repack: the following assertion expects two task kinds but receives [].
There was a problem hiding this comment.
Fixed in bf6f0a1: the fixture now groups c and d, which start in separate files. The checked-in distributed test passes and verifies both pickle round trips, the commit, and unchanged values.
|
@wjones127 @Xuanwo I reworked this PR against your review comments on #9291 and #8614. Here is how each one is handled now; the description has the details.
Two decisions you may want changed:
A #9606 now carries only the |
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The distributed repack test now requests a layout change and exercises the task/result round trip. Integrating repacks into compact_files follows the pluggable planner/executor direction requested in #9291. The replacement commit preserves row addresses, deletion vectors, overlays, index coverage, and row lineage while detecting overlapping base changes.
Mixed rewrite/repack runs commit two versions. If the repack phase fails, refresh and replan; the completed rewrite remains committed.
Please mark this PR with the breaking-change label.
| * moved to new files: index coverage, overlays, and row update versions are | ||
| * left as they are. | ||
| */ | ||
| optional bool data_change = 2; |
There was a problem hiding this comment.
@wjones127 This is the data_change field you suggested in #8644 r3833888750, and it puts this PR under the format-spec vote. Two questions before I ask for votes:
- Do you still want it on the legacy
DataReplacement, now that feat(transaction): implement the Transaction V2 action vocabulary #8644 models the same thing asTombstoneFieldDataplusAddDataFilewithdata_change: false? I kept it because, after feat(transaction): implement the Transaction V2 action vocabulary #8644 merges, release builds still needLANCE_ENABLE_UNSTABLE_TRANSACTION_V2to write V2, so repacks would keep committing asDataReplacementthere. If the field isn't in the transaction file, an index build racing a repack of its column has to retry. Once V2 is generally available, repacks can move to aCompositeOperation, and a run that both rewrites and repacks can then commit once. - If you do want it, should this field, the spec text in
transaction.mdand theDataReplacementchanges go to a separate PR for the vote? That PR would be about 600 lines instead of 4,300, and later changes to the compaction code here wouldn't fall under the spec vote.
cc @Xuanwo
There was a problem hiding this comment.
✅ Gate recommendation: approve.
Repacking through compact_files reduces column-file fan-out while preserving row addresses, deletion vectors, overlays, index coverage, and row lineage. The new Python and Java statistics expose the same manifest counts used by the planner.
For the transaction-format question, retaining DataReplacement with data_change: false follows the maintainer's suggested mechanism and supports value-preserving commits in the current format. Composite transactions remain unmerged.
Mixed rewrite/repack runs commit two versions. If repacking fails, refresh and replan; the completed rewrite remains committed.
Please mark this PR with the breaking-change label.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
Repacking through compact_files reduces column-file fan-out while preserving row addresses, deletion vectors, overlays, index coverage, and row lineage. The expanded layout statistics expose recorded file sizes, fields per file, and dead-slot ratios in Rust, Python, and Java using manifest metadata only.
For the transaction-format question, retaining DataReplacement with data_change: false follows the maintainer's suggested mechanism and supports value-preserving commits in the current format. Composite transactions remain unmerged.
Mixed rewrite/repack runs commit two versions. If repacking fails, refresh and replan; the completed rewrite remains committed.
Please mark this PR with the breaking-change label.
What's wrong today
Each
add_columnsbackfill gives every fragment one more data file. Scans and random reads slow down as the count grows, andcompact_filesnever fixes it: its candidates are fragments with too many deletions or overlays, or too few rows, so a large, clean fragment split across many files is never picked. This is the "horizontal compaction" gap from #4286 and #3995.What this adds
compact_filesgets a second kind of task. Besides rewriting fragments into new fragments, a task can repack the columns of one fragment into fewer data files without moving its rows, so the fragment id, row addresses, deletions, overlays and index coverage don't change.The default planner plans fragment rewrites exactly as before, then looks at every fragment no rewrite took and plans a repack for it when either:
max_data_files_per_fragmentfiles, asDataset::column_layout_statscounts them (defaultNone, so nothing changes unless it is set), orcolumn_groups.With
column_groups, a group that no single file holds exactly is moved into a file of its own. When the fragment is also over the limit, the files holding only columns no group names are merged into one file.A merge only takes files it empties, two or more at a time, so it always lowers the file count. The planner leaves a file alone if it holds a blob column, a column only partly present in the fragment, or the fragment's spilled row lineage, and also if it shares a column with the lineage file. A V2.0 file can keep a struct's header after the struct's last child in that file was dropped, and such a file is merged only together with the struct. Without groups, when every file could go, one of them stays (the largest, if some merge leaves it in place) unless the limit is 1.
scopelimits a run to one kind of task:RewriteFragments,RepackColumns, orAll(the default).max_data_files_per_fragmentandcolumn_groupscan also be set as table config (lance.compaction.max_data_files_per_fragment,lance.compaction.column_groups).scopeis a per-run option only. Repacks aren't planned underForceBinaryCopy, since they reencode.column_layout_statsreports, for each fragment, how many data files hold a schema column, each file's recorded size and number of schema fields, the share of field slots that hold no live data (tombstoned or left by a dropped column), and how many overlays it has. It reads only manifest metadata. The planner's file-count trigger uses it, and callers can read it to see why a fragment was or wasn't picked:dataset.stats.column_layout_stats()in Python,Dataset.getColumnLayoutStatistics()in Java.The compaction section of the user guide documents the new options and config keys.
How the tasks run and commit
Tasks run through a new object-safe
CompactionExecutortrait.DefaultCompactionExecutorruns both kinds.compact_files_with_executor(dataset, remap_options, planner, executor)lets a caller pass its own executor (for example, one that runs tasks on a cluster) alongside the existingCompactionPlanner.compact_files_with_plannerandCompactionTask::executeuse the default executor.A repack task reads only the base values of the columns it moves. Overlays are left off the read on purpose, so they keep shadowing the new files the same way they shadowed the old ones. It writes one new file per planned file, lined up with the fragment's physical rows, and deletes what it wrote if a later file fails.
commit_compactioncommits fragment rewrites as oneOperation::Rewrite, as before, then commits repacks as oneOperation::DataReplacement { data_change: false }. A run with both kinds therefore makes two versions. The default planner never puts one fragment in both kinds of task, andcommit_compactionrejects results that do before either commit, so the second commit doesn't conflict with the first. If the second commit fails, the first stays committed and the repacks can be planned again.Why
DataReplacementwithdata_change: falseRepacking moves values without changing them.
DataReplacementalready means "these fragments get new files for these fields", and the composite transactions draft (#8644,AddDataFile) uses adata_changeflag for the same idea. Withdata_change: false:DataReplacementis applied to the fragment as it stands at commit, not to a post-image built at read time. If a concurrent commit changes the fragment's other columns, deletes some of its rows, or drops a column the repack leaves alone, that change is kept and the repack still commits. A concurrent update or replacement of a moved column, a rewrite of the fragment, a delete of the whole fragment, or adrop_columnsof a moved column makes the repack retry. Withdata_change: false, the last two are retryable, while adata_change: truereplacement treats them as incompatible.To carry a repack,
DataReplacementnow accepts several groups for one fragment (one per new file), applied in order. The check that every new file in one operation has the same fields is gone.data_changeis anoptional boolin the protobuf. A transaction written before it existed reads astrue, which is what it meant.What changes for existing callers
TaskDatagainskindandRewriteResultgainsrepacked_files, both#[serde(default)]. A plan or result serialized by an older release reads as a fragment rewrite.TaskDataandRewriteResultnow declare theserialVersionUIDtheir current shape had, so streams from older workers still deserialize. Java and Python expose the three options, the task kind (TaskData.getRepackFiles(),CompactionTask.kind), andDataReplacement.dataChange.try_harvest_seedsopens the data file holding the indexed field instead of the fragment's first file. Undercolumn_groupsan indexed column can sit in a later file, and after a repack the old file only holds a tombstone for it. Single-file fragments read the same file as before.column_groupsname that isn't a top-level column is skipped with a warning instead of failing the run. A persisted group can outlive the column it names, sincedrop_columnsand renames leave the config as it is.max_source_bytescounts only the files a repack task reads, which are the files holding the columns it moves, with no overlays.Known limits
column_groups,max_bytes_per_fileis ignored so every group's files split at the same rows.Tests
repack_collapses_backfilled_filesrepack_keeps_the_biggest_file,repack_follows_column_groupsrepack_keeps_deletions,repack_drops_file_left_with_only_dropped_columnsrepack_keeps_scalar_index,repack_columns_preserves_vector_index,repack_columns_preserves_data_overlaycompaction_rewrites_and_repacks_in_one_run,compaction_scope_selects_task_kindsRewritethenDataReplacementand keeps the two task kinds on separate fragments;scopeselects one kindrepack_tasks_run_distributed,task_data_without_kind_rewrites_fragmentskindtask reads as a rewriterepack_against_concurrent_delete,repack_against_concurrent_drop,repack_against_concurrent_update_of_moved_columnrepack_merges_unclaimed_columns_over_the_limitrepack_keeps_the_spilled_lineage_file,repack_merges_around_a_blob_column,repack_merges_around_a_blob_outside_the_kept_file,repack_never_adds_a_file,repack_skips_legacy_filesrepack_ignores_struct_header_without_children,repack_moves_a_struct_with_its_header,repack_merges_only_files_it_empties,repack_keeps_the_largest_file_that_shares_a_struct,repack_keeps_the_largest_file_next_to_a_header_only_file,repack_prefers_leaving_the_largest_file_in_place,repack_plans_nothing_for_header_only_files,repack_with_a_dropped_struct_child_convergesrepack_is_not_planned_under_force_binary_copy,repack_counts_only_the_files_it_reads_against_the_byte_budget,commit_rejects_a_fragment_in_both_kinds_of_taskmax_source_bytesand mixed-result guardstest_conflicts_data_replacement(new cases),test_data_replacement_data_change_roundtrips,test_data_replacement_without_data_change_keeps_overlays,test_data_replacement_applies_several_groups_to_one_fragment,prepare_indices_keeps_coverage_for_moved_valuesDataReplacementchanges on their owncolumn_layout_stats_counts_files_per_fragment,column_layout_stats_counts_dropped_columns_as_dead_slots,column_layout_stats_skips_files_without_schema_columnstest_compact_files_repacks_columns,test_distributed_repack(Python),testRepackColumns(Java)