Skip to content

feat(index): maintain tagged fragment reuse histories - #9161

Open
LuQQiu wants to merge 8 commits into
lu/fri-commit-pathfrom
lu/fri-maintenance
Open

LuQQiu wants to merge 8 commits into
lu/fri-commit-pathfrom
lu/fri-maintenance

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Maintenance for tagged fragment reuse histories: trimming drained records, pruning superseded index segments, garbage-collecting unreferenced row maps, and remapping indices through compaction-only chains. Legacy (version 0) FRI maintenance keeps its existing path untouched.

Trim

cleanup_frag_reuse_index on a tagged table derives which transitions are still needed and splices the survivors byte-verbatim (no re-encode of retained records). A transition is retained while some index still translates through it: its provenance intersects the index's stored coverage and at least one destination is not directly covered, plus the downstream producer-to-consumer closure so chains never break mid-path. A fully drained entry is deleted outright. The trim intent is re-derived against the current manifest on every commit attempt; if a rebase leaves nothing to trim, the operation aborts instead of committing an empty version.

Superseded segment pruning

Before trimming, cleanup removes index segments whose entire valid coverage (stored bitmap plus reader-derived translated coverage) is directly covered by kept newer siblings, newest-first to a fixed point, as a separate removal-only commit. Only segments the reader considers usable count as covering; an unusable segment can neither justify pruning a sibling nor block a trim. The removal set is re-derived against the current manifest on every commit attempt, including commit-path rebases: prune transactions carry an explicit property, the conflict resolver replaces a rebased removal set wholesale, and a rebase that leaves nothing to remove aborts instead of committing (so a concurrent replacement that narrows a justifying sibling's coverage can never make a stale removal land). Pruning also refuses histories it cannot fully interpret, same as trim.

Row-map garbage collection

_fri/<map_id>/ directories follow the same three states as _indices: referenced by a retained manifest, unreferenced but young, unreferenced and aged out (deleted). References are collected from every retained manifest version's FRI entry. If any history record cannot be interpreted (unknown envelope records included), row-map GC disables itself for the run rather than guessing.

Index remapping

Indices whose provenance runs through ordered-compaction-only chains are remapped and swapped (each hop requires the transition's sources to be within the segment's current coverage); any chain touching a stable partition is skipped with a log line, since translation stays available at query time. Two correctness details: the published coverage is intersected with what the remapped file actually contains (the bitmap-index remap path excludes rows already ceded to direct siblings), and the metadata-only "keep" outcome preserves the original base_id so indices inherited through a shallow clone keep pointing at their real files.

Unknown-record consistency

Envelope-level unknown records flag the decoded ledger as unsupported, giving every consumer the same conservative answer: GC disables, trim, remap, and superseded-segment pruning all refuse, appends onto such histories are rejected, reads are unaffected. The pruning refusal sits inside the per-attempt derivation, so no removal commit can be built from a history the client cannot fully interpret.

Merging segments on a tagged table

optimize_indices merge opens the source segments through the translating loader, so the merged files hold live addresses. The merged segment claims the union of the merged segments' stored provenance plus the newly indexed fragments (provenance, not the derived live coverage), so translated coverage is kept, an unselected sibling's coverage is untouched, and the reader keeps translating the merged segment while its provenance runs through retained transitions. Vector merges classify dormant and live segments on the same derived coverage. Draining also works through rebuilds and delta builds.

Staged segments (merge_existing_index_segments over segments built with execute_uncommitted) are not in the manifest, so the merge plans them as one group with the reader's own steps (plan_staged_segments: may_need_translation, segment_plans, translation_fingerprint, the same ones the snapshot plan runs per committed group) and threads that plan through every scalar merge, the per-segment filters and the opens (the *_with_plan loaders from #9107). A segment that is neither staged nor listed is an error, never empty coverage. Here coverage follows the destination model: each staged segment's bitmap becomes the live coverage its plan derives before the merge runs, because the per-type merges emit the live-domain union and a provenance bitmap would be pruned to nothing at commit. A group that covers no rewrite destination completely claims nothing and those rows stay on the scan path. The NGram merge translates only through the version-0 remapper, so a staged NGram segment whose plan translates is refused rather than merged with stale addresses; the FM merge rebuilds over the derived coverage. The version-0 handle is never consulted on a tagged table. Staged vector shards never open the dataset: the merge filters each by its stored bitmap in the raw address domain and the merged segment keeps the provenance union, so it translates at query time like a committed vector merge (pinned by an IVF_FLAT case across a stable partition). For that to survive the commit, commit_existing_index_segments keeps a retired fragment that the tagged entry's lineage records in a new segment's bitmap; a retired fragment off the lineage is still pruned. Translating is not enough on its own, though: the values a segment holds are the ones it read at its own dataset version, and every commit since then may have moved its rows (a recorded transition: the provenance stays and translates) or rewritten an indexed column in place (the values are stale: that transition's coverage must go). So replay_staged_segments validates the staged segments up to the snapshot being merged against with the conflict resolver's own rules, as a CreateIndex built at the oldest build version and rebased across every transaction up to that snapshot: a transition keeps the provenance, an in-place rewrite withdraws the transition's sources (the withdrawal from #9139), and a bare compaction of a covered fragment, a partly covered rewrite group or a version in the window that can no longer be read is an error pointing at a rebuild. The merge runs after the replay, over what is still valid, and stamps the merged segment with the validated snapshot; the commit reads from the oldest validated version, so the commit loop carries the segments across anything newer through the same rules. The cost is proportional to the commits crossed between the build and the merge, and segments are replayed per build version, so a segment built after a rewrite is not withdrawn for it again. The history has to be complete: the transaction listing only knows the manifests that still exist, so the versions listed must be exactly build+1 through the snapshot, else the segments are refused with the rebuild error (a build version kept by a tag with the versions after it cleaned up would otherwise skip the rewrite in between). An in-place rewrite of an indexed column on a stable-partitioned table costs the whole transition's coverage for every segment still translating through it, so remap (#9187) after a stable partition before rewriting columns in place; optimize_indices restores what was withdrawn: maintenance opens through open_*_index_for_maintenance, which reaches a registered segment the listing omits and reads it as contributing nothing, so a rebuild over the live fragments replaces it. The query entries (open_scalar_index, open_vector_index, open_generic_index by uuid) see the listing only and refuse such a segment, since no scan was scheduled for its rows and an empty answer would drop them. The snapshot plan records why the reader derives no coverage for a registered segment (withdrawn, no derived coverage because superseded or missing contributors, unsupported by this build, corrupt metadata): maintenance reads the first two as empty translations and replaces them, skips the third, fails on the fourth; an empty bitmap is never planned as identity, so a merge never reads the withdrawn pages raw. The last segment of a name emptied by a withdrawal stays as the record of what to build: queries scan, optimize_indices reads its parameters through the maintenance entry and rebuilds it from the live fragments (scalar and vector; a vector segment with files and no live coverage is dormant, a definition without files is still trained from its definition), and it takes part in neither remap nor merge as a normal segment. Under a history this build cannot interpret optimize is a no-op with a warning, like trim, superseded pruning and remap. The default optimize's steady-state vector rebalance and the inverted append/merge publish the stored provenance and read old data through the reader's derived coverage, exactly as the scalar merge does, so neither turns indexed rows of a partition's destinations into scans. In the staged replay a same-name CreateIndex in the window (routine optimize_indices on the index a distributed rebuild is replacing) is no conflict unless it changed the index's fields or type; the overlap rules settle it at commit. Staged NGram segments cannot be carried across a stable partition (the commit-time NGram check and the merge both refuse a translating NGram segment): rebuild them. remap_column_index remaps the first segment of the name it is given, one segment per call. A user index built before a stable partition and committed after it lands the same way, translating, through the resolver rule in #9139, and one committed after a concurrent in-place rewrite lands with that transition withdrawn; the cases live here because this PR is where a user CreateIndex is admitted on a tagged table. The staged plan looks only at the staged group, not at committed same-name siblings: if a committed delta already covers a destination directly, the staged group still claims it and the commit displaces that delta, so the delta's other fragments fall back to scan. That is a coverage regression, not a wrong result.

Validation

On the current #9139 head: lance-table 485, lance lib whole suite green (staged-merge tests in index/create/tests/staged_tagged.rs, each proven to fail without its fix); fmt and clippy clean; python and java bindings compile. Lifecycle tests cover partial and full draining, retained-version file ownership, trim retries, and trim x trim conflicts.

Stack: 8 of 11, follows #9139 (synchronized), #9162 builds on this.

🤖 Generated with Claude Code

lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Sep 13, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-changes Latest Gatekeeper recommendation requests changes. and removed K-changes Latest Gatekeeper recommendation requests changes. labels Sep 13, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 13, 2026
@LuQQiu

LuQQiu commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Adjudicated both rounds against the code; the pruning concerns were real and are fixed, the trim concerns were already structural.

Pruning validated nothing before removing: fixed in 5db67d4. Removals were derived through the lenient reader path, which only logs on unsupported records, so an old client could prune a segment whose coverage flows through a transition it cannot interpret, and commit that removal before trim's refusal fired. The strict ledger check now sits inside derive_superseded_segments, so every attempt refuses (NotSupported, message consistent with trim, remap, and GC) before any removal-only commit is built.

Pruning race on rebase: confirmed and fixed in 867ae45. The removal-only shape fell through every re-derivation branch in finish_create_index, and a concurrent replacement of a justifying sibling (for example this PR's own remap swap, which publishes narrowed coverage) escaped both has_append_drop_conflict (replacement-style is exempt) and has_replaced_identity_conflict (keys on our removed set, not our justification). A stale removal set could therefore land after concurrent coverage withdrawal. Prune transactions now carry an explicit transaction property; on rebase the resolver re-runs derive_superseded_segments against the rebased manifest and replaces the removal set wholesale, aborting via a no-op marker (treated as success) when nothing remains, mirroring the tagged-trim mechanism. The regression test drives exactly the claimed race (stale prune rebasing over a same-uuid narrowed replacement) and fails without the fix.

Trim from a stale view: already handled. The trim is re-derived against the manifest actually being committed onto during every rebase (finish_create_index): the stale outcome is discarded wholesale, and a rebase that leaves nothing to trim aborts via the TAGGED_TRIM_REBASED_TO_NOOP marker instead of committing an empty version. With 867ae45 the prune-then-trim split is now symmetric: both commits re-derive on every attempt and only consume committed state.

@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 13, 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 13, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 18, 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 18, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 19, 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 19, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 22, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

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

This comment was marked as outdated.

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

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 23, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 24, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 24, 2026
LuQQiu added a commit that referenced this pull request Sep 24, 2026
…9107)

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

```text
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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lance-gatekeeper[bot]

This comment was marked as outdated.

lance-gatekeeper[bot]

This comment was marked as outdated.

lance-gatekeeper[bot]

This comment was marked as outdated.

LuQQiu and others added 8 commits September 24, 2026 15:45
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgAshYD7wVzPVdjXRuWPPs
…d table

Address the whole-stack review's merge finding, verified as a coverage
regression rather than a correctness bug: merge already opens the selected
segments through the translating loader, so the merged file holds live
addresses, but the maintenance path still reasoned in terms of the stored
bitmap intersected with live fragments.

- Old-data filters and the "has old data" checks used stored ∩ live. On a
  tagged table a fully rewritten segment has an empty intersection, so its
  translated rows were filtered out of the merge and vector segments were
  classified dormant and skipped. Use the coverage the tagged reader derives
  for each segment instead: that is where the translated rows live.
- The scalar merge committed stored ∩ live as the new bitmap, dropping the
  translated destination coverage so those fragments fell back to scan. On a
  tagged table a segment's bitmap is provenance, not a coverage claim: the
  merged segment now commits the union of the selected segments' stored
  bitmaps, retired sources included, as the vector merge already did. The
  reader stops translating at a live fragment, derives destination coverage
  whenever every contributor is present (so an unselected sibling keeps its
  share), and trim retains the mapping the provenance still names. A
  from-scratch rebuild still commits exactly the live fragments it scanned.

Tests: the pinned "merge drain not yet supported" case becomes the supported
lifecycle (merge, derived coverage of every live fragment, index used, trim,
counts equal an index-disabled scan); an unselected sibling keeps its
translated coverage; vector merge on a tagged table with newly appended rows;
a legacy-format vector file on a tagged table is excluded and scans. Each
fails without the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…im honest

End-to-end leg of admitting mutations on tagged tables: after a stable
partition (i_idx provenance {0, 1} -> destinations {10, 11}) a delta
segment is built over an appended fragment, then a row of one destination
and every even value are deleted, which drops F10 entirely. The
translating segment is kept next to its delta sibling (its stored bitmap
is empty against the live fragments by construction; without the tagged
retention rule it is dropped), every indexed query equals the index-disabled
scan, trim is a no-op while the segment still translates, and draining the
segment afterwards trims the entry as usual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… translation plan

The distributed build flow (`execute_uncommitted` per fragment, then
`merge_existing_index_segments`, then `commit_existing_index_segments`)
failed on a tagged table, in three places: every staged open went through
the snapshot plan, which only lists committed segments; the tagged coverage
and filter builders read "not registered" as "covers nothing" and would
have filtered every row of a staged segment out of the merge; and the
merged coverage went through the v0 handle, which a tagged table does not
have, so it shrank to nothing.

The merge now plans the staged set as one group with the reader's own
algorithm: `plan_staged_segments` runs the steps `fri_query_plan` runs per
committed group (`may_need_translation`, `segment_plans`,
`translation_fingerprint`), so a staged segment translates exactly as it
would once committed, with its stored bitmap as provenance and the other
staged segments as its siblings. That plan is threaded through every scalar
`merge_segments`, the per-segment filters and the opens (the
`*_with_plan` entry points from the consumers PR).
`tagged_segment_coverage` takes the plan: a staged segment's coverage comes
from it, and a segment that is neither staged nor listed in the manifest is
an error, never empty coverage.

Coverage follows the destination model. The per-type merges already emit
the effective union in the live domain, and a provenance bitmap left on a
merged segment is pruned to nothing at commit, so
`remap_merged_segment_coverage` writes each staged segment's planned live
coverage into its bitmap before the merge and reports the coverage as
remapped. A group that covers no rewrite destination completely claims
nothing: those rows stay on the scan path and a warning says so. The v0
handle is never consulted on a tagged table.

The NGram merge reads spill files and translates only through the v0
remapper, so a staged NGram segment whose plan translates is refused
(NotSupported) instead of being merged with stale addresses; the FM merge
rebuilds over the derived coverage.

Tests (`index/create/tests/staged_tagged.rs`): btree and bitmap staged
segments merged and committed across a stable partition claim exactly the
destinations the group covers and answer point queries like the scan; a
lone contributor over half a partition claims nothing and scans; segments
over fragments no rewrite touched merge as identity; the #9421 deferred
compaction flow on a tagged table; and the NGram refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mits

`build_index_metadata_from_segments` prunes from a new segment every
fragment the current manifest no longer lists. Under a tagged history that
is exactly a translating segment's provenance: a segment built before a
stable partition names the retired sources, and its rows translate to the
destinations when it is opened. Pruning them left a committed staged vector
merge (which keeps the provenance union; the vector merge never opens the
dataset) with an empty bitmap that the reader then excluded. The prune now
keeps a retired fragment that is on the tagged entry's lineage; a retired
fragment off the lineage (a bare rewrite of uncovered data, a whole-fragment
delete) holds nothing the segment can reach and is pruned as before.

Tests: staged IVF_FLAT shards (shared centroids) merged and committed
across a stable partition keep {0, 1}, translate to both destinations, and
answer k = 1 and k = 8 like the flat scan, before and after trim
(`staged_tagged.rs`); a user index built before a stable partition and
committed after it lands translating, a partial-coverage or NGram segment
retries, an index off the rewritten fragments lands directly
(`index_over_committed_sp.rs`, exercising the resolver rule from the commit
path PR, which admits CreateIndex on tagged tables only from this PR on);
RewriteColumns on a column with a direct post-rewrite index prunes that
index's bitmap as on an untagged table (`tagged_mutations.rs`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A staged segment was built at its own dataset version. On a tagged table
every commit since then may have moved its rows (a rewrite recording a
transition: the provenance stays and translates) or rewritten an indexed
column in place (the values it holds are stale: that transition's coverage
must be withdrawn). The merge used to translate addresses and project
coverage without knowing either, and stamped the result with the current
version, so a rewrite between the build and the merge left a merged segment
claiming full coverage with pre-rewrite values inside.

`replay_staged_segments` validates the segments up to the snapshot being
merged against with the conflict resolver's own rules: a CreateIndex of the
segments at their oldest build version, checked against every transaction
up to the snapshot (a transition keeps the provenance; an in-place rewrite
withdraws the transition's sources; a bare compaction of a covered fragment
or a partly covered group means the segment must be rebuilt, as does a
version in the window that can no longer be read). `merge_existing_index_
segments` replays first, then plans and merges only what is still valid,
and stamps the result with the validated snapshot. `commit_existing_index_
segments` reads from the oldest validated version (the build for unmerged
segments, the snapshot for merged ones), so the commit loop carries the
segments across anything newer through the same rules. The cost is
proportional to the commits crossed. Tables without a tagged history keep
their existing path. The commit-time validator this replaces is gone; the
coverage prune keeps a retired fragment that the lineage records, and
nothing else.

The replay needs the whole history between the build and the snapshot:
the transaction listing only knows the manifests that still exist, so a
version cleaned up in between (the build kept by a tag, the versions after
it removed) would leave no trace and its column rewrite would go
unreplayed. The versions listed must be exactly build+1 through the
snapshot, else the rebuild error. Segments are replayed per build version,
so a segment built after a rewrite is not withdrawn for it again.

Rebuilding a withdrawn segment: the tagged reader leaves a segment that
derives no coverage out of its listing, so `optimize_indices` could not
open it by uuid, skipped the column, and the withdrawn coverage was never
rebuilt. Maintenance now opens through `open_*_index_for_maintenance`
(`OpenPurpose::Maintenance`): the manifest is consulted for a registered
segment the listing omits, and the remapper opens it as contributing
nothing (an empty translation), so the optimizer replaces it with a
rebuild over the live fragments. The query entries (`open_scalar_index`,
`open_vector_index`, `open_generic_index` by uuid) still see the listing
only and refuse such a segment: no scan was scheduled for its rows, so an
empty answer would drop them.

Tests: segments staged over F0 and F1, `i` (or the vector) of a row in F1
rewritten in place, F1 partitioned, merged and committed: the merged
segment keeps F0 only and the new value comes from the scan (BTree, Bitmap
and IVF_FLAT); a staged segment committed after a destination's column was
rewritten claims nothing; the same with two partitions around the rewrite;
a bare rewrite of a staged source says rebuild; a cast of the indexed
column refuses the stale segment; a user index built before a partition
and committed after a concurrent in-place rewrite lands with the transition
withdrawn and `optimize_indices` rebuilds it; a merged segment with mixed
provenance loses the direct id and the sources alike, is refused by the
query entries by uuid and rebuilt by `optimize_indices`; staged segments
whose history lost a version to cleanup are refused by merge and commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hrough every optimization

Four maintenance paths on a tagged table left indexed rows on the scan path
or refused work they should have done.

A vector segment whose coverage an in-place rewrite withdrew entirely could
not be rebuilt: its empty bitmap made it look like a born-empty definition
(mergeable), and the optimizer then opened the logical index through the
query listing, which excludes such a segment, so `optimize_indices` failed
with "index not found". A segment with index files and no live coverage is
dormant now, whatever its bitmap says (a definition without files is still
the deferred build it always was), and the optimizer opens the logical index
through the maintenance entry (`open_logical_vector_index_for_maintenance`,
listed metadata for listed segments, stored metadata for excluded ones), so
the dormant segment is replaced by a rebuild over the live fragments, its
parameters read from its metadata.

The default optimize's steady-state rebalance published `stored ∩ live` for
the rebalanced vector segment, empty for retired provenance, and the
inverted append/merge kept only `stored ∩ live` of the selected segments as
old data and coverage: on a tagged table both turned every indexed row of a
partition's destinations into a scan. The rebalanced file and the merged
inverted segment are read through the translating loader, so both publish
the stored provenance, and the inverted merge keeps as old data the
coverage the reader derives for each segment, exactly as the scalar merge
already did.

Under a history this build cannot interpret the reader lists no user
segment, so every fragment looked unindexed and every `optimize_indices`
rebuilt the whole table only to have the result excluded again. Optimize
now leaves such a table alone with a warning, as trim, superseded pruning
and remap already refuse it.

The staged-segment replay carried the segments as a CreateIndex through the
resolver's full rules, so a same-name CreateIndex in the window (routine
`optimize_indices` adding a delta to the logical index a distributed
rebuild is replacing) was a conflict and the segments were refused for the
whole build window. In replay mode (`TransactionRebase::for_staged_replay`)
a same-name CreateIndex is no conflict unless it changed what the index is
(its fields or type); the overlap rules settle the rest at commit time.
Ordinary concurrent commits are unchanged.

Tests: a withdrawn IVF_FLAT index rebuilt by optimize (new segment claims
the live fragments, ANN plan, results equal the flat scan) and the scalar
counterpart (the new segment holds the rewritten row); the rebalance and the
inverted append/merge keeping the translated coverage; optimize as a no-op
under an unsupported history (no commit, no segment written, twice); a
distributed rebuild merged and committed across a same-name delta in the
window, and refused across a same-name replacement of another type. Each
fails without its fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… act on it

A registered segment the reader lists nothing for used to be one case,
"missing coverage", and maintenance could only guess at it. There are four
reasons and they want different treatment. The snapshot plan now records
which one holds (`SegmentRemappingPlan::MissingCoverage(reason)`):

- withdrawn: the stored bitmap is empty (every covered fragment withdrawn
  by an in-place rewrite, or a deferred definition). Before this an empty
  bitmap was planned as identity, since it needs no translation, so a merge
  that selected the segment read its pages raw, stale addresses included;
- no derived coverage: the bitmap is not empty but the backtrack derives
  nothing (newer siblings own its direct coverage, or a destination lacks
  contributing sources);
- unsupported: this build cannot translate it (no batch remapper for its
  type, no stored bitmap, or a history with transitions it cannot read);
- corrupt: its details are missing or cannot be interpreted.

A query refuses every kind by uuid, as before: the listing excludes the
segment and no scan was scheduled for its rows. Maintenance reads a
withdrawn or superseded segment as an empty translation (it contributes
nothing and is replaced), skips one this build cannot translate with a
`NotSupported` error the optimizer already turns into a skip, and fails on
corrupt metadata. The reader's batch-remapping check moved into
`segment_supports_batch_remapping` so the plan and the listing agree.

Tests: one table with all four kinds on a tagged snapshot (a superseded
translating segment, an emptied copy, one without a bitmap, one without
details): every query open refuses, maintenance opens the first two as
empty translations, skips the third and fails on the fourth. Fails without
the classification (an emptied segment opens as identity; unknown coverage
would be merged as empty).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@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 with a non-blocking risk.

The two remaining optimizer coverage regressions are fixed: vector rebalance retains source provenance, and Inverted merge uses the reader-derived tagged coverage. The focused regressions and original Inverted merge probe now pass.

I also reproduced a separate partial-withdrawal ANN mismatch in the tagged write-path prerequisite: after an indexed-column rewrite, a surviving segment can expose retired rows to search, even after default optimization. Its withdrawal, identity-planning, and query-prefilter paths are unchanged by this maintenance patch, so I am not requesting a change here. This prerequisite risk needs attention before the stacked feature is rolled out.

Please mark this PR with the breaking-change label.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant