Skip to content

perf(frag-reuse): build the index-open path on RowAddrRemap instead of a per-row HashMap - #38

Closed
rerun-rmack wants to merge 3 commits into
release-8.0.0from
rmack/fri-runs-map
Closed

perf(frag-reuse): build the index-open path on RowAddrRemap instead of a per-row HashMap#38
rerun-rmack wants to merge 3 commits into
release-8.0.0from
rmack/fri-runs-map

Conversation

@rerun-rmack

@rerun-rmack rerun-rmack commented Aug 18, 2026

Copy link
Copy Markdown

Problem

open_frag_reuse_index builds a HashMap<u64, Option<u64>> per reuse version with one entry per remapped row. It runs on every index open and the result is cached, so readers pay it.

Measured on a production payload: 676,592,102 entries. 88 MB on disk becomes 26.8 GB resident, 40.5 GB peak during construction, and takes 144 s to build. That OOMs a 60 GiB pod, which is how we found it.

Fix

Upstream already built the right structure. lance-format#7237 ("introduce RowAddrRemap structure to avoid remap OOM caused by HashMap", merged 2026-07-03) added RowAddrRemap with a Compact rank/select variant that is O(#fragments). It applied it to the scalar-index remap consumers and left the fragment-reuse index-open path on the hashmap, which is the path that OOMs us. That is still true on main and every release branch through v10.0.

So this uses their structure rather than inventing one.

Two commits:

  1. Backport RowAddrRemap from release-9.0.0, byte-identical to origin/release-9.0.0, verified by diff. Reviews as a pure import; diffs clean when we move to 0.16.
  2. Build the open path on it. RowAddrRemap::compact takes exactly what a FragReuseGroup already holds, so the conversion is mechanical.

FragReuseIndex::new still accepts maps and stores them as RowAddrRemap::Direct, so existing callers are unaffected.

What we had to add on top of the backport

Marked as fork-local at the bottom of the backported file so the delta against release-9 stays visible:

  • Debug and DeepSizeOf for RowAddrRemap, plus the num_groups/num_fragments accessors they use. FragReuseIndex needs both; upstream does not yet, precisely because it still holds hashmaps there. They will hit this when they convert the open path.
  • remap_column_index composed a map by enumerating keys, and Compact deliberately cannot enumerate: it stores per-fragment bitmaps and treats any unlisted offset in a rewritten fragment as deleted, so its key set is not finite. That path now rebuilds per-row maps from the details. Still O(rows), but only tests reach it in-tree and nothing in redap does.

Testing

Upstream's 5 module tests pass unmodified. lance-table 107, lance frag_reuse 3, optimize:: 95, remap 30. Clippy and fmt clean.

Open, and why this is still draft

Not yet run against the real 88 MB payload. Two things need measuring there before merge:

  • CompactRowAddrRemap::get reports any offset in a rewritten fragment that is not in the bitmap as deleted, including offsets past physical_rows, where the hashmap reports absent. Semantics differ from today on those edge cases.
  • GroupRemap::new hard-errors if new fragments are not in ascending id order, or if the rewritten-row count does not match the new fragments' total. On the read path that turns a bad committed payload into a permanent query failure. This connects to the open upstream fix(compaction): preserve row order across parallel tasks lance-format/lance#8400 about parallel compaction tasks arriving out of order, and to the fact that the sortedness check at optimize.rs:634 is a debug_assert!.

If a payload we have already committed trips either condition, open_frag_reuse_index fails every query on that dataset, which would be worse than the OOM. Measuring that is the remaining gate.

Superseded

An earlier revision of this PR implemented a run-length structure of our own (RowAddrRunMap). Dropped in favour of upstream's. Thanks to the review that surfaced lance-format#7237.

@zehiko

zehiko commented Aug 18, 2026

Copy link
Copy Markdown
Member

copy pasting just as extra inputs:

 ┌─────┬───────────┬──────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
  │  #  │ Severity  │    Status    │                                                      Finding                                                       │
  ├─────┼───────────┼──────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │     │           │              │ builder.finish()? at index/frag_reuse.rs:89 turns a write-side bug into an unrecoverable read-side failure. That   │
  │ F2  │ HIGH      │ reproduced   │ line is on the query path (open_frag_reuse_index ← every scalar/vector index open). The value is cached; the error │
  │     │ (design)  │              │  is not. A bad committed FragReuseIndexDetails fails every query, every attempt, permanently. Old behaviour:       │
  │     │          │              │ last-write-wins, dataset stays queryable.                                                                           │
  ├─────┼──────────┼──────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │     │          │              │ Unguarded u64 overflow on the mapped side — frag_reuse.rs:275 (iter_keys) and :344-345 (push_mapped). The deleted   │
  │ F1  │ HIGH     │ reproduced   │ side is guarded (inclusive ranges, saturating_add); that symmetry never reached the mapped side. Release-mode: :275 │
  │     │          │ (2 panics)   │  wraps to an empty range and silently drops the key, leaving a stale index address; :344 wraps to 0, coalescing     │
  │     │          │              │ unrelated addresses into one run.                                                                                   │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │       │          │              │ The runs form depends on compaction preserving row order, and upstream has an open PR fixing violations — #8400,  │
  │ A3    │ MEDIUM   │ verified     │ "Concurrently completed tasks could also arrive in a different order." optimize.rs:634's sortedness check is a    │
  │       │          │              │ debug_assert!, i.e. a release no-op. Upstream's Compact hard-errors on the analogous violation.                   │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │       │          │              │ iter_keys() documents "ascending"; body is mapped.chain(deleted). push_mapped(100,900); push_deleted(50);         │
  │ F3    │ MEDIUM   │ reproduced   │ push_deleted(51) → [100, 50, 51]. Harmless today (sole consumer collects into a HashMap); the PR's own test sorts │
  │       │          │              │  before comparing, which hid it.                                                                                  │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │       │          │              │ Our fork's spec page asserts the opposite of what we measured: "does not affect query performance once the index  │
  │ A6    │ MEDIUM   │ verified     │ is cached" (docs/src/format/index/system/frag_reuse.md:45-47), and sizes FRI growth in number of versions, never  │
  │       │          │              │ bytes. A trap for the next operator.                                                                              │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │ F4    │ LOW      │ latent       │ Deserialize populates private runs/deleted without finish()'s validation; get's partition_point assumes both      │
  │       │          │              │ sorted and disjoint, and would answer wrongly with no error. No consumer today.                                   │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │ F5    │ LOW      │ verified     │ stream_row_ids_from_digest duplicates transpose_row_ids_from_digest's stream logic; the "equivalent by            │
  │       │          │              │ construction" claim holds today but nothing enforces it.                                                          │
  ├───────┼──────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │       │          │              │ Drop the row_id_maps→row_addr_maps rename from this PR (breaks public API via the glob, collides with upstream    │
  │ A4/A5 │ LOW      │ —            │ #7237's bq/storage.rs edits); drop unused PartialEq/Eq/serde derives; #[deprecated] on FragReuseIndex::new;       │
  │       │          │              │ replace finish()'s merged vector with a two-pointer merge.                                                        │
  └───────┴──────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

@rerun-rmack rerun-rmack changed the title perf(frag-reuse): store the reuse mapping as runs, not one entry per row perf(frag-reuse): build the index-open path on RowAddrRemap instead of a per-row HashMap Aug 18, 2026
@rerun-rmack
rerun-rmack marked this pull request as ready for review August 18, 2026 12:47
rerun-rmack and others added 3 commits August 18, 2026 09:22
Verbatim copy of `rust/lance-core/src/utils/row_addr_remap.rs` as it exists on
`release-9.0.0`, from upstream lance-format#7237 ("introduce RowAddrRemap
structure to avoid remap OOM caused by HashMap", merged 2026-07-03), plus the
module registration.

No edits, so the file diffs clean against release-9. Nothing uses it yet; the
next commit does.

Upstream applied this to the scalar-index remap consumers but left the
fragment-reuse index-open path on `HashMap<u64, Option<u64>>`, which is the
path that OOMs us. That is still true on main and every release branch through
v10.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`open_frag_reuse_index` built a `HashMap<u64, Option<u64>>` per reuse version
with one entry per remapped row. It runs on every index open and the result is
cached, so readers pay it. Measured on a production payload: 676,592,102
entries, 88 MB on disk becoming 26.8 GB resident and 40.5 GB peak, 144 s to
build. That OOMs a 60 GiB pod.

Builds `RowAddrRemap::Compact` from the same `FragReuseGroup` fields instead,
which is O(#fragments). `GroupInput` takes exactly what a group already holds:
the deserialized `changed_row_addrs`, the old fragment ids, and the new
fragments as (id, physical_rows).

`FragReuseIndex::new` still accepts maps and stores them as
`RowAddrRemap::Direct`, so existing callers keep working unchanged.

Three fork-local additions to the backported module, marked as such at the
bottom of the file so the diff against release-9 stays obvious: `Debug` and
`DeepSizeOf` for `RowAddrRemap` (it is stored in `FragReuseIndex`, which needs
both, and upstream does not yet because it still holds hashmaps there), plus
the `num_groups`/`num_fragments` accessors those impls use.

One consumer had to change shape. `remap_column_index` composed a map across
versions by enumerating keys, and the compact form deliberately cannot
enumerate: it stores per-fragment bitmaps and treats any unlisted offset in a
rewritten fragment as deleted, so its key set is not finite. That path now
rebuilds the per-row maps from the details. Still O(rows), but it is reached
only by tests in-tree and by no caller in redap, whereas the open path above is
on every query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lockfile only, no manifest change; both are patch bumps within the same minor.

  h2   0.4.15 -> 0.4.16   RUSTSEC-2026-0258
  rkyv 0.8.16 -> 0.8.18   RUSTSEC-2026-0233, -0234, -0235

Pre-existing on the branch and unrelated to the change this PR carries. Matches
the same bump on the release-9 branch, where cargo-deny actually runs in CI;
release-8.0.0's `rust.yml` triggers on `main` and `release/**`, which our
branch name does not match, so nothing here is enforced.

Neither is urgent on its own merits. rkyv is in the lockfile but not the
resolved graph (`cargo tree -i rkyv` matches nothing); the only thing that
pulls it is `lindera-dictionary`, behind the optional `tokenizer-lindera`
feature, so we never compile it. The h2 advisory is a denial of service driven
by a peer sending empty DATA frames, and we are an HTTP/2 client to AWS, so the
peer is S3 or DynamoDB behind TLS.

This does not make `cargo deny` green on release-8. Three unrelated advisories
remain, all of which release-9 already handles: RUSTSEC-2026-0194/0195
(quick-xml) are on release-9's deny.toml ignore list but not this branch's, and
RUSTSEC-2026-0204 (crossbeam-epoch 0.9.18) is fixed there by 0.9.20. Left alone
rather than widened into this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rerun-rmack
rerun-rmack marked this pull request as draft August 18, 2026 13:27
@rerun-rmack
rerun-rmack marked this pull request as ready for review August 18, 2026 13:34
@rerun-rmack
rerun-rmack requested a review from zehiko August 18, 2026 22:37
amunra pushed a commit that referenced this pull request Aug 19, 2026
`RowAddrRemap` is now on this branch (upstream lance-format#7237,
backported in #43), but `open_frag_reuse_index` was never converted to it --
not here, and not on upstream main or v10.0 either.

It built a `HashMap<u64, Option<u64>>` per reuse version with one entry per
remapped row, on every index open, and cached the result, so readers pay for
it. On a large production payload that is hundreds of millions of entries and
tens of gigabytes resident, taking minutes to build -- enough to OOM the pod
that opens the index.

Against the same payload, `RowAddrRemap::compact` builds in a fraction of a
second and well under a gigabyte, and agreed with the map on every address
probed. The only differences are offsets past a fragment's `physical_rows`,
where the map reports absent and compact reports deleted; that is compact's
documented behaviour, and those addresses are not rows.

This supersedes the hand-shaped variant in #38. With lance-format#7237 backported, this
branch takes the same patch release-9 does instead of a release-8-specific one.

Three additions to the remap module: `Debug` and `DeepSizeOf` for
`RowAddrRemap`, plus the `num_groups`/`num_fragments` accessors those use.
`FragReuseIndex` derives `Debug` and implements `DeepSizeOf`, and now stores
these, so both are required of them.

`remap_column_index` composed a map by enumerating keys, and the compact form
deliberately cannot enumerate: it stores per-fragment bitmaps and treats any
unlisted offset in a rewritten fragment as deleted, so its key set is not
finite. That path now rebuilds the per-row maps from the details. Still
O(rows), but only tests reach it in-tree and no production caller does. The
follow-up commit removes that rebuild entirely.

Reworded when porting: branch references updated for release-8, specific
payload figures generalised, and phrasing that described this tree relative to
another copy of it removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c9220e)
amunra added a commit that referenced this pull request Aug 20, 2026
…f a per-row HashMap (#44)

* Build the fragment-reuse index open path on RowAddrRemap

`RowAddrRemap` is now on this branch (upstream lance-format#7237,
backported in #43), but `open_frag_reuse_index` was never converted to it --
not here, and not on upstream main or v10.0 either.

It built a `HashMap<u64, Option<u64>>` per reuse version with one entry per
remapped row, on every index open, and cached the result, so readers pay for
it. On a large production payload that is hundreds of millions of entries and
tens of gigabytes resident, taking minutes to build -- enough to OOM the pod
that opens the index.

Against the same payload, `RowAddrRemap::compact` builds in a fraction of a
second and well under a gigabyte, and agreed with the map on every address
probed. The only differences are offsets past a fragment's `physical_rows`,
where the map reports absent and compact reports deleted; that is compact's
documented behaviour, and those addresses are not rows.

This supersedes the hand-shaped variant in #38. With lance-format#7237 backported, this
branch takes the same patch release-9 does instead of a release-8-specific one.

Three additions to the remap module: `Debug` and `DeepSizeOf` for
`RowAddrRemap`, plus the `num_groups`/`num_fragments` accessors those use.
`FragReuseIndex` derives `Debug` and implements `DeepSizeOf`, and now stores
these, so both are required of them.

`remap_column_index` composed a map by enumerating keys, and the compact form
deliberately cannot enumerate: it stores per-fragment bitmaps and treats any
unlisted offset in a rewritten fragment as deleted, so its key set is not
finite. That path now rebuilds the per-row maps from the details. Still
O(rows), but only tests reach it in-tree and no production caller does. The
follow-up commit removes that rebuild entirely.

Reworded when porting: branch references updated for release-8, specific
payload figures generalised, and phrasing that described this tree relative to
another copy of it removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c9220e)

* Write the fragment-reuse remap comments for a general audience

The comments added with the compact index-open path described this codebase
relative to another copy of it, and cited one deployment's numbers. Neither
survives being read by someone who does not share that context, and the
figures date the comment as soon as the payload changes.

Rewritten to state the properties instead:

* `RowAddrRemap`'s hand-written `Debug` now says why it is not derived -- the
  payload is unbounded bitmaps and maps, so a derived impl would print the whole
  remap -- rather than describing which copy of the tree needs it.
* `FragReuseIndex::row_addr_maps` and `open_frag_reuse_index` now contrast the
  two memory profiles directly: a materialized map holds one entry per rewritten
  or deleted row and so grows with the rows compaction has touched, while the
  compact form grows with fragment count. That is the reason the compact form is
  built on a cached read path, and it stays true independent of any one dataset.
* `FragReuseIndex::new`'s note on `Direct` drops the `O(#rows)` shorthand for the
  same phrasing.
* `remap_index`'s comment no longer claims no caller reaches it, which was both
  scoped to one deployment and misleading: the function's purpose is to consume
  the reuse index, so its input is by construction the largest one available. It
  now records that this is the last place whose memory grows per row, and that
  composing the per-version remaps would remove it.

`CompactRowAddrRemap`'s `num_groups`/`num_fragments` also move into the existing
`impl` block. They sat in a second one purely to keep them below the marker; with
the marker gone the split had no reason to exist. They now sit next to
`is_empty`, the other query about the structure's shape.

No behaviour change: comments, and moving two methods between `impl` blocks on
the same type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Implement DeepSizeOf for RoaringBitmap and use it

Two places approximated a roaring bitmap's memory by its serialized size, each
with its own copy of the reasoning: `RowAddrRemap` in lance-core and
`RowAddrSelection` in lance-select. Roaring does not expose its resident
allocation and keeps its containers private, so an approximation is unavoidable
-- but it belongs in one place, and `DeepSizeOf` is defined in this workspace, so
implementing it for a foreign type is allowed. `deepsize.rs` already does that
for `str`, `String`, the atomics, `dyn Array` and `RecordBatch`.

With the impl in place, `RowAddrRemap`'s hand-rolled estimate is replaced by
deriving `DeepSizeOf` on `CompactRowAddrRemap` and `GroupRemap`. The derive walks
`Vec` -> `HashMap` -> tuple -> `RoaringBitmap`, all of which already had impls, so
the arithmetic disappears and the result improves: the previous version charged
fixed constants per entry and ignored the hash maps' and vectors' spare capacity,
which the container impls account for.

The new impl documents how close the approximation is, checked against roaring's
internals rather than assumed. Each container type is laid out the same way in
memory as on disk: an array container is a `Vec<u16>` at 2 bytes per value either
way, a bitmap container is a `Box<[u64; 1024]>` against a fixed 8 KiB, and a run
container is a `Vec<Interval>` at 4 bytes per run against `RUN_ELEMENT_BYTES` of 4.
So the serialized size covers the payload closely. What it misses is the
`Vec<Container>` those payloads hang off, which costs tens of bytes per container
against a 4-byte serialized descriptor, so an allowance for it is added back. That
sum errs high -- it double-counts the descriptor, and most bitmaps also carry a
4-byte offset per container in the header -- which is the safer direction when the
figure is what a byte-bounded cache charges an entry at admission.

The remaining shortfall is the array and run vectors' spare capacity, which is not
observable through roaring's public API.

`RoaringBitmap::statistics()` would expose the capacities, but it reports array bytes
as `capacity * size_of::<u32>()` for a `Vec<u16>` and bitmap bytes as a bit count, so
deriving the payload from it would mean depending on those quirks. Only its container
count is used, which is sound.

Tested from both directions: the same cardinality spread across four containers must
cost more than packed into one, and more values in a single container must cost more
than fewer.

Test asserts the derive reaches the bitmaps rather than stopping at the enum: two
remaps differing only in how many rows their bitmaps hold must report different
sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Test the fragment-reuse index open path

The open path had no direct coverage: the change from a per-row map to the compact
form was verified only by the module's own unit tests one layer down, and by
whatever exercised it incidentally.

`open_frag_reuse_index` turns out to be testable without a dataset -- it takes
`FragReuseIndexDetails` directly -- so these build details in memory, serialize the
row-address treemaps the way the real payload does, and open the result.

Scenarios chosen for where the positional pairing is most likely to go wrong:

* moved, deleted and untouched addresses each resolving distinctly, including the
  difference between a covered-but-emptied fragment (deleted) and a fragment no
  group mentions (unchanged)
* agreement with `transpose_row_ids_from_digest` across every real address, for
  ascending old fragments -- the case where the positional and address-ordered
  pairings must agree
* non-ascending `old_frags`, where the two pairings disagree. This input is not
  reachable from any current writer: `build_manifest` sorts the manifest's fragment
  list by id after every operation, documented as an invariant of
  `Manifest::fragments` and relied on elsewhere, so a rewrite group's fragments
  always arrive ascending and read order equals address order. Pinned anyway,
  because it records which pairing is correct -- for this input the map form
  overwrites two live rows' mappings with `None` and reports them deleted, where the
  positional form is right. A latent bug in the code being replaced, not a live one
* chains of 2, 8 and 32 versions, entering at the head and midway
* deletion mid-chain being terminal, with a later version left unconsulted
* zero-row new fragments not consuming a position
* several rewrite groups sharing one version, with independent positions
* empty details, and a version carrying no groups
* four inconsistent payloads that the map form accepted silently -- row counts
  disagreeing either way, new fragments out of write order, and a rewritten address
  outside the group's old fragments -- now rejected at open
* a corrupt treemap surfacing as an error rather than a panic

On the `FragReuseIndex` side: composition over compact rounds, `remap_row_id`,
`remap_row_ids_record_batch` keeping values paired with their remapped addresses,
`remap_row_ids_roaring_tree_map`, and a 32-round chain.

Also pins that a chain tolerates a `Direct` link. Nothing in-tree builds a mixed
chain -- `new` produces all `Direct`, the open path all `Compact` -- but
`new_from_remaps` and the public field permit one. Asserted against absolute
expectations rather than against an all-compact chain, since a differential check
would also pass against a stubbed `remap_row_id`, and it records that the two forms
are chainable rather than interchangeable: a `Direct` link knows only the addresses
it lists, so an unlisted offset in a covered fragment reads as untouched where the
compact form reports it deleted.

Four scenarios came out of review, covering branches the first pass missed:

* that the open path yields `RowAddrRemap::Compact` at all. Every lookup below
  answers identically for a materialized map, so without this a revert to
  `transpose_row_ids_from_digest` would restore the memory blowup and stay green
* a 200k-row fragment whose addresses are run-optimized before serializing, which is
  the shape `rewrite_files` writes. Every other payload here is a small array
  container, so `RoaringBitmap::rank` was never exercised on run or bitmap containers
  or on offsets past 65535
* five new fragments in one group, so the binary search in `compute_new_addr` lands
  strictly inside the range list. With one or two ranges a mid-list off-by-one passes
* an emptied old fragment in the middle of `old_frag_ids` rather than last, where
  mis-charging its rows to the running position would shift the fragments after it

`remap_row_ids_record_batch` is now parameterized over both column layouts in use:
`row_id_idx` is 1 for scalar indices and 0 for vector storage, and only the former
was covered. `remap_row_addrs_tree_map`, the wrapper with the most callers in-tree,
was untested and now is.

Dropped from the first pass: chain depths of 2 and 8, which pin nothing 32 does not;
a second 32-round chain test one layer down; and two rejection cases that duplicate
`row_addr_remap.rs`. The row-count cases are kept because that validation has no
coverage there, and they now assert the error is the one they name rather than any
error at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Keep opening fragment-reuse payloads that record no rewritten rows

Positional remapping requires a group's rewritten-row count to equal the row count
of its new fragments, and rejects the group otherwise. The per-row map it replaces
had no such check -- its `zip` silently truncated -- so a payload that violates the
rule used to load and now does not.

That is reachable, and not only in theory. Lance 0.30.0 through 4.0.0-beta.6 wrote
exactly this shape for a stable-row-id dataset whose index remap was deferred: an
empty set of rewritten addresses, alongside new fragments carrying real row counts.
The writer stopped doing it in 4.0.0-beta.6's successor, which made row addresses
mandatory on that path, but the payloads it wrote are on disk.

The consequence would not have been confined to a remap. `open_frag_reuse_index`
runs inside `load_indices`, so the error surfaces in scan planning, `validate()`,
and every commit -- a dataset that opens today would stop opening. Exactly the
memory-problem-into-availability-problem trade this change set exists to avoid.

So a group recording no rewritten rows but non-empty new fragments now drops those
new fragments, leaving every fragment it covers resolving to deleted. That is what
the per-row map produced for the same input: with no addresses to pair, it emitted
every covered address as missing. The rows are unreachable through the index either
way until it is rebuilt; only the failure mode would have been new. Logged at warn
so the payload is visible rather than silently reinterpreted.

A group that genuinely deleted everything carries no new fragments, so it is
untouched by this: the shapes are distinguished by the new fragments being present.
Both cases are tested, as is the checked-in v6.0.0-beta.3 dataset under
`test_data/fri_straddle_pre_6610`, which continues to open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Correct the ordering docs on both fragment lists

`MissingAddrs::new` has said "fragments is not guaranteed to be sorted by id" since
2023-11-03 (lance-format#1511). That was true when written, and stopped being true 132 days
later: lance-format#2075, "force fragments to be stored in the manifest in id-order", made
`build_manifest` sort the list after every operation, which `Manifest::fragments`
documents as an invariant and which `Dataset::validate` and
`Manifest::fragments_by_offset_range` both rely on.

The comment is still correct about this type -- it does tolerate an unsorted list,
deliberately -- but read as evidence that unsorted input arises in practice it is
badly misleading, and it cost real time. The note now says what the tolerance is and
is not, and spells out what unsorted input actually produces here: with `row_addrs`
in ascending address order, which is how a serialized `RoaringTreemap` iterates, the
rows of an out-of-order fragment are reported missing and their real mappings
overwritten.

`GroupRemap::new` gains the matching note on the other side. Its two fragment lists
are treated differently and nothing said why: `old_frag_ids` carries no ordering
requirement, because positions are assigned by walking it, so every order is handled
and none rejected; `new_frags` must be ascending, because `compute_new_addr`
accumulates row counts in that order, and a violation is rejected. Worth stating
together, since the natural question on reading the one check is why there isn't
another.

No validation added for `old_frag_ids`. It would reject payloads this code already
handles correctly, which is the same availability trade the previous commit removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Test that old fragment order is followed whatever it is

The previous commit documented that `old_frag_ids` carries no ordering requirement:
positions are assigned by walking the list, so any order is handled and none is
rejected. Relaxing a constraint in a comment is worth little without tests over the
space it opens up, and the existing coverage was thinner than it looked.

What was there: one two-element descending case with every row rewritten, and a
three-element non-monotonic list whose middle fragment was the emptied one. Every
expectation was hand-written.

Added an `rstest` over four permutations -- ascending, descending, rotated, and a
scrambled sparse set -- with each fragment keeping two of three rows so deletions
interact with the ordering, and the output split across two fragments so the range
search does too. Expectations are derived from the contract rather than written out:
the k-th kept row in list order occupies the k-th slot across the new fragments in
their order.

Checked the tests actually bite, by sorting `old_frag_ids` inside `GroupRemap::new`
and confirming what fails: the descending, rotated and scrambled cases, the existing
read-order test, and `test_compact_lookup`. The ascending case correctly survives,
since sorting a sorted list changes nothing.

That exercise also showed the pre-existing three-element case was never really
ordering coverage: `[0, 7, 1]` sorts to `[0, 1, 7]`, and because fragment 7 is the
emptied one the two live fragments keep their relative order, so it passes either
way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Run the btree frag-reuse reconstruct test against both remap forms

Upstream lance-format#7237 parameterized four index types over `Direct` and `Compact` and left
the rest on `Direct` only. One of those left behind matters here:
`test_btree_index_state_reconstruct_applies_frag_reuse_index` is the only test that
drives a `FragReuseIndex` through a real index reconstruct, and it built one from a
materialized map -- the form the open path no longer produces.

Now an `rstest` over both. The two cases move a row differently, because `Compact`
cannot name an arbitrary destination, only a position in a new fragment: the direct
map sends row 0 to 5000, while the compact form rewrites all 1000 rows of fragment 0
into fragment 1 in order, sending row 0 to fragment 1 offset 0. Both land outside the
original `[0, 1000)` range so neither collides, and rewriting every row keeps the
compact case a faithful analogue rather than deleting the other 999.

Checked both cases depend on the remap rather than passing incidentally, by not
handing the index to `reconstruct`: both fail.

Six files still carry `Direct`-only remap tests -- `lance/src/index.rs`,
`index/vector/ivf.rs`, `scalar/lance_format.rs`, `scalar/inverted/index.rs`,
`scalar/inverted/builder.rs` and `vector/bq/storage.rs`. Extending the
parameterization to those is worth doing and is not specific to the fragment-reuse
index, so it belongs in its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Run the all-rows-deleted remap test against both remap forms

Upstream lance-format#7237 parameterized four index types over `Direct` and `Compact` and left
seven test sites on `Direct` only. I parameterized all of them to see whether the
compact form broke any, and it broke none -- so all but one are reverted here rather
than carried.

The one kept is `index.rs::test_remap_empty`. It is the only test where the two forms
run genuinely different logic in a decision that changes the outcome: `remap_index`
returns `Keep` instead of rewriting when `fully_deleted_fragments` matches the index's
fragment bitmap, and the two arms compute that set differently -- `Direct` infers it
from whichever keys the map happens to hold, `Compact` reads it off the fragments its
groups cover. `Direct`'s inference is unsound for a partial map, over-claiming a
fragment as fully deleted on the evidence of one row; the compact arm cannot make that
mistake. Nothing else exercised it through a real caller.

The rest were dropped because `fully_deleted_fragments` and `affected_fragments` are
the only methods any consumer calls beyond `get`, and the sole non-test caller of
either is the branch above. Every other remap consumer -- bitmap, bq storage,
inverted, btree, pq -- only calls `get`, so the difference between the forms at those
sites lives entirely inside `RowAddrRemap::get`, which is unit-tested directly one
layer down. Both `bq/storage.rs` cases were the clearest example: each leaves exactly
49 surviving rows, so the repack path under test sees an identical input shape and
only the row-id values differ.

Also not parameterized: `index/vector/ivf.rs`. Its `build_mapping` leaves a third of
the rows absent so they pass through unchanged, and `check_index` asserts that third
stayed put. `Compact` cannot express that, since an unlisted offset in a covered
fragment reads as deleted, so covering it would mean restructuring what the test
asserts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Let a reader choose how the fragment reuse index is expanded

Compaction picks between the materialized and compact remaps with
`IndexRemapMode`; the reader had no equivalent, so `open_frag_reuse_index` built one
form unconditionally. That leaves the other arm of the enum dead on the read path,
and leaves a deployment no way to change form short of a new build.

`ReadParams::frag_reuse_remap_mode` now selects it, with
`DatasetBuilder::with_frag_reuse_remap_mode` to set it and the
`LANCE_FRAG_REUSE_REMAP_MODE` environment variable supplying the default when
present. This follows `DecoderConfig::cache_repetition_index`, which is how this
crate already exposes a reader-side switch: a public field whose `Default` consults
an environment variable through a `OnceLock`, so the variable is read once per
process and the API remains the way callers select behaviour.

**The default is `Direct`, which preserves the behaviour a reader had before the
compact form existed.** Picking up a Lance carrying this change is therefore not
itself a behaviour change; a caller that wants the compact form asks for it. That
matters because the compact form is not a strict improvement -- it trades memory that
grows with rows for memory that grows with fragments, at the cost of more work per
lookup -- so which one suits depends on the dataset, and the choice belongs to the
caller rather than to a release note.

The mode is part of the fragment reuse index's cache key. Without that, two datasets
sharing a `Session` and choosing differently would serve each other an entry built
the other way, and the forms are not interchangeable: they resolve some addresses
differently.

Threaded the way `file_reader_options` already is -- `ReadParams` to `Dataset` to the
open site -- so the plumbing follows an existing path rather than inventing one. Two
things that path does not make obvious, both regression-tested:

* the builder keeps its own copy of the field, so its default has to consult the same
  environment-aware function rather than `IndexRemapMode`'s derived default -- with
  the derive it would ignore the variable, and would drift if `#[default]` ever moved
* `with_read_params` copies fields one at a time, so a new one is silently dropped
  unless added there too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Test the reader-side remap switch end to end, including what it costs

The switch had no test through the public API: the earlier ones passed a mode to an
internal function, and nothing exercised
`DatasetBuilder::with_frag_reuse_remap_mode` or the plumbing behind it.

Two tests, both building a multi-fragment dataset with a scalar index and then
compacting several times with the remap deferred, so the index is never rewritten and
its stored addresses have to be resolved through an accumulated reuse index at query
time -- which is what makes these exercise the reader rather than compaction.

The first reopens through the builder in each form and checks they answer identically:
the same counts before compaction and after, and the scalar index still serving the
scan. It also asserts the reopened dataset carries the mode that was asked for,
because without that a no-op setter would pass -- the two forms agree on every count,
which is the property being checked, not evidence either was selected.

The second measures the difference rather than arguing it. Opening the same dataset
each way and reading `Dataset::cache_size_bytes` around a query, the materialized form
grows the cache by about 4.4 MB and the compact form by about 140 kB -- roughly thirty
times, on eight fragments of five thousand rows with three compaction rounds. That is
a public API over the same estimate the byte-bounded cache charges at admission, so it
measures what the cache believes rather than resident set size; the gap is wide enough
that the distinction does not change the conclusion. The assertion is a tenfold margin
against a measured thirtyfold, and it also requires the materialized form to have
grown at all, so a test that cached nothing cannot pass.

Both were checked by mutation: neutering the builder's setter fails the first, and
forcing the open path back to the materialized form fails the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* Keep the two remap forms apart in a shared session's index cache

What an index caches is not the index as stored. With the remap deferred its row
addresses are stale, and the load path translates them through the fragment reuse
index as it reads. So the cached state is the index *as translated*, and the two forms
do not translate identically -- an offset past a fragment's `physical_rows` is deleted
under one and untouched under the other.

The cache prefix identified which reuse index did the translating, `{index}-{fri}`,
but not which form. A `Session` is shared state by design, so two datasets pooling one
could disagree about the form, and the second would be served state the first had
already translated: the mode became advisory, decided by whichever opened first.
Measured on a shared session, opening the second form added one cache entry rather
than a set -- it built its own reuse index and then read the other's work.

`GlobalIndexCache::for_dataset_with_remap_mode` puts the form in the per-dataset
prefix, so everything beneath it -- the reuse index and every index's own state --
is partitioned without touching `for_index` or any of its callers. The same open now
adds a full set of entries instead of one, so each form does its own translating.

Partitioning here rather than per index also removes the need for the mode in
`FragReuseIndexKey`, which is reverted: one partition point instead of two.

Costs nothing in the ordinary case. One form in use means one prefix and the cache
behaves exactly as before; two forms cost a second copy of the state for the datasets
that differ, which is memory rather than correctness. `load_manifest` deliberately
keeps the unpartitioned prefix: the index metadata it caches is the manifest's own
list and does not depend on the form. If `Direct` is eventually removed, one form
remains and this collapses back into `for_dataset`.

The accompanying test opens the same dataset version both ways in one session, in both
orders, and checks the answers agree. It passes with or without this change, because
the forms agree on every address a writer can produce -- so it is regression cover for
the sharing working at all, not a guard on the partitioning. The partitioning was
verified by cache entry counts instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* test: turn the deadlock test's prints into assertions

The fragment-reuse deadlock test printed its compaction metrics, the index
names it found and a success line, none of which a test run checks. Two of
them were guarding something worth asserting, so assert it: compaction must
have rewritten fragments, or no reuse index exists and the path under test is
never entered; and the index names belong in the failure message of the
assertion that looks for the reuse index, not on stdout ahead of it.

The remaining two were progress narration. The timeout is what decides the
test, so the elapsed time it reported was never read.

`println!` is denied workspace-wide (`clippy::print_stdout`), so this also
takes the file off the list of reasons `cargo clippy --all --tests --benches
-- -D warnings` fails, and sorts its imports so `cargo fmt --all --check`
passes. Both were missed because `.github/workflows/rust.yml` triggers on
`release/**`, which does not match this branch's base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* fix: keep the manifest's index list out of the remap-partitioned cache

Partitioning the dataset's index cache by remap form moved every key under a
`{uri}/{form}` prefix, including the manifest's own index list. That list is
read straight from the manifest and is not translated through the fragment
reuse index, so it is identical under either form -- and `load_manifest`
prefetches it while decoding the manifest, before any form is known, at the
unpartitioned prefix.

Producer and consumer therefore stopped agreeing. `load_manifest` wrote the
entry under `{uri}/` and `load_indices` looked for it under `{uri}/Direct/`,
so the prefetch became a write nobody read: every `load_indices` missed and
re-read the index section from the manifest object. That is scan planning,
`validate()` and every commit, and the orphaned entries stayed admitted
against the byte-bounded cache while being unreachable.

`test_load_indices` covers exactly this -- "we should have opportunistically
cached the indices in memory already" -- and had started failing.

Give the dataset a second handle for entries that are manifest-derived rather
than translated, and put the index list there on both sides. The partitioned
handle keeps everything whose contents the form can change, so it stays safe
by construction: anything reached through it is partitioned without the
caller having to remember.

The shared-session test also gains the assertion it was missing. It compared
row counts, which agree between the forms for any payload a real writer
produces, so it passed with the partitioning deleted outright. It now checks
the form of the reuse index each reader is served, and fails in both
directions under that mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* fix: correct comments that state the opposite of the code, and two vacuous tests

Four public methods had lost their documentation. Both new remap-mode setters
were inserted between an existing doc comment and the method it documented, so
`ReadParams::file_reader_options` and `DatasetBuilder::with_read_params` ended
up undocumented while the new methods rendered under someone else's summary
line -- which is the line rustdoc shows in the method list.

Three comments said something untrue:

  - "It cannot fail" sat six lines above a `?` on a treemap deserialize. The
    narrower claim is what was meant: the materialized form does not reject a
    payload whose row counts disagree, where the compact form does.
  - `row_addr_maps` was documented as built compact, but the form became a
    reader choice defaulting to direct, so the field doc stated the opposite of
    what ships.
  - A test comment said `old_frag_ids` imposes no ordering requirement, against
    a module header stating the order is load-bearing. What is absent is
    validation, not the requirement; a reader who believed the comment would
    pass an arbitrary order and get silently wrong addresses.

Two tests asserted things that could not fail.

The roaring size test compared totals across bitmaps with different container
counts, but a serialized bitmap already carries a per-container descriptor, so
the assertion held with the in-memory container allowance removed entirely. It
now compares the allowance itself.

The builder default test claimed to catch the builder taking the derived
default rather than the environment-aware one, but with the variable unset the
two are the same value, so it could only ever catch `#[default]` moving. The
parsing is now split out of the `OnceLock` -- which reads the environment once
per process, so no test could vary it -- and covered directly, including the
unparseable value that warns and falls back. The builder test keeps its wiring
check and drops the claim it could not support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

* test: run the deferred-remap catch-up against both remap forms

Every test that reached `remap_column_index` ran at the default form, and the
three tests that select a form are read-only after opening. So the branch that
exists because the compact form cannot enumerate its keys was never executed --
the half of the change written for `Compact` had no coverage at all.

This is the sequence the compact form exists for: open `Compact` so the reuse
index does not cost one entry per rewritten row, let maintenance rewrite the
index, then trim the reuse index away.

The load-bearing assertion is the last one. Trimming the reuse index to zero
versions only shows maintenance ran, and stays true even when the remap wrote a
wrong index, because trimming is driven by index metadata rather than index
contents. A reader opened after the trim has nothing left to translate through,
so it can only answer correctly if the rewritten index really holds current
addresses. Emptying the composed remap fails exactly that assertion, in both
forms, with the trimming assertion still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXueHshgpL7dw1adLsfTUK

---------

Co-authored-by: Ryan Mack <rmack@rerun.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zehiko zehiko closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants