Repository navigation
feat(compaction): repack a fragment's columns as a compact_files task - #9604
LuciferYang wants to merge 84 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. |
Per-fragment count of data files still holding a live field, plus overlay count, read from fragment metadata only. This is the planning input for horizontal compaction and keeps the decision observable.
|
@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).
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.
|
@wjones127 Thank you for your careful review. I have some other things to take care of today, and I will revise the corresponding issues tomorrow. |
…paction # Conflicts: # java/lance-jni/src/optimize.rs # rust/lance-table/src/transaction/manifest_build.rs # rust/lance/src/dataset/optimize.rs # rust/lance/src/index.rs # rust/lance/src/io/exec/fts.rs # rust/lance/src/utils/test.rs
…onstructor and output-property check
…ete written files when the layout is refused
…ore the replace_files version and V1 rules
| // Read the base values only: the overlays stay on the fragment and keep | ||
| // shadowing them, so every live row reads as it did. | ||
| let mut base = fragment.clone(); | ||
| base.overlays.clear(); |
There was a problem hiding this comment.
This repack changes soft-deleted rows' stored values while declaring data_change=false. The source retains its deletion file, so updater_with_version routes those positions through DeletionRestorer, which writes blanks or copies of live rows. The merged contract requires the original base values for every physical row, and include_deleted_rows exposes those values. The current repack_keeps_deletions assertion checks only the live scan, so it misses this contract violation.
There was a problem hiding this comment.
Good catch. Fixed in 67d4504: the repack now reads the fragment with its deletion file cleared, as it already did with the overlays, so it copies the base values of every physical row, and the deletion file keeps masking the deleted ones. repack_keeps_deletions now also compares an include_deleted_rows scan before and after the repack; it fails without the fix.
…paction # Conflicts: # rust/lance/src/dataset/optimize.rs
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The deleted-row issue is fixed: repacks now preserve every physical row's base values while retaining the fragment's deletion metadata. File-count repacking through compact_files addresses backfill-created file fan-out, with the shared replacement rule keeping planning and commit aligned.
Rewrites and repacks commit separately: if repacking fails, the rewrite remains committed. Replan instead of resubmitting those results. The overlapping-value protections continue to reject stale repacks that would overwrite newer values.
Broader grouping across write operations remains in #10509. Prefer merging layout statistics #9605 first, then rebasing this PR to remove the stacked statistics changes.
Please mark this PR with the breaking-change label: Rust CompactionOptions, TaskData, and RewriteResult in rust/lance/src/dataset/optimize.rs gain fields, and Operation::DataReplacement in rust/lance-table/src/transaction/operation.rs gains data_change, so downstream Rust callers must update struct literals and exhaustive patterns.
The
data_changefield onDataReplacementand its spec text landed in #9705. This PR makes the Rust transaction code act on it.Dataset::column_layout_statsand its Python and Java bindings are #9605, which this branch merges. Once #9605 merges, those files drop out of this diff.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, for every fragment no rewrite took, it plans a repack when the fragment holds columns in more than
max_data_files_per_fragmentfiles, asDataset::column_layout_statscounts them. The option defaults toNone, so nothing changes unless it is set.A repack merges whole files, and only files the commit will drop, at least two at a time, so the file count always goes down. A file stays if it holds a blob column, a column only partly in the fragment, or the fragment's spilled row lineage, or shares a column with the lineage file. When every file holding a column could go and the limit is above 1, one of them is kept if a merge can leave it: the merge that leaves the largest file in place wins, then the one that leaves the fewest live files.
scopelimits a run to one kind of task:RewriteFragments,RepackColumns, orAll(the default).max_data_files_per_fragmentcan also be set as table config (lance.compaction.max_data_files_per_fragment);scopeis a per-run option only. Repacks aren't planned underForceBinaryCopy, since they reencode.plan_groupsplans rewrites only.Callers can read the same stats the planner uses 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 options and the config key.How the tasks run and commit
CompactionTask::executeruns a task by its kind: a rewrite task goes throughrewrite_filesas before, a repack task through the new repack code.compact_files_with_plannerruns every task of the plan through it, so a task run in-process and one serialized to a worker take the same path.A repack task copies the base values of the columns it moves for every physical row, deleted rows included; overlays and the deletion file stay on the fragment and keep applying to the new files. It writes one new file per planned file, row-aligned with the fragment, and deletes what it wrote if it fails.
commit_compactioncommits fragment rewrites as oneOperation::Rewrite, as before, then repacks as oneOperation::DataReplacement { data_change: false }in a separate version. Results that put a fragment in both kinds of task are rejected before either commit. If the repack commit fails, the rewrite stays committed and the dataset handle is left at its version; the run should be planned again, not retried with the same results.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. A concurrent change to the fragment's other columns, a delete of some of its rows, or a drop of a column the repack leaves alone 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 a drop of a moved column makes it retry; for the last two, adata_change: truereplacement fails instead. A concurrentadd_columnsalso makes it retry, since itsMergeconflicts with everyDataReplacement.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 theoptional bool#9705 added to the protobuf. It is written only when false, and a transaction written before it existed reads astrue.Where the file-layout rule lives
What a
DataReplacementcommit does to a fragment's data files is nowFragment::replace_filesin lance-table, a pure function: the exact-match swap, appending a file for fields the fragment has no data for, tombstoning and appending, the V1 guard, dropping files left with no live field and no spilled lineage, and the "no changes" error.build_manifestgroups the replacements by fragment and calls it once per fragment; overlay tombstoning and row-version stamping stay there, since they depend ondata_changeand the read version.The repack planner builds a placeholder file for each merge it considers, applies it with
replace_files, and scores the result, so it has no copy of the rule of its own. A repack task'sfiles_removedcomes fromreplace_filestoo.The
TODO(rmeng)row-count check stays a TODO:DataFilehas no row count to check.What changes for existing callers
TaskDatagainskindandRewriteResultgainsrepacked_files, both#[serde(default)]. A plan or result serialized by an older release reads as a fragment rewrite.TaskData::repack_columns(fragment, files)builds a repack task, and a repack task refusesoutput_clustering_idandoutput_metadata, since it writes no new fragment.repacked_files, not the task'skind.TaskDataandRewriteResultnow declare theserialVersionUIDtheir current shape had, so streams from older workers still deserialize. Java and Python exposemax_data_files_per_fragment,scope, the task kind (TaskData.getRepackFiles(),CompactionTask.kind), andDataReplacement.dataChange/data_change.max_source_bytescounts only the files a repack task reads, which are the files holding the columns it moves, with no overlays.Known limits
Tests
repack_collapses_backfilled_filesrepack_keeps_the_biggest_file,repack_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_kinds,commit_rejects_a_fragment_in_both_kinds_of_taskRewritethenDataReplacement;scopeselects one kind; a fragment in both kinds of result is refused before either commitcompaction_plans_no_repack_by_default,repack_is_not_planned_at_the_limitrepack_tasks_run_distributed,task_data_without_kind_rewrites_fragments,rewrite_result_without_repacked_files_is_a_rewrite,repack_task_refuses_output_properties,repack_task_deletes_its_files_when_the_layout_is_refusedrepack_against_concurrent_delete,repack_against_concurrent_drop,repack_against_concurrent_new_value_of_moved_column,repack_against_concurrent_update_of_another_columnrepack_keeps_the_spilled_lineage_file,repack_merges_around_a_blob_column,repack_merges_around_a_blob_outside_the_kept_file,repack_merges_every_movable_file_next_to_one_that_stays,repack_never_adds_a_file,repack_skips_legacy_files,repack_skips_a_fragment_holding_a_legacy_filerepack_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_prefers_the_proposal_leaving_the_fewest_files,repack_plans_nothing_for_header_only_files,repack_merges_all_files_when_none_can_stay,repack_with_a_dropped_struct_child_convergesrepack_moves_a_struct_whose_parent_has_no_file_fieldrepack_options_parse_from_config,compaction_scope_rejects_an_unknown_name,max_data_files_per_fragment_must_be_positiverepack_is_not_planned_under_force_binary_copy,repack_counts_only_the_files_it_reads_against_the_byte_budgetmax_source_bytesrulestest_defer_index_remap_allows_a_repack_only_plan,test_undrained_fri_allows_a_repack_only_plan,test_column_repack_seeds_moved_column,test_harvest_seeds_from_repacked_filedefer_index_remapnor an undrained fragment reuse index refuses a repack-only run, which moves no rows; a repacked zone-map column gets a seed in its new filetest_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 owntest_data_replacement_data_change_round_trips(Python)DataReplacement(data_change=False)survives commit andread_transaction, and onlydata_change=Truewithdraws index coverage of the replaced columntest_compact_files_repacks_columns,test_distributed_repack,test_repack_options_come_from_dataset_config,test_repack_options_override_dataset_config,test_repack_rejects_bad_options,test_repack_keeps_vector_index(Python)test_commit_leaves_dataset_at_the_rewrite_when_the_repack_fails(Python)Compaction.commitleaves the dataset at the rewrite's versiontestRepackColumns,testDeserializeTaskAndResultFromOlderVersion(Java)TaskDataorRewriteResultserialized by the previous release still deserializesCredit
Repacking started from @dshepelev15's #9291: the repack task writes each new file the way #9291's
rewrite_columnsdid, reading the moved columns back through the fragment updater. Itscolumn_groupsoption is left for the follow-up above.Dataset::column_layout_statsstarted in @Xuanwo's #8614. Thanks to both of you.Co-authored-by: dshepelev15 d-shepelev@list.ru
Co-authored-by: Xuanwo github@xuanwo.io