feat(core,index): fragment reuse row map for reordered rewrites - #8972
Open
LuQQiu wants to merge 4 commits into
Open
feat(core,index): fragment reuse row map for reordered rewrites#8972LuQQiu wants to merge 4 commits into
LuQQiu wants to merge 4 commits into
Conversation
A reordered rewrite (reclustering) distributes live rows of n source fragments across m destination fragments in scan order, so unlike compaction the destination of a row cannot be derived from row order. This adds the standalone format capability that records and replays that mapping, with no transaction or read-path integration yet: - lance-core/utils/stable_partition: pure translation arithmetic. CountsMatrix stores cumulative per-destination row counts at every 64K-row block boundary; a point lookup is counts base + label rank in one block, a sweep is counter[label]++ per row seeded from any block boundary. Encode/decode for the on-disk form plus content validation. - lance-index/frag_reuse/row_map: the row map file. One Lance file with a single nullable u16 label column (one row per physical source row, NULL = deleted at source) and the encoded counts in a global buffer, so open costs one tail read. RowMapWriter interleaves NULLs from the source deletion vectors while the caller streams live-row labels; RowMapReader offers point, coalesced-batch and sweep translation. - benches/stable_partition_row_map: encoded size and translation costs. 2M rows / 1000 destinations on V2_1: 11.15 bits/row uniform-random labels (worst case, nominal 10) and 6.27 bits/row with 16-destination block locality; sweep ~170M rows/s, full-block label rank 3.8us. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups on the stable-partition row map: - The counts header now carries a representation tag. Only the dense grid is written (exact, data-independent size: ~600KB for a 50M-row rewrite across 500 destinations, ~61MB at 1B rows across 1000 — trivial beside the rewrite either way); unknown tags are rejected with a clear error, so sparser encodings can be added later without breaking readers. - RowMapReader::open() now fails loudly on a bad file instead of translating rows to wrong addresses or panicking: it checks the label column's schema, decodes with exact structural checks (magic, version, supported representation, shape, precise payload length), runs the full counts consistency validation, and reconciles label row count against the counts. Batch column casts return errors instead of panicking. - Documented the stable-partition ordering contract the arithmetic rests on (labels in source physical-row order, destinations filled in that same order and never re-sorted, destination list fixed), mirroring the Ordering section of row_addr_remap.rs. - translate_many now subtracts block starts in u64 like translate. - Replaced three copies of a hand-rolled LCG with seeded StdRng; rand is already a workspace dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Deduplicate the ordering-contract paragraph in the module docs and drop a redundant explicit rustdoc link. - open() enforces the full schema contract it claims: exactly one column, named label, u16, nullable. - Document the remaining public writer/reader methods. - Deterministic NULL edge-case test: fully-deleted source, zero-row source, deleted tail drained by finish(), empty translate_many and sweep inputs. - State that sweep's one-block-at-a-time IO is intentional (bounded memory); prefetch belongs to read integration. - Fix the 50M x 500 counts size in docs (1.5 MB, not 600 KB) and allow the size-probe printlns in the bench. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LuQQiu
marked this pull request as ready for review
September 3, 2026 21:04
Review follow-up: make explicit that the row map represents stable partitions only. A rewrite that sorts rows within a destination cannot be expressed by destination labels (equal labels would rank in source order, not output order) and would need a per-row final-offset encoding as a separate format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
This revision now makes the intended stable-partition-only contract explicit. Arbitrary per-destination permutations remain unsupported by the accepted scope and require a separate encoding; no further change is requested for that limitation.
Batch lookup still expands every touched 64K block and repeats prefix scans, so follow-up integrations should use the linear sweep for dense ranges and bound sparse batches to keep decoded memory predictable.
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.
Stack
What
Part 1 of adding first-class support for reordered rewrites to the fragment reuse machinery. A reordered rewrite (e.g. reclustering rows by a sort or clustering key) reads
nsource fragments in scan order and distributes their live rows acrossmdestination fragments. Compaction's existing remapping (CompactRowAddrRemap) derives the old-to-new address mapping from row order alone, which only works because compaction preserves relative row order; a reordered rewrite breaks that assumption, so the mapping must be recorded explicitly.This PR adds the standalone format capability that records and replays that mapping. It has no transaction or read-path integration yet (that comes in follow-up PRs); everything here is a self-contained module with its own tests and benchmarks.
Design
What is recorded
A reordered rewrite is a stable partition: it scans the source fragments in order and appends each live row to exactly one destination fragment, so within every destination, rows keep their relative source order. As the rewrite job scans, it makes one routing decision per row — which destination does this row go to — and that decision is the only information the mapping needs, because the row's offset inside its destination is derivable (see below). The recorded decision is called the row's label.
Worked example used throughout. Sources
F1(5 physical rows, row 2 deleted) thenF2(3 rows); the rewrite produced two destinations, listed in order as[F10, F11]. The job's routing decisions, in scan order:A label is the destination's index in the ordered destination list (0 = F10, 1 = F11), not the fragment id itself: u16 indices cover up to 65,536 destinations per rewrite, and the small id list is stored once elsewhere.
The label column is that right-hand column persisted as one Lance file: a single nullable u16 column, one row per physical source row (deleted rows included), in concatenated scan order. With an illustrative block size of 4 rows (real block size: 64K):
Reading the labels left to right and counting per destination replays the whole rewrite: F10 received source rows 0, 3, 4, 6 (as its rows 0-3), F11 received rows 1, 5, 7.
The counts, in the same file's global buffer. Translating one row must not require replaying the file from the start. So for every 64K-row block boundary the file stores, per destination, the cumulative "rows so far" count. For the example (block = 4 rows):
This is a dense
num_blocks x mgrid of u32s — exact and data-independent: ~1.5 MB for a 50M-row rewrite across 500 destinations, ~61 MB at a 1B-row rewrite across 1000, read once at open. The encoded header names the representation, so sparser encodings (e.g. per-destination postings for strongly local redistributions) can be added later without breaking readers; unknown tags fail with a clear error. Deleted counts are implied (block length − sum of the block's deltas), and the final row doubles as the per-destination totals for conservation checks.The arithmetic rests on an explicit ordering contract (documented in the module): labels are recorded in source physical-row order, each destination receives its rows in that same order and is never re-sorted, and the destination list is fixed for the whole rewrite. A rewrite that routes rows through parallel writers must restore per-destination source order before recording the mapping.
That contract is deliberate scope: this format represents stable partitions only. A rewrite that sorts rows within a destination is not representable by destination labels (two rows with equal labels rank in source order, not output order); such a rewrite needs a per-row final-offset (permutation) encoding, which would be a separate format rather than a relaxation of this one.
How it is read
Open = one tail read (file footer + the counts global buffer) plus an in-memory consistency check of the counts, so a corrupt buffer fails at open instead of mistranslating rows. No label IO.
Point lookup ("where did source row
ggo?") reads one block:block = g / 64K,pos = g mod 64K; read that one block of labels.posis NULL → the row was deleted, done.destination offset = counts[block-1][label] + (labels equal to it in this block before pos).Example: source row 6 lands in block 1 at position 2 with label 0 (= F10). Base = counts after block 0 for F10 = 2. Within block 1, one earlier row (row 4) is labeled F10. So row 6 is
(F10, row 3)— matching the replay above. The in-block count scans at most 64K u16s (3.8 µs measured); the block read is one aligned range read.Sweep ("translate everything from here on") seeds per-destination counters from a block boundary (
counters = counts[block-1], zeros for block 0), then for each row:offset = counter[label]++. O(1) per row, no rank computation — used for bulk translation, and it works from any block boundary, not just the file start (~170M rows/s measured).Where the code lives
lance-core/src/utils/stable_partition.rs— the arithmetic above, no IO:CountsMatrix(+ builder, codec,validate()),translate_in_block,SweepTranslator. Sibling ofrow_addr_remap.rs, which is the order-preserving (compaction) counterpart.lance-index/src/frag_reuse/row_map.rs— the file:RowMapWriter/RowMapReaderon the existingIndexStore/IndexWriter/IndexReadertraits, no new IO plumbing. The writer takes labels for live rows only (a rewrite job scans with deletions applied, so it never sees a deleted row) and interleaves the NULLs itself from the source deletion vectors. The reader offers point, coalesced-batch (read_ranges) and sweep translation.Counts blocks are logical 64K-row blocks addressed by row-number range reads, deliberately decoupled from physical page boundaries: correctness never depends on how the encoder cut pages.
Benchmarks
2M rows, 1000 destinations, ~1/8 deleted, local FS (
benches/stable_partition_row_map.rs):Uniform-random is the number to budget with: a first-pass reclustering exists precisely because arrival order does not correlate with the clustering key, so a 64K-row source block scatters across all destinations and page dictionaries cannot beat the nominal width (11.15 = 10 bits + validity + page overhead; ~1.4 GB per billion source rows). The locality row is the upside case — time-correlated clustering keys, or incremental re-clustering of mostly-sorted data — where per-page dictionaries kick in. Format version matters either way: the 2.1 miniblock path is what reaches these numbers at all (2.0 stores plain u16, ~17 bits/row).
Tests
validate()rejects non-monotone counts and over-budget blocks.RowMapReader::openenforces the schema contract (exactly one column, namedlabel, u16, nullable), runs the full counts consistency check, and reconciles the label row count, so a corrupt or foreign file errors instead of panicking or silently mistranslating.finish(), and emptytranslate_many/sweepinputs.cargo test -p lance-core -p lance-index: 1556 passed, 0 failed. Clippy clean for the new code (--no-deps; the two pre-existing nightly-drift lints inlance-encodingandinverted/cross_column.rsare untouched).Follow-ups (separate PRs)
{sources, destinations, row_map}toOperation::Rewrite, conservation validation at commit, legalize deferred index remap for reordered groups.RowIdRemapperseams.🤖 Generated with Claude Code