feat(index): apply shared FRI remapping to scalar and vector queries - #9107
Merged
Merged
Conversation
LuQQiu
added this pull request to stack #9108
September 9, 2026 19:16
LuQQiu
removed this pull request from stack #9108
September 10, 2026 03:15
LuQQiu
added this pull request to stack #9117
September 10, 2026 03:15
LuQQiu
added a commit
that referenced
this pull request
Sep 10, 2026
Resolve FTS document-state sharing and scalar cache rebind API conflicts. Keep batch remappers store-bound and reload on binding changes. Formatting passes; Clippy is blocked by main requiring object_store_opendal 0.60.1, which the registry does not currently resolve.
LuQQiu
removed this pull request from stack #9117
September 10, 2026 18:17
LuQQiu
added this pull request to stack #9137
September 10, 2026 18:17
This was referenced Sep 10, 2026
LuQQiu
marked this pull request as ready for review
September 10, 2026 18:21
LuQQiu
added a commit
that referenced
this pull request
Sep 15, 2026
FRI keeps existing indices usable after fragment rewrites by translating old physical row addresses to new ones. Today it supports order-preserving compaction. This proposal extends the same system index to support stable partitioning, with both mapping types sharing one fragment-lineage history. Following [the unified FRI proposal](#8972 (comment)), source/destination lineage stays in FRI details, while large mapping payloads remain in separate immutable files. ### One history, multiple mappings Continue storing FRI information in a single `__lance_frag_reuse` system-index entry. Keep the existing `InlineContent` / `ExternalFile` envelope and the original field number for legacy versions. Add tagged transitions alongside them: ```text FragmentReuseIndexDetails └── InlineContent, stored inline or in external details.binpb ├── legacy_versions[] └── transitions[] ├── ordered sources[] ├── ordered destinations[] └── mapping ├── OrderedCompaction: surviving-row bitmap └── StablePartition: immutable row-map reference ``` Sources and destinations define the common rewrite graph. Each mapping defines how to translate row offsets. Legacy groups can be read as ordered-compaction transitions; mixed histories follow fragment lineage, not the order of records or dataset version numbers. ### Lightweight metadata, external row maps Ordered compaction retains its compact bitmap representation. Stable partition assigns each physical source row a nullable `uint16` destination label, preserving source order within each destination. A null label means the row was deleted. A counts matrix lets readers reconstruct destination offsets without reading all preceding labels. Stable-partition metadata records `map_id`, `map_size_bytes`, and optional `base_id`. The labels and counts are stored in `_fri/<map_id>/stable_partition.lance`. Mapping identity is independent of the FRI index UUID: updating the history rewrites its metadata, but does not rewrite existing row-map files. The history can be opened without loading labels; address translation reads the required blocks. ### Publication and compatibility `AppendFragmentReuseTransitions` expresses a transition delta. Combined atomically with a fragment rewrite, it lets the commit apply the delta to the current history and publish destination fragments and their mappings together. The persisted FRI details remain a snapshot of that history. - **Index version 0:** existing compaction format and read/write behavior remain unchanged. - **Index version 1:** supports legacy groups and tagged transitions in one history. - The first commit publishing index version 1 sets reader and writer flag **512**. The reader flag prevents old clients from partially interpreting the history; the writer flag prevents them from dropping mappings during metadata maintenance. Subsequent manifests retain both bits. ### Scope and validation This PR contains protobuf definitions, the corresponding format documentation, the proposed flag constant, and minimal compile adapters. It does not enable tagged-history reads or writes. Mapping implementations and reader integration follow in #9106 → #9064 → #9067 → #9068 → #9107. Replaces #9065 as the standalone spec at the bottom of native stack #9137, based on main `31d78d170`. `cargo fmt --all` and whitespace checks pass. Clippy and tests are blocked by dependency resolution: main requires `object_store_opendal 0.60.1`, while the crates.io index currently offers only up to 0.60.0. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
LuQQiu
force-pushed
the
lu/fri-index-consumers
branch
from
September 18, 2026 18:03
e477cca to
5dd6cd2
Compare
LuQQiu
force-pushed
the
lu/fri-index-consumers
branch
from
September 18, 2026 18:08
5dd6cd2 to
1e49aec
Compare
LuQQiu
force-pushed
the
lu/fri-index-consumers
branch
from
September 18, 2026 18:10
1e49aec to
98d73ae
Compare
Apply the shared fragment-reuse-index (FRI) remapping to the scalar indexes (btree, bitmap, bloomfilter, label_list, ngram, rtree, zonemap, inverted/FTS) and to the vector IVF consumer path, layering the additive with_remapping load entry points and the async BatchRowIdRemapper onto the plan-time coverage readers from the base. Adds the remapping module in lance-index-core plus the frag_reuse_remapping consumer and frag_reuse_reader tests in lance. Scanner partial-segment selection now drops ANN coverage when a tagged contributor is deselected, forcing a flat fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgAshYD7wVzPVdjXRuWPPs
…ition A v1 FRI whose only transition is dropped (unknown field) must force scalar queries to fall back to scanning rather than reuse the stale index addresses. The test installs such a history over an indexed dataset and asserts the plan carries no ScalarIndexQuery and every value count matches the pre-FRI direct-scan truth, so no rows are lost and no stale rows are returned. The guard's own fail-without-fix witness is the unit test dropped_unknown_transition_denies_identity_to_live_segment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Combination tests for FRI v1 composed with #9449 scalar fragment-scope pruning. The pruning consumes each segment's FRI-effective coverage, so a query scoped to one reclustered destination keeps every source segment that contributed to it and prunes segments that do not intersect it. The derived-listing cache holds the full effective listing per snapshot, so scoping one query does not poison the next on the same snapshot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sting cache memory-only Two correctness fixes on the tagged FRI consumer. - load_indices admitted a segment when may_need_translation was true as long as the index plugin supported remapping, without checking that the mapping itself was available. A dropped unknown transition loses the record of which fragments it touched, so a segment whose coverage is projected onto live destinations while its stored addresses are pre-transition would be granted direct coverage the translator cannot honor, dropping rows and suppressing the scan fallback. Exclude every translation-needing segment when the ledger has an unsupported transition: cannot prove translation, so no coverage. - The derived-listing cache carried a persistence codec, but the derived listing depends on the reader's translation capability, not only the snapshot: one build excludes a segment another admits. A persisted entry reused across builds could serve a listing the current reader must not use. Make the cache memory-only so it is never persisted. Tests: a projected-bitmap dropped-transition query that must scan and match an index-disabled scan cold and warm (verified to fail without the guard); the earlier query-vs-scan test now baselines against an index-disabled scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback that the combination tests did not exercise the promised scenarios. - Pruning test now adds an unrelated third segment, opens the index scoped to one destination with a load counter to prove exactly the two reclustered contributors load and the unrelated segment does not, and runs a real search asserting only the scoped fragment's rows come back from both contributors. - Cache test now runs real scoped queries for one destination then the other on the same snapshot, comparing each against an index-disabled same-fragment scan by row identity, so a listing poisoned by the first scope would fail the second. - Adds a vector prefilter test that reaches the scoped scalar prefilter (ScalarIndexQuery feeding ANN) and matches an index-disabled scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t both layers Address review feedback on the fragment-scope combination tests. - The pruning test now builds its unrelated third segment on the appended fragment (not on a recluster destination) and asserts at two layers: the scoped opener loads exactly the two reclustered contributors (a load counter, verified to fail when the scope is ignored) and its search returns every contributor address in both destinations, because the opener selects segments and never trims rows; the scanner query scoped to one destination returns exactly that destination's rows. - The vector prefilter test uses a strict subset scope: the vector index covers one destination and fast_search bounds the search to it, while the scalar prefilter is FRI-translated over both destinations and the other destination holds predicate-matching rows with closer vectors. No such row leaks, all in-scope matches are kept, and top-1 proves filtering precedes top-k. The control keeps the vector index and only switches the scalar prefilter to its scan-based form. - Adds the same subset-scope test for full-text search. An uncommitted inverted index only has staged partitions, so the test runs the merge step the CreateIndex commit would run, since that commit is refused on a tagged history at this layer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- BitmapIndex::load and load_with_remapping carried identical code for streaming the keys column and reading the null bitmap; only the remapping of the null bitmap differed. Extract load_key_map and read_null_bitmap so both paths share one implementation. The legacy loader also unwrapped the bitmap deserialization; it now reports a corrupt file like the remapping loader. - load_indices decided the unsupported-transition exclusion inside the per-segment translation branch, after consulting the plugin registry. Decide it once, before the loop: when the ledger carries a dropped unknown transition, no user-index segment can prove its addresses translatable, so every one is excluded. Same behavior, stated once, no registry lookups. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LuQQiu
force-pushed
the
lu/fri-index-consumers
branch
from
September 23, 2026 17:46
b018a2f to
fdefb3f
Compare
… by snapshot Under a tagged history the index cache scoped every v1 entry under the manifest path, so every commit (an append, an unrelated rewrite, a trim) cold-started every index: translated pages, postings and IVF partitions were re-read and re-translated although nothing that decides their content had changed. Keying on the FRI entry's UUID would not help: every rewrite and trim mints a new one. Cached objects are now classified by what they depend on: - Content decoded without translation (identity segments under a tagged history, v0 histories, no history) depends only on the index file: plain per-index namespace. The IVF state entry and the legacy whole-index entry join it (they embed no rows); the FRI UUID stays in the key only for a v0 history, whose remapper is applied while the index is decoded. - Content that embeds translated addresses lives under a namespace named by the segment's translation fingerprint: blake3 over the segment's live coverage, its sibling exclusions restricted to the destinations of its mapping path, the fingerprints of the transitions on that path in ledger order, and whether the ledger dropped unsupported transitions. These are exactly the inputs of `remap_row_ids_excluding`, so an entry goes cold when a transition on the path is trimmed or replaced, a fragment on the path is dropped, or a sibling takes direct ownership of a path destination, and stays warm across appends and rewrites of fragments the segment never covered. `segment_plans` now also reports each segment's path; the fingerprint is computed once per snapshot inside the query plan build. Tests (all through the real open paths, with `LocalMetricsCollector`): - the existing manifest-path pin is flipped: a path-only snapshot change is now a hit (0 loads); - `tagged_append_keeps_translated_entries_warm`, `unrelated_transition_keeps_translated_entries_warm` (new FRI UUID, extra transition off the path: still warm, same container), `identity_segment_survives_two_appends`, and the vector fixture extended with an append (IVF state hit, partition hit) fail with the old keying; - `coverage_change_misses_without_new_fri` (a live destination dropped, FRI untouched) and `relevant_transition_misses_translated_entries` (a new hop on the path, applied correctly) pin the misses that must stay misses; - `segment_plans_excludes_the_sibling_direct_union` pins the path and the fingerprint's sensitivity (coverage, path, on-path exclusions) and insensitivity (off-path exclusions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rage A legacy-layout inverted index (tokens, postings and documents at the index root, no metadata file) keeps its per-document lengths aligned with the row ids it was built with. Loading it through a translating remapper would drop rows from the id column but not from the lengths, so scores could change silently. The loader now refuses a legacy layout under a remapper (`not_supported`); an untouched (identity) legacy segment still loads. The tagged reader treats such a segment like any other segment it cannot translate: `load_indices` probes the layout (the loader's own metadata-file probe, cached per index uuid like the vector format check) and excludes it from coverage, so the planner scans its fragments. A full-text query that names its column then runs the flat path over every fragment and returns exactly what an index-free scan returns; the exclusion is not left to the error. Test: `legacy_layout_fts_on_tagged_table_is_excluded_and_scans` rewrites a single-partition index into the legacy layout, tags the table, asserts no usable segment is advertised, and asserts a real full-text query (cold and warm) equals the index-free scan. Fails without the exclusion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… plan `open_scalar_index` resolves a segment's translation through the snapshot plan, which only knows the segments listed in the manifest. A staged (uncommitted) segment about to be merged is not listed, so opening it under a tagged history failed with "FRI remapping requires committed segment metadata", even when no rewrite ever touched its fragments. `open_row_id_remapping_with_plan` and `open_scalar_index_with_plan` take an explicit `SegmentRemappingPlan` for one segment; the existing entry points are the `None` case and the snapshot lookup error now says how a staged segment is opened. The cache namespace still follows the resolved remapping, so a staged translate open lands under its own translation identity. The planner that builds such plans for a staged group with the reader's own steps, and its consumers (the staged merge, its coverage and filters), land with the maintenance PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
LuQQiu
force-pushed
the
lu/fri-index-consumers
branch
from
September 23, 2026 21:06
fc17a6c to
ac29a2a
Compare
Contributor
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The unused staged-group planner moved to the downstream tagged-maintenance PR, while the opt-in loader hook stays here. Default scalar and vector opens still use the reviewed snapshot plan; the version-0 path, conservative scan fallback, and translation-scoped caches are unchanged. The excluded Python and Java lockfiles now include the same direct blake3 dependency as the root lockfile.
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.
What this adds
This PR connects scalar and vector index loading to the common version-1 FRI reader. It applies the coverage and translation plan produced for each index segment while keeping version-0 FRI on its existing synchronous path.
An unaffected segment receives no remapper, so existing and external plugins continue using their normal loader. A segment that requires translation is retained only when its consumer supports batch row-ID remapping; otherwise its destination coverage falls back to scanning.
Index consumers
The shared batch-remapping path is used by:
Consumers translate physical row IDs in batches, preserve result order and deletion handling, and avoid building a partition-sized per-row lookup table. Vector loading preserves its existing partition and quantization layouts.
Version-0 FRI continues to use the original synchronous remapper and plugin APIs. A separate version-1 identity state ensures an unaffected segment never receives a synthetic legacy remapper merely because the dataset contains version-1 history.
Query planning and caching
The version-1 plan is built once per dataset snapshot and cached by manifest identity. It records each segment’s derived coverage and the fragments owned by newer selected segments. Warm segment opens reuse this plan instead of recalculating whole-dataset coverage.
Direct destination segments take precedence over older translated segments. When a logical index needs multiple source segments to cover a destination, all required contributors are selected; omitting one forces scan fallback for that destination. Fragment-restricted vector queries also scan when their selected segments do not provide complete coverage.
Cached objects are keyed by what they depend on. Content decoded without translation (identity segments, version-0 histories, no history, the IVF state entry) lives in the plain per-index namespace and stays warm across commits. Content that embeds translated addresses (pages, postings, partitions loaded through a translating remapper) lives under a namespace named by the segment's translation identity: a hash of its live coverage, its sibling exclusions on its path's destinations, the fingerprints of the transitions on its path, and whether the ledger dropped unsupported records. Such an entry goes cold exactly when that state changes (a transition on the path trimmed or replaced, a fragment on the path dropped, a sibling taking direct ownership of a path destination) and survives appends and rewrites of fragments the segment never covered. The FRI entry's UUID is not part of the key: every rewrite and trim mints a new one.
Staged segments. A segment built but not yet committed (the distributed build flow:
execute_uncommittedper fragment, then merge, then commit) is unknown to the snapshot plan.open_row_id_remapping_with_plan/open_scalar_index_with_planopen one segment through a caller-supplied plan; the existing entry points are theNonecase. The cache namespace still follows the resolved remapping. The planner that builds such plans for a staged group, and the staged merge itself, land in #9161.Two boundaries. A legacy-layout full-text (inverted) segment, whose per-document lengths cannot be translated, is excluded from coverage on a tagged table and its fragments are scanned; a full-text query must then name its column, since a column-less query discovers columns from the usable segments. Segment-only selection (
with_index_segmentswithout a fragment list) answers from the selected segments only, by contract, where the pre-FRI path fell back to a flat search; pass the fragment list to get the coverage guard and the flat fallback.Validation
End-to-end tests cover all supported scalar and vector consumers, multi-segment source coverage, two-source/two-destination rewrites, deleted rows, partial and filtered queries, direct destination precedence, reconverging lineage, unsupported plugins and index versions, scan fallback, cache reuse, prewarm, append/delete/checkout, and eager/deferred compaction.
A version-0 lifecycle suite covers scans, filtered queries, writes, compaction, optimization, historical checkout, remapping, and cleanup while asserting that version-0 datasets never enter the new asynchronous path.
Validated with workspace Clippy and
cargo fmt --all. The python and java lockfiles carry the newblake3dependency (CI runs--locked); the staged-group planner that an earlier revision of this PR carried moved to #9161 with its consumers.🤖 Generated with Claude Code