feat(format): store large row id sequences in a hidden data file column - #8953
Draft
wjones127 wants to merge 2 commits into
Draft
feat(format): store large row id sequences in a hidden data file column#8953wjones127 wants to merge 2 commits into
wjones127 wants to merge 2 commits into
Conversation
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 lance-format#8931 Co-Authored-By: Claude Opus 5 (1M context) <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 lance-format#8931 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Draft prototype of §5.3 of the Stable Row IDs design, "Row-ID sequences as a hidden column".
A fragment's row id sequence is run-encoded, so an appended fragment costs about 20 bytes of manifest and never needs to leave it. A fragment assembled from many places has no runs to exploit and falls back toward a byte per row per id. Inline, that cost is rewritten into every manifest version, so the manifest grows with the table and every commit rewrites all of it.
This adds a third arm to the
row_id_sequenceoneof:a hidden
uint64column at the reserved field id-3, one row id per physical row in offset order. It is located by the samefields/column_indicespair as a user column and read back with the ordinary data file reader, so it inherits the file format's encodings and page layout rather than introducing a new file type. The pre-existingexternal_row_idsarm — an opaque byte range, never written by any release — is left untouched for compatibility.Scope
Compaction is the only write path.
assign_row_idsruns inlance-tablewith no object store, so it cannot spill, and an appended fragment's sequence is aRangethat is nowhere near the threshold.CompactionOptions::inline_row_ids_max_bytessets the budget, defaulting to the 200 KiB inline limit the format has always documented.The spilled file is reached through
Fragment::referenced_lance_files, which is what cleanup, transaction validation, file listing, and shallow-clonebase_idrewriting all already walk — so those paths pick it up without changes of their own.cleanup_keeps_a_live_spilled_filecovers the failure mode with teeth: if the file were not reachable from there, an ordinary cleanup would delete a live file and leave the fragment claiming row ids it can no longer read.Gating
FLAG_UNSTABLE_SPILLED_ROW_IDSis bit 9 (value 512), deliberately aboveFLAG_UNKNOWN. Every released build therefore already refuses such a dataset with no change of its own, and this build understands the bit only in debug builds or withLANCE_ENABLE_UNSTABLE_SPILLED_ROW_IDS=1.Benchmarks
LANCE_ENABLE_UNSTABLE_SPILLED_ROW_IDS=1 cargo bench --bench rowid_spill— 8 fragments × 1,000,000 rows, in two workloads that bracket what the run encoding can do with a sequence. Within each workload the two arms differ only ininline_row_ids_max_bytes: the inline arm never spills (today's behaviour), the spilled arm takes the 200 KiB default.deleted— 30% of rows deleted, then compacted. The deletions become holes but the surviving ids are still ascending, so each sequence encodes as a range plus a bitmap: 0.18 B/row.shuffled— every row rewritten in random order, then compacted. No run structure survives, so the encoding degrades toU64Segment::Array, a bitpacked array of absolute values: 4.00 B/row, 22x the deleted case. This is the worst case for inline sequences, and the shape a reclustering pass would leave behind.The shuffle is not a synthetic manifest edit: the benchmark reads every row with its stable row id, permutes them, writes the data back out, and commits an
Operation::Rewritecarrying the permuted sequences on the new fragments — which is how a reclustering pass would have to preserve row ids. Both arms then compact that state. Atakeby row id is checked against the value the row should hold, so a spilled sequence that resolved to the wrong row would fail the benchmark rather than just look fast.Local NVMe. The byte rows are deterministic and reproduced exactly across runs. The latency rows move between runs, so a difference under about 2x should be read as noise; every conclusion below either rests on a byte count or on a gap far wider than that.
What the two cases have in common
Inline sequences are written two or three times per commit, not once. A commit puts the fragment list in the manifest and writes the whole transaction to its own file under
_transactions/. When that transaction serializes underMAX_INLINE_TRANSACTION_BYTES(20 MiB) it is also copied into the manifest. That is visible in both tables: indeletedthe manifest delta is 1.91M for 0.95M of row ids, exactly 2x, because the 2.86M transaction fits under the cap; inshuffledthe 61.04M transaction does not fit, so the manifest holds the sequences once and the transaction file holds them again.The read cost, once the row id index exists, is not a per-query tax.
take by row id (index built)is the control row and never favours either arm consistently (0.78 vs 0.59 ms, 1.18 vs 0.88 ms). What spilling adds is a one-time index build per dataset open.deleted: a modest manifest win, paid for on the read sideThe manifest shrinks by a third (5.73M → 3.82M) and the transaction file by the same 0.95M. But the bitmap encoding is extremely good here — 0.18 B/row against 2.76 B/row for the general-purpose column encoding — so spilling costs ~15 MiB on disk to save ~1 MiB of manifest, and the dataset-wide row id index build goes from 0.39 ms to 26.47 ms, roughly 70x. Compaction is ~13% slower. Cold open and commit latency are inside the noise band at this size, and a 5.73 MiB manifest decodes in well under a millisecond on a local filesystem either way.
On this workload the design is a bad trade on raw bytes, and pays off only because the manifest is rewritten on every commit while the data file is written once.
shuffled: the case the design is actually forEvery cost above either inverts or disappears once the inline encoding has nothing to exploit:
deletedcase was too small to show.deletedis an artifact of the bitmap encoding being nearly free, not a property of spilling.Two caveats on the
shuffledtable. Its row-version rows are zero in both arms because the benchmark's rewrite leaves version metadata unset and the compaction has none to carry forward, so that manifest is row-ids-only; with version tracking active the spilled arm's manifest would not be empty. And the 30.52M transaction file in the spilled arm is the old fragments' inline sequences, which the setup rewrite created — the new fragments contribute only file references.Known gaps
_row_created_at_version(-4) and_row_last_updated_at_version(-5). In thedeletedcase those are 1.91 MiB, larger than the 0.95 MiB of row ids this change moves, so on that workload spilling row ids alone removes only part of the manifest bloat.inline_row_ids_max_bytesis onCompactionOptions. It belongs in table config at GA; a per-call compaction option is the smallest thing that let the two arms be measured.lance-tableneed the sequence and cannot do IO to get it:refresh_row_latest_update_meta_for_partial_frag_rewrite_colshad atodo!()there and now returnsError::NotSupportedrather than panicking;resolve_update_version_metadatalogs a warning and uses default versions;refresh_row_latest_update_meta_for_full_frag_rewrite_colstreats the fragment as empty and writes no metadata at all. Only the last is silent, and all three want the read to become async.RowIdSequenceneeds every id, so the read path allocates oneu64per row._rowidin the fragment's main data file would remove one file open per fragment from the read path._rowidcolumn is not attempted. It would narrow the 2.76 B/row the column costs on sorted-with-holes sequences, which is what makes thedeletedcase a loss on disk.CompactionOptionspushed a future indataset_schema_evolution.rspast the workspace'slarge_futuresdeny, so two call sites there are nowBox::pin-ed. Included because it is a direct consequence, not a drive-by.Refs #8931