Conversation
BubbleCal
force-pushed
the
yang/oss-2269-row-lineage-spill-column
branch
from
September 16, 2026 07:58
43c6966 to
3d4d2dc
Compare
A fragment's row id sequence is run-encoded, so an appended fragment costs about 20 bytes of manifest. A fragment assembled from many places, such as the output of compacting a table that has had rows deleted, has no runs to exploit and falls back toward 8 bytes per row. Inline, that cost is rewritten into every manifest version, so the manifest grows with the table and every commit rewrites all of it. Add a third arm to the `row_id_sequence` oneof, `DataFile column_row_ids = 12`: a hidden uint64 column at the reserved field id -3, one row id per physical row in offset order, located by the same `fields`/`column_indices` pair as a user column and read with the ordinary data file reader. Compaction is the only write path, since `assign_row_ids` has no object store and an appended sequence is a range that never approaches the threshold. `CompactionOptions::inline_row_ids_max_bytes` sets the budget, defaulting to the 200 KiB inline limit the format already documents. The spilled file is reached through `Fragment::referenced_lance_files`, so cleanup, transaction validation, file listing and shallow-clone base_id rewriting pick it up unchanged. `FLAG_UNSTABLE_SPILLED_ROW_IDS` is bit 9, deliberately above `FLAG_UNKNOWN`, so every released build already refuses such a dataset without a change of its own. This build understands the bit only in debug builds or with `LANCE_ENABLE_UNSTABLE_SPILLED_ROW_IDS=1`. `refresh_row_latest_update_meta_for_partial_frag_rewrite_cols` had a `todo!()` for a sequence held outside the manifest, unreachable while `External` was unused. It now returns `Error::NotSupported` rather than panicking. Adding a field to `CompactionOptions` pushed a future in dataset_schema_evolution.rs past the workspace `large_futures` threshold, so two call sites there are now boxed. Refs #8931 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit ced5863)
Extend the row id spill prototype to all three per-row lineage sequences: row ids (field -3), created-at versions (-4) and last-updated-at versions (-5) each gain a DataFile arm on their DataFragment oneof, reserve every negative field id for system columns, and share one lineage file per fragment when compaction spills them. Placement follows when a value is known: sequences the commit assigns stay inline where a conflict retry can rewrite them; sequences carried over from existing rows may go to a data file. Compaction is the only writer, opted in per table through lance.row_lineage.spill. The feature flag takes bit 11, above FLAG_UNKNOWN like the tagged FRI flag, so every released build refuses such a dataset. Readers skip negative ids in files[].fields so the columns may later live in the fragment's main data file. Version sequences are loaded asynchronously alongside the row ids and cached when spilled. Building a manifest cannot read a data file, so the commit path reads every spilled sequence of the current manifest ahead of each build attempt and hands them over in ManifestBuildConfig; updates, merge inserts and partial column rewrites on a spilled table keep every row's lineage the way they do on an inline one. A new fragment committed with spilled row ids keeps the created-at versions its writer placed and gets its last-updated-at stamped by the commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existing case deletes rows and compacts, which leaves the surviving ids ascending, so each sequence still encodes as a range plus a bitmap at 0.18 B/row. That is the best case for keeping sequences in the manifest, and it makes spilling look like a bad trade: 70x slower row id index build, ~15 MiB more on disk to save ~1 MiB of manifest. Add a second case that rewrites every row in a random order, which is what a reclustering pass leaves behind. The encoding degrades to `U64Segment::Array` at 4.00 B/row and the trade reverses: the manifest stops carrying row ids (30.52M to zero), cold open goes 17.17ms to 0.16ms, a small append commits 20x faster, compaction is 12% faster spilled, the column costs fewer bytes per row than the inline encoding (2.89 vs 4.00 B/row), and the index build difference falls to 9%. The rewrite is a real `Operation::Rewrite` carrying permuted sequences on the new fragments, the way such a pass would have to preserve row ids, and a `take` by row id is now checked against the value the row should hold. Also measure the per-commit transaction file. A commit always writes the whole transaction under `_transactions/` as well as putting the fragment list in the manifest, and copies it into the manifest too when it serializes under `MAX_INLINE_TRANSACTION_BYTES`, so an inline sequence is written two or three times per commit rather than once. Refs #8931 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 5ede414)
BubbleCal
force-pushed
the
yang/oss-2269-row-lineage-spill-column
branch
from
September 16, 2026 08:37
3d4d2dc to
e952b51
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
§5.3 of the Stable Row ID GA design (#8931, tracked as #9250): a fragment's row lineage sequences -- its row ids and its created-at and last-updated-at versions -- can leave the manifest and live as hidden
uint64columns of a Lance data file.Builds on the prototype in #8953 (kept as its own commit, with authorship) and extends it to all three sequences, following the design write-up agreed on 2026-09-16.
Problem
Each sequence is stored inline in the fragment's manifest entry. An appended fragment's sequences are single runs and cost a few dozen bytes, but once compaction merges fragments whose rows came from many places the row id sequence degrades to 4-8 bytes per row and the version sequences to a run per row. The manifest then grows with the table's row count and every commit rewrites all of it (#8621).
Format
DataFragmentgains aDataFilearm on each of its three lineage oneofs:column_row_ids = 12,column_last_updated_at_versions = 13,column_created_at_versions = 14. The column is located by the file'sfields/column_indicespair like a user column, at the reserved field ids-3(_rowid),-4(_row_created_at_version) and-5(_row_last_updated_at_version).files[].fields. This is what lets a later writer put the lineage columns in the fragment's main data file instead of a separate one; this PR writes the separate-file form only.FLAG_UNSTABLE_SPILLED_ROW_LINEAGE = 1 << 11(bit 9 is reserved and bit 10 is the tagged FRI flag from feat(format): define a unified tagged fragment reuse history #9136), aboveFLAG_UNKNOWNthe way that flag is. Every released build (v11.0.0 boundary 256, v12/v13 pre-releases 512) already refuses such a dataset. Like data overlay files, release builds understand the bit only withLANCE_ENABLE_UNSTABLE_SPILLED_ROW_LINEAGE=1; debug builds always do.external_*arms are untouched:external_row_idsstays readable, the version arms stay a valid encoding nothing reads, and none is written.Placement rule
Where a sequence may live follows from when its values are known. A value the commit assigns -- an appended fragment's row ids, an inserted row's created-at, every row's last-updated-at -- can change when a commit conflict is retried, so it stays inline where the retry can rewrite it. A value carried over from existing rows is fixed before the commit and may go to a data file. Compaction is the only such writer here.
Behavior
load_row_id_sequencegains aColumnarm; a newload_row_version_sequenceloads either version sequence wherever it is stored, with spilled ones cached per fragment and file.FileFragment::opennow loads the version sequences asynchronously alongside the row ids instead of decoding them synchronously in the reader builder. Cleanup, shallow-clonebase_idrewriting, file listing andDataset::validatereach the spilled file throughFragment::referenced_lance_files.lance.row_lineage.spill=true, withlance.row_lineage.inline_max_bytesoverriding the 200 KiB default. A table that never sets it is unchanged.lance-tablethat read existing lineage (resolve_update_version_metadatafor row rewrites,refresh_row_latest_update_meta_for_partial_frag_rewrite_colsfor partial column rewrites) cannot read a data file. The commit path inlancenow reads every spilled sequence of the current manifest ahead of each build attempt (load_spilled_row_lineage, cached per fragment and file) and hands them over inManifestBuildConfig::spilled_row_lineage, soUpdateBuilder,merge_insertin both write modes, and externally assembledOperation::Updates keep every row's lineage on a spilled table exactly as on an inline one. A build that needs a spilled sequence it was not given returnsNotSupportedinstead of the prototype's warn-and-default, which silently rewrote lineage.RowDatasetVersionMeta::load_sequencereturnsNotSupportedfor the external and column arms instead oftodo!().Known gaps
place_row_lineageand commits it with created-at placed, as the contract above allows.Schema::validaterejects negative ids) and is a follow-up.FileFragment::openstill loads the row id sequence eagerly on stable-row-id tables; with a spilled sequence that is one file read per fragment per process before the cache is warm.Benchmarks
LANCE_ENABLE_UNSTABLE_SPILLED_ROW_LINEAGE=1 cargo bench --bench rowid_spill, the benchmark from #8953 extended to this branch: 8 fragments x 1,000,000 rows, local NVMe (macOS), one run per arm, measured on this branch just before its rebase onto #9136 (the rebase only moved the feature flag bit and touched docs). The two arms differ only in the table config: the inline arm never opts in (today's behavior), the spilled arm setslance.row_lineage.spill=truewith the default 200 KiB budget. Byte counts are deterministic; latencies are single samples (cold open averaged over 10, sequence loads over 3), so treat differences under about 2x as noise. Lower is better for every row.deleted: 30% of rows deleted, then compacted. The row id sequences still run-encode as range plus bitmap, so this is the workload where spilling is a bad trade on bytes.
shuffled: every row rewritten in random order, then compacted. No run structure survives, which is the workload the design is for.
The 30.52 MiB transaction file in the spilled arm is the old fragments' inline sequences that the setup rewrite created; the new fragments contribute only file references. The version sequences are zero in both arms because the benchmark's rewrite leaves version metadata unset.
The
deletedtake row:FileFragment::openloads the fragment's row id sequence whenever the table uses stable row ids, whether or not_rowidis projected, and the index build reads sequences uncached, so the first take after it pays one spilled-file read per fragment. That eager load predates this PR and is nearly free for inline sequences; making it conditional on the projection is a follow-up.Validation
cargo test -p lance-tablecargo test -p lance --lib -- rowid row_version stable_row optimize::tests dataset_transactions fragment::tests cleanup::tests feature_flag update::tests merge_insert::tests dataset_io mem_walcargo clippy --all --tests --benches -- -D warnings,cargo fmt --allcargo check --manifest-path python/Cargo.toml,cargo check --manifest-path java/lance-jni/Cargo.tomlRefs #8931, closes #9250
🤖 Generated with Claude Code