Skip to content

feat(index): apply shared FRI remapping to scalar and vector queries - #9107

Merged
LuQQiu merged 10 commits into
mainfrom
lu/fri-index-consumers
Sep 24, 2026
Merged

LuQQiu merged 10 commits into
mainfrom
lu/fri-index-consumers

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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.

stored index segment
        │
        ▼
snapshot FRI plan
        ├── Identity     → use the existing plugin load path
        ├── Translate    → load through asynchronous batch remapping
        └── No coverage  → omit the segment and scan

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:

  • B-tree, bitmap, zone map, Bloom filter, RTree, label-list, NGram, and inverted/FTS scalar indices.
  • Vector partition storage, including PQ-backed storage.

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_uncommitted per fragment, then merge, then commit) is unknown to the snapshot plan. open_row_id_remapping_with_plan / open_scalar_index_with_plan open one segment through a caller-supplied plan; the existing entry points are the None case. 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_segments without 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 new blake3 dependency (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

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer enhancement New feature or request labels Sep 9, 2026
@LuQQiu
LuQQiu added this pull request to stack #9108 September 9, 2026 19:16
@LuQQiu
LuQQiu removed this pull request from stack #9108 September 10, 2026 03:15
@LuQQiu
LuQQiu changed the base branch from lu/fri-query to main September 10, 2026 03:15
@LuQQiu
LuQQiu changed the base branch from main to lu/fri-query September 10, 2026 03:15
@LuQQiu
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.
@github-actions github-actions Bot added the A-deps Dependency updates label Sep 10, 2026
@LuQQiu
LuQQiu removed this pull request from stack #9117 September 10, 2026 18:17
@LuQQiu
LuQQiu added this pull request to stack #9137 September 10, 2026 18:17
@LuQQiu
LuQQiu marked this pull request as ready for review September 10, 2026 18:21
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. and removed K-approved Latest Gatekeeper recommendation permits acceptance. labels Sep 10, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 10, 2026
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
LuQQiu force-pushed the lu/fri-index-consumers branch from e477cca to 5dd6cd2 Compare September 18, 2026 18:03
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 18, 2026
@LuQQiu
LuQQiu force-pushed the lu/fri-index-consumers branch from 5dd6cd2 to 1e49aec Compare September 18, 2026 18:08
@LuQQiu
LuQQiu force-pushed the lu/fri-index-consumers branch from 1e49aec to 98d73ae Compare September 18, 2026 18:10
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. and removed K-approved Latest Gatekeeper recommendation permits acceptance. labels Sep 23, 2026
LuQQiu and others added 7 commits September 23, 2026 10:30
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
LuQQiu force-pushed the lu/fri-index-consumers branch from b018a2f to fdefb3f Compare September 23, 2026 17:46
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 2026
LuQQiu and others added 3 commits September 23, 2026 13:56
… 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
LuQQiu force-pushed the lu/fri-index-consumers branch from fc17a6c to ac29a2a Compare September 23, 2026 21:06
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 23, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. and removed K-approved Latest Gatekeeper recommendation permits acceptance. labels Sep 23, 2026

@jackye1995 jackye1995 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good!

@LuQQiu
LuQQiu merged commit 0ce3950 into main Sep 24, 2026
43 of 45 checks passed
@LuQQiu
LuQQiu deleted the lu/fri-index-consumers branch September 24, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-deps Dependency updates A-index Vector index, linalg, tokenizer A-java Java bindings + JNI A-python Python bindings enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants