Skip to content

rfc: incremental index reconciliation - #1156

Open
ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:claude/rfc-incremental-index-reconciliation
Open

ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:claude/rfc-incremental-index-reconciliation

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

docs/rfcs/2026-10-25-incremental-index-reconciliation.md, the second step of #840. Index maintenance rebuilds every lagging index from the whole table, so a few appended rows cost a pass over every row on each run, and full-text coverage is never refreshed by maintenance at all; PR #1150 puts index build on that same mechanism.

The RFC makes the work proportional to what changed under one rule: an index gains a segment only over fragments whose row IDs no segment of that index has indexed. Lance 13's prefilter on a stable-row-ID dataset admits a row only through its current live copy, so under the rule every answer equals a full build's, for any number of segments and any merge of them. Fresh fragments get a segment of their own; rows moved by updates are folded in by Lance's filtered BTREE merge or a whole rebuild (full text, vector); a bounded merge policy caps segments per index; full text is maintained like the other kinds, with certificates on subset builds and on merges of certified segments. Compaction needs no index work of its own: Lance's stable-row-ID Rewrite apply moves each index's bitmap to the rewritten fragments. For BTREE and vector indexes the index build and optimize contract does not change; a certified full-text index is maintained instead of reported pending. The storage format does not change.

Every Lance fact the design rests on is cited to the 13.0.0 source (prefilter, subset builder, segment merges, CreateIndex apply, row-ID sequences, the global BM25 scorer), and the evidence section lists the surface guards that pin each one.

For review: the decision to challenge is the soundness argument for the rule and its conservative fresh test (smallest row ID of a fragment above the largest of every covered fragment, else treated as moved); the argument assumes coverage leaves a fragment only when the fragment leaves the manifest. Lance 13 breaks that on in-place column rewrites and on compaction of fragments with newer data overlays; graph writers produce neither, and the RFC says what a writer that does must change first. The row-ID lineage page says indexes reference row addresses and that stable row IDs in indexes are in progress, while the cited 13.0.0 code works in stable row IDs, so the guards pin the code. Lance's CreateIndex apply checks no coverage overlap, so the reconciler must. Open: the merge policy's constants, and whether optimize may defer full-text and vector rebuilds for a small moved tail.

Index maintenance rebuilds every lagging index from the whole table and never refreshes full-text coverage. The RFC adds segments over fragments whose row IDs no segment of the index has indexed, which keeps answers equal to a full build's under Lance 13's stable-row-ID prefilter, merges segments under a bounded policy, and folds moved rows by a filtered BTREE merge or a whole rebuild. Full text is maintained like the other kinds; the index build contract does not change.

ragnorc commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Why

A routine append should not scan every row of a table to refresh an index. On current main (d5bf9fe4), optimize does that scan: TableStore::stage_index_fold rebuilds each lagging BTREE or vector index from the whole table. Full text stays out of that path (TableStore::can_fold_index). The uncovered tail stays correct and grows until omnigraph rebuild-full-text-indexes.

What

The RFC adds one rule. An index gains a segment only over fragments that hold row ids no segment of that index has indexed. I checked the rule against Lance 13.0.0. It matches the pinned prefilter. The decision record still has three gaps. Close them before acceptance.

How the rule holds on the pin

Lance 13.0.0 builds the scalar and full-text mask from the union of the segment bitmaps (DatasetPreFilter::new, prefilter.rs 77-97). On a stable-row-id table, do_create_deletion_mask_row_id (prefilter.rs 176) is an allow-list of row ids whose live copy is in that union. The mask is built when a covered fragment is missing, a covered fragment has a deletion file, or a current fragment sits outside the union (create_deletion_mask_impl). A complete index with no deletions has no mask. Under the rule that state has no stale entry, so the missing mask is safe.

lance_surface_guards.rs::index_delta_segment_serves_stale_entries_of_an_updated_row shows the failure the rule avoids: a second segment over an updated row's new fragment lets the old entry through. Vector search already restricts each segment to its own bitmap (knn.rs 2276-2289 and 2390-2407).

lance::dataset::rowids::load_row_id_sequence is public (dataset.rs pub mod rowids, function at rowids.rs 37). load_row_id_sequences starts at line 194. Graph compaction commits Operation::Rewrite with rewritten_indices: Vec::new() (table_store.rs stage_compaction). Old bitmaps keep old fragment ids. The RFC's missing-fragment arm then treats every uncovered fragment as moved. That is the safe side.

CreateIndex apply removes indexes by uuid and checks covering fields. It does not check names or coverage overlap (lance-table-13.0.0 manifest_build.rs 1157-1178). validate_segment_metadata does (lance-13.0.0 index.rs 116-145), on Lance's commit path and inside merge_existing_index_segments. A detached CreateIndex that OmniGraph builds itself must assert disjoint coverage. That part of the design is right.

The BTREE merge calls build_per_segment_filters (btree.rs 121-122, append.rs 201) and live_row_ids (append.rs 103). The full-text merge on 13.0.0 passes None as the old-data filter (index/scalar/inverted.rs 1000-1006; the None argument is line 1004). build_global_bm25_scorer is lance-index-13.0.0 scalar/inverted.rs 418. replace(true) is the name check at create.rs 376. The subset bitmap is create.rs 665-669. build_scalar_index scans with with_fragments.

The row-id lineage page still says secondary indexes reference row addresses and that stable row ids in indexes are in progress. The 13.0.0 prefilter cited above works in stable row ids. The guards pin the code. That citation is right.

python3 scripts/check-docs.py on this head reports Documentation OK (401 Markdown files checked). The registry row matches the frontmatter. Filename, created, heading, and status match the RFC guide.

Blocking

  1. The summary says the index build and optimize contract stays the same, and that both leave every buildable index covering every current fragment. Open PR feat(cli): build a branch's declared indexes with omnigraph index build #1150 at e6668bc4 says the opposite for full text. Its maintenance guide leaves full-text coverage pending and names index rebuild --kind full-text as the refresh. This RFC is the change that maintains a certified full-text index. Say that.

    "Refused by maintenance" is a different outcome from the code. A search calls verify_index per segment (dataset_index.rs validate_full_text_demand) and returns FullTextIndexRebuildRequired. optimize, and feat(cli): build a branch's declared indexes with omnigraph index build #1150's index build, record pending_indexes and still publish. Keep that pending result for an index with no certificate. A hard failure of the run would change the feat(cli): build a branch's declared indexes with omnigraph index build #1150 contract.

  2. The decision table has no row for one index with a fresh uncovered fragment and a moved uncovered fragment. An append plus an update is that run. A BTREE merge of the old segments plus a segment over only the moved fragments leaves the fresh fragment uncovered. A second published segment over the moved fragment is the stale-entry case the rule exists to avoid. When any uncovered fragment is moved, publish one BTREE segment from a filtered merge whose new input covers every uncovered fragment. Full text and vector already rebuild, which covers both.

    Put the identity and endpoint BTREEs in the same function. foldable_index_lag folds them today. They are not rows of the schema @index list. They are the indexes whose whole rebuild the motivation describes.

  3. The alternatives section says lance#9817 has no scheduled fix. Two fixes merged to Lance main on 2026-10-23: #9785 for scalar search, and #9872 for full-text search and full-text merge (commit 08b6af244849). They are not in the pinned 13.0.0. The compare of tag v14.0.0-rc.2 with that commit diverges, so they are not in that release candidate either. #9872 filters each source segment by its own coverage. The 13.0.0 None filter remains true for this pin.

    Keep the append extension. That is the durable cost fix, and it stays useful after the bump. Mark the fresh test and the full-text whole rebuild as the 13.0.0 fence. Remove that fence in the bump that contains #9785 and #9872. That is the same bump that makes index_delta_segment_serves_stale_entries_of_an_updated_row red.

    Invariant 11, as written, says routine maintenance scales with the rows written since the last run. That holds for an append of fresh fragments. An update still rebuilds full text and vector from every row. The BTREE merge reads every entry of the index. State those costs in the invariant.

Optional

  • On d5bf9fe4, maintain_indices_for_branch builds missing indexes. It does not call stage_index_fold. has_fts_postings_on is true when any trained full-text segment exists, even when a fragment is uncovered. optimize is the path that rebuilds a lagging BTREE or vector. PR feat(cli): build a branch's declared indexes with omnigraph index build #1150 adds TableStore::stage_index_reconciliation for index build. Cite that name. Put feat(cli): build a branch's declared indexes with omnigraph index build #1150 in blocked_on. The names BuiltIndex, built, refreshed, and index rebuild --kind full-text exist on that branch. Shipped main spells the command omnigraph rebuild-full-text-indexes. Issue No CLI command builds a branch's missing declared indexes #840 asks for the missing-index command. It does not specify this cost step.
  • IndexFact stores the union of fragment coverage and no segment count (crates/omnigraph/src/engine/plan_source/indexes.rs). PR rfc: request-aware plan cost #996 prices request waves. K segments are K probes. Extend that fact in the implementation, so the planner has one index inventory. RFC 0046 still says optimize is the only reconciler, and it sends full-text coverage to the rebuild command. Phase 5 has to amend those rows. feat(cli): build a branch's declared indexes with omnigraph index build #1150 already adds index build as a second reconciler.
  • Vector merge_segments in 13.0.0 does build a per-segment row filter (ivf.rs 2473-2496). The shared IVF model is the reason to rebuild (select_steady_state_rebalance: segments with different centroids are never joined). A subset build trains its own centroids, so nearest ranks are not the ranks of one index trained on the whole table. The proposed exact-nearest comparison with an index-free scan is the right check.
  • The unpublished intermediate BTREE segment is an _indices/<uuid> directory that no manifest names. plan_orphans deletes that class of orphan after seven days. Issue storage: safely reclaim obsolete physical __manifest versions and files #644 is the separate __manifest reclamation gap. Retained historical segments follow the same retention as a whole rebuild.
  • optimize commits compaction as its own detached Rewrite, then the index work. One graph publication is one __manifest commit. Say those two table commits. Schema-apply PR rfc: schema apply with live branches #963 counts a row change and ignores index maintenance. Keep this publication index-only so that rule stays true.
  • The certificate change amends the accepted rule in RFC 0043: the engine's builder wrote the segment from rows, all of them or none. write_certificate says the same and refuses a merge. The rollout's disposition note on RFC 0043 is required. Write the new certificate before publication. That matches the recovery reason RFC 0043 recorded.

Tradeoff and liability

The append path removes a cost that grows with table size. It adds a fresh test, a segment budget, and a second way to certify full text. Query work grows with the segment count until a merge. The report has to name K.

Five more index kinds would each add a merge row or a rebuild row. One decision function and one rule keep that growth small. The 13.0.0 fence has to stay easy to delete. A permanent fresh test, after Lance search already applies segment ownership, would be a second copy of a fixed upstream rule.

Correctness stays ahead of cost. The mixed fresh-and-moved row is a correctness gap in the table. The upstream fixes do not weaken the 13.0.0 rule. They change how long that rule should live.

Validation

  • Head reviewed: 3aeb466358ef2918b7b8b491b6dde5ed4f8f9979. Base: d5bf9fe4e31adb4ea15624b05eb42f5a50dbe66e. Detached worktree of refs/pull/1156/head.
  • Lance crates.io trees: lance-13.0.0, lance-index-13.0.0, lance-table-13.0.0.
  • python3 scripts/check-docs.py in that worktree: Documentation OK (401 Markdown files checked).
  • Lance #9785 and #9872: gh pr view shows both MERGED at 2026-10-23T10:44Z. Commit 08b6af244849 is the #9872 merge. Tag v14.0.0-rc.2 compared with that commit is diverged.
  • No review and no review comment on this head at post time.
  • I did not run engine tests. This pull request changes two doc files. I did not rebuild Lance after 13.0.0 to rerun the #9817 reproduction.

Recommendation: revise the three blocking points, then accept the draft. This is a comment review.

Inline comments (also part of this review):

docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 34-39:
Blocking. Open PR #1150 at e6668bc4 leaves full-text coverage pending. Its maintenance guide says only index rebuild --kind full-text refreshes that coverage. This RFC is the change that maintains a certified full-text index. Say that in the summary.

"Refused by maintenance" (line 96) is a different outcome from the code. A search calls verify_index per segment in validate_full_text_demand and returns FullTextIndexRebuildRequired. optimize, and #1150's index build, record pending_indexes and still publish. Keep that pending result for an index with no certificate. A hard failure of the whole run would change the #1150 contract.

docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 179-187:
Blocking. This table has no row for one index that has a fresh uncovered fragment and a moved uncovered fragment. An append plus an update is that run. A BTREE merge of the old segments plus a segment over only the moved fragments leaves the fresh fragment uncovered. A second published segment over the moved fragment is the stale-entry case in index_delta_segment_serves_stale_entries_of_an_updated_row.

When any uncovered fragment is moved, publish one BTREE segment from a filtered merge whose new input covers every uncovered fragment. Full text and vector already rebuild, which covers both.

Include the identity and endpoint BTREEs in this function. foldable_index_lag folds them today. They are not rows of the schema @index list.

docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 305-307:
Blocking. lance#9817 has two fixes on Lance main, both merged 2026-10-23: #9785 (scalar search) and #9872 (full-text search and full-text merge), commit 08b6af244849. They are not in Lance 13.0.0. Tag v14.0.0-rc.2 compared with that commit is diverged, so they are not in that release candidate. #9872 filters each source segment by its own coverage. The None old-data filter is real on the pin (lance-13.0.0 index/scalar/inverted.rs 1004).

Keep the append extension as the durable design. Mark the fresh test and the full-text whole rebuild as the 13.0.0 fence. Remove that fence in the bump that contains #9785 and #9872. That is the same bump that makes index_delta_segment_serves_stale_entries_of_an_updated_row red.

docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 273-275:
Blocking. This bound holds for an append of fresh fragments. An update still rebuilds full text and vector from every row. The BTREE merge reads every entry of the index. State those two costs in this invariant.

docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 42-46:
Optional. On d5bf9fe4, maintain_indices_for_branch builds indexes that are missing. It does not call stage_index_fold. optimize does, after compaction. PR #1150 adds TableStore::stage_index_reconciliation for index build. Cite that name, and put #1150 in blocked_on. Shipped main still spells the rebuild command omnigraph rebuild-full-text-indexes.

State the full-text contract change, add the mixed fresh-and-moved BTREE case, name the identity and endpoint BTREEs, record lance#9785 and lance#9872 and mark the moved-row half as the Lance 13.0.0 fence, state the moved-row costs, and block on PR ModernRelay#1150.

ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Why

A routine append should not read every row to refresh an index. This RFC adds a segment only over fragments whose row ids no segment of that index has indexed. Under the Lance 13.0.0 stable-row-id prefilter, that rule keeps each answer equal to a full build.

What

Commit 1ae0381d answers the review posted on 3aeb4663. The four blocking findings and the six optional findings are resolved in the text. One new blocking finding remains. Revise before acceptance.

How

I read the RFC at 1ae0381dcbaba496721dc72f2b8479abe300aa02 and checked the new sentences against OmniGraph main da1c46368c33876d61615add0ceb6778f9ce2b58, the pinned Lance 13.0.0 sources, lance#9785, lance#9872, PR #1150 at 41e15739, PR #1168 at 6d31796a, and PR #1171 at 612a776b.

Impact

The append rule can stand. The new compaction sentence cannot. An implementation that follows it rebuilds every index after a compaction whose bitmaps already cover the new fragments.

Prior findings

  1. Full-text pending contract. Resolved. The summary now says PR feat(cli): build a branch's declared indexes with omnigraph index build #1150 leaves full-text coverage pending, and this RFC maintains a certified full-text index from index build and optimize. An index with no certificate stays pending. Search still returns FullTextIndexRebuildRequired. That matches feat(cli): build a branch's declared indexes with omnigraph index build #1150: can_fold_index still skips full text, stage_index_reconciliation folds only the lagging foldable indexes, and docs/user/operations/maintenance.md names index rebuild --kind full-text as the coverage refresh. The shipped spelling rebuild-full-text-indexes is named too.

  2. Mixed fresh-and-moved fragments, and the identity and endpoint BTREEs. Resolved. When any uncovered fragment is moved, the decision table builds one BTREE segment over every uncovered fragment and publishes only the filtered merge. A second published segment over the moved fragment is the stale-entry case. The terms, the motivation, and the engine-test bullet include the identity and endpoint BTREEs. On main, plan_index_work_node builds the __id BTREE and plan_index_work_edge_on_dataset builds __id, __src, and __dst. foldable_index_lag folds them, because can_fold_index keeps every non-system BTREE.

  3. The 13.0.0 fence. Resolved. lance#9785 (012e98af, merged 2026-10-23) and lance#9872 (08b6af24, merged 2026-10-23) are outside tag v13.0.0 (0923349b, 2026-10-07). Compares of both v13.0.0 and v14.0.0-rc.2 with 08b6af24 return diverged. The fence section, the alternatives, and the negative full-text guard name the bump that contains those two fixes. The None old-data filter is still the 13.0.0 full-text merge. See the optional line note below.

  4. Invariant 11 costs. Resolved for updates. The work column charges a moved BTREE with the uncovered rows and then every index entry, and it does not scan covered fragments. Full text and vector still rebuild from every row. That is the cost the first review asked to state. The compaction sentence below states a different cost, and that cost is wrong.

The six optional notes are resolved in the text. blocked_on names #1150 and TableStore::stage_index_reconciliation. IndexFact gains a segment count for the PR #996 probe cost. Phase 5 amends the RFC 0046 rows. The vector rebuild reason is the separate IVF model (ivf.rs:407), and the 13.0.0 vector merge does filter each source (ivf.rs:2473). The intermediate BTREE segment is an _indices/<uuid> directory no manifest names. collector.rs reclaims it after UNVERIFIED_THRESHOLD_DAYS (7). Optimize's index step is a second detached table commit in the same graph publication. The certificate is written before publication, and rollout step 3 requires the RFC 0043 disposition note.

Blocking

  1. Compaction rewrites row-id index bitmaps. The new paragraph says the opposite.

On a stable-row-id table, Lance applies Operation::Rewrite with an empty rewritten_indices list by recalculating every row-id index bitmap onto the new fragments (lance-table-13.0.0 manifest_build.rs:1071-1094, debug_assert!(rewritten_indices.is_empty())). results_are_row_addrs is false for BTREE, inverted, and vector indexes (lance-table-13.0.0 format/index.rs:142-153), so those indexes take recalculate_fragment_bitmap. They do not take drop_rewritten_fragments. TableStore::stage_compaction commits that Rewrite (table_store.rs:4378-4384). The guard compacting_a_chosen_fragment_drops_the_bytes_of_dropped_columns asserts that the scalar index covers the rewritten fragment (lance_surface_guards.rs:1444-1464). PR #1168 finding F14 records the same apply path.

After that apply, an index that covered the rewritten fragments still covers the current fragments. foldable_index_lag leaves a complete scalar index alone. The next build leaves it alone. It does not take the moved-fragment action. The evidence bullet that requires that action for every compaction describes a run this pin does not produce.

The missing-fragment arm remains the right fallback when a bitmap still names a fragment the manifest has dropped, or when a sequence cannot be read. OmniGraph compaction rewrites the bitmap, so that arm does not run. State the covered result: index work after compaction is only the fragments the rewritten bitmaps still omit.

Today's optimize pays no index rebuild for that covered result. The new sentence calls the moved-row action the cost today's optimize already pays. An implementation that follows the sentence scans every current row and reads every index entry after a compaction that now does no index read.

The owner of this fact is Lance's Rewrite apply. foldable_index_lag already reads the bitmap Lance wrote. A compaction branch beside that bitmap is a second coverage story. Invariant 12 says the manifest is the source of truth.

Optional

  1. The full-text merge passes None as the old-data filter at lance-13.0.0 index/scalar/inverted.rs:1004. Line 1002 is the empty update stream.

  2. PR rfc: served graph maintenance #1168 index upkeep publishes one segment on every catch-up and treats several live segments as the hazard. This RFC allows K fresh segments under the rule. Name rfc: served graph maintenance #1168, and let this RFC own the segment policy, or the two drafts will implement two policies. rfc: served graph maintenance #1168 F14 also says Lance 14 (lance#9481, lance#10041) makes BTREE address-based, and an empty rewritten_indices Rewrite then drops BTREE coverage. The fence-removal sentence should name that change. Deleting the moved-row path in the bump that contains #9785 and #9872 is safe while compaction still carries row-id coverage.

PR #1171 discovers change-feed rows by fragment identity. It does not share this decision. Its compaction note, that row ids and content are preserved, matches the bitmap rewrite above.

Tradeoff and liability

Correctness stays ahead of cost. The fresh-segment rule is the durable part. It matches the 13.0.0 prefilter (prefilter.rs:176, union at prefilter.rs:77-97). The moved-row BTREE merge is the right 13.0.0 fence for an update: build_per_segment_filters keeps the live row ids of each segment's own fragments (btree.rs:121-122, append.rs:201, append.rs:103), and the 13.0.0 full-text merge does not. Query fan-out grows with the segment count until a merge. The report has to name K.

The compaction sentence adds liability. Five more maintenance drafts that each restate compaction coverage will drift. #1168 F14 and this paragraph already disagree. One sentence that points at the Rewrite apply removes that drift. The fresh test, the segment budget, and the second full-text certificate remain the necessary new state.

Validation

  • Head reviewed: 1ae0381dcbaba496721dc72f2b8479abe300aa02. Base: da1c46368c33876d61615add0ceb6778f9ce2b58. The pull request was open. reviews was empty. The only issue comment was the review on 3aeb4663.
  • Isolated worktree at that commit. python3 scripts/check-docs.py: Documentation OK (406 Markdown files checked).
  • Lance sources: crates.io lance-13.0.0, lance-index-13.0.0, lance-table-13.0.0. Tag v13.0.0 is 0923349baa19fd9af776b7d4626dc758dc610c80 (2026-10-07).
  • gh pr view on lance#9785 and lance#9872: both MERGED at 2026-10-23T10:44Z. gh api repos/lance-format/lance/compare/v13.0.0...08b6af244849 returned diverged (ahead 391, behind 2). The same compare for v14.0.0-rc.2 returned diverged (ahead 113, behind 3). v13.0.0...012e98afb36f returned diverged (ahead 390, behind 2).
  • Related heads: feat(cli): build a branch's declared indexes with omnigraph index build #1150 41e15739639c5472a26744a6760307e1879ac99c, rfc: served graph maintenance #1168 6d31796ab1ef3da102d22a579e699bcc4d5c05be, rfc: discover changes by fragment identity #1171 612a776b6aa54b8c8f1e74fa73eb830ad819a90a.

No .gqt case. The RFC changes no query rows. The contradicted claim is the assertion in compacting_a_chosen_fragment_drops_the_bytes_of_dropped_columns. I read that assertion and the Lance apply it commits. I did not re-run the test. This workspace has no build cache, and the RFC changes no code. A pass would repeat the assertion. It would not show a behavior change.

Recommendation

Revise before acceptance. Correct the compaction coverage sentence and the test bullet that depends on it. The four earlier blocking findings can stand.

Inline on docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 187-190:

Blocking. On a stable-row-id table this Rewrite does the opposite of this sentence.

lance-table-13.0.0 manifest_build.rs:1071-1094 runs when next_row_id is set. It asserts rewritten_indices is empty, then recalculates every row-id index bitmap onto the new fragments. BTREE, inverted, and vector indexes are row-id indexes (format/index.rs:142-153 results_are_row_addrs is false). TableStore::stage_compaction commits this Rewrite (table_store.rs:4378-4384). The guard compacting_a_chosen_fragment_drops_the_bytes_of_dropped_columns asserts that the scalar index covers the rewritten fragment (lance_surface_guards.rs:1444-1464). PR #1168 F14 records the same path.

After that apply, a fully covered index still covers the current fragments. foldable_index_lag leaves it alone. The next build leaves it alone. The evidence bullet near line 408, which requires the moved-fragment action after every compaction, describes a run this pin does not produce.

Keep the missing-fragment arm for a bitmap that still names a dropped fragment. Say that OmniGraph compaction rewrites the bitmap, so index work after it is only the fragments those bitmaps still omit. Today's optimize pays no index rebuild for the covered result. This sentence calls the moved-row action that cost.

Lance's stable-row-ID Rewrite apply moves each row-ID index's bitmap to
the rewritten fragments, so index work after a compaction is only the
fragments the bitmaps still omit; the previous text had every compaction
take the moved-row action. State the rule's assumption that coverage
leaves a fragment only with the fragment, the two Lance applies that
break it, and why graph writers do neither. Name lance#9481 and
lance#10041 in the fence, own the segment policy, and correct the
full-text merge line reference.

ragnorc commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Why

A routine append should not read every row to refresh an index. This RFC adds a segment only over fragments whose row ids no segment of that index has indexed. Commit 25a27f29 corrects the compaction cost from the review on 1ae0381d. That correction holds on the pinned Lance 13.0.0. The new fence puts the BTREE address change on the wrong release.

What

The interdiff against 1ae0381d is one file, docs/rfcs/2026-10-25-incremental-index-reconciliation.md, 80 insertions and 25 deletions. The compaction paragraph, the fresh-test assumption, the fence, the #1168 sentence, and the new guard are the new claims.

How

I read the RFC at 25a27f29d07aaaa483eba84b40a2643f9b605979 and checked the new sentences against OmniGraph origin/main 790174c7, the merge base da1c4636, crates.io Lance 13.0.0, and Lance tag v14.0.0.

Impact

The 13.0.0 rule can stand. An implementer who follows the fence will wait for lance#9785 to handle address-domain BTREE compaction. Lance 14.0.0 already changed that compaction, and current stage_compaction refuses the task.

Prior findings

  1. Compaction rewrites row-id index bitmaps. Resolved for the pin. On a stable-row-id table, lance-table-13.0.0 manifest_build.rs:1071-1094 recalculates every non-address bitmap onto the new fragments when rewritten_indices is empty. BTREE, inverted, and vector details are not address-domain (format/index.rs:142-153). recalculate_fragment_bitmap (index_maintenance.rs:657-689) moves a bitmap when every source fragment of a group is covered, leaves it when none are, and returns an error when a group mixes the two. plan_compaction bins fragments that share one set of segment bitmaps (lance-13.0.0 optimize.rs:949-981), and stage_compaction keeps each task as its own group. A compacted fragment is covered exactly when its sources were, except for the overlay prune the next paragraph names. foldable_index_lag then reads that bitmap. The scalar guard compacting_a_chosen_fragment_drops_the_bytes_of_dropped_columns asserts the rewritten fragment stays covered (lance_surface_guards.rs:1444-1464). Full text and vector take the same branch.

  2. The fresh-test assumption. Holds on current writers. withdraw_in_place_rewrites (index_maintenance.rs:155) drops coverage for an update with fields_modified, a Merge whose data-file path changed, or a DataReplacement. prune_overlay_stale_fields_from_indices (index_maintenance.rs:474) drops a rewritten fragment when an overlay is newer than the index. Keyed updates go through apply_assignments, which writes every field of full_schema (mutation.rs:484). Lance Auto resolves to RewriteRows (merge_insert.rs:2344-2347). validate_known_present_update requires RewriteRows (table_store.rs:6788). Schema evolution's Merge reuses the current fragments and writes no data file (stage_schema_evolution). No production writer builds Operation::DataOverlay. The missing-fragment arm remains the right fallback. OmniGraph compaction does not reach it.

  3. Optional line reference. Resolved. The full-text merge passes None at lance-13.0.0 index/scalar/inverted.rs:1004. Line 1002 is the empty update stream.

  4. Optional segment policy. Resolved as ownership. rfc: served graph maintenance #1168 closed on 2026-10-27 without a merge. Its last text already gives the per-kind rule to this RFC. The address-domain sentence is a new release error, below.

Blocking

  1. The BTREE address change is in Lance 14.0.0. The stale-entry fixes are not. Current compaction refuses the Lance 14 task before any bitmap update.

gh api repos/lance-format/lance/compare/v14.0.0...65cc9daab3ee (#9481) and ...6e442848bf4a (#10041) both return behind with ahead_by: 0. Those commits are in tag v14.0.0 (release commit 3a9665ec3cf6, 2026-10-26). The same compares for 08b6af244849 (#9872) and 012e98afb36f (#9785) return diverged. A later commit that contains #9872 also contains #9481 (compare/65cc9daab3ee...08b6af244849 is ahead, behind_by: 0). The fence says the bump that contains #9785 and #9872 is the bump that contains #9481 and #10041. Lance 14.0.0 already split them.

On that tag, IndexMetadata::supports_eager_remap is true only for a BTREE with index_version >= 1 (format/index.rs:186-190). Lance 13.0.0 writes BTREE version 0 (lance-index-13.0.0 scalar/btree.rs:98). Lance 14 writes version 1 (BTREE_INDEX_VERSION = BTREE_ROW_ADDR_DOMAIN_VERSION). rewrite_files then fills RewriteResult.row_addrs for a stable-row-id table that holds such a segment (optimize.rs at v14.0.0, the records_from_fragment_metadata branch near 2656 and the Some(serialized) return near 2939). TableStore::stage_compaction returns an error when any result has row_addrs (table_store.rs:4353-4357) and does not build the Rewrite.

drop_rewritten_fragments is the apply that runs after that check is gone. At v14.0.0 manifest_build.rs:1090-1134, a non-empty rewritten_indices is applied first, and an address-domain index that was not in that list loses the rewritten fragments. OmniGraph commits frag_reuse_index: None, so the deferred-remap arm does not keep the bitmap. A version-0 BTREE still takes recalculate_fragment_bitmap. Full text and vector are not supports_eager_remap on this tag, so they keep coverage.

The published btree page now says the same split. The ids column is a row address from index format version 1, and an older segment stores a row id (https://lance.org/format/index/scalar/btree/). The row-id lineage page still says stable row ids in indexes are in progress. The 13.0.0 source, not that page, is what the compaction paragraph cites, and that citation is right.

Put the remap, or an explicit decision to keep the row_addrs refusal, on the bump to Lance 14.0.0. Leave the moved-row fence until the later bump that contains #9785 and #9872. Say that a version-0 segment keeps the 13.0.0 bitmap move, and that a segment the Lance 14 builder writes does not. The new guard should expect the new BTREE to lose coverage and full text and vector to keep it. One assertion that requires all three to stay covered goes red for a reason that does not apply to full text or vector.

The same builder is today's stage_index_fold. The refusal is not limited to the new extend path. #1177 serves this optimize path. It does not add a second segment policy.

Optional

  1. The compaction paragraph says a fully covered index is left alone, as today's optimize leaves it. foldable_index_lag skips a complete scalar index, and a complete vector index with one segment (table_store.rs:2879-2883). A vector index with more than one segment is rebuilt even when every fragment is covered. The decision table already rebuilds vector past K. Name the scalar index, and the one-segment vector index, in that sentence.

Tradeoff and liability

Correctness stays ahead of cost. The fresh-segment rule matches the 13.0.0 prefilter. The corrected compaction paragraph follows the bitmap Lance writes, which is the coverage foldable_index_lag already reads. That removes the false cost from the previous text, a full index read after every compaction. Invariant 12 holds. The manifest bitmap is the source of truth. A second compaction branch beside that bitmap would drift.

The fence adds liability. It joins two releases that Lance already shipped apart. The next bump to 14.0.0 fails compaction once any BTREE segment is version 1, while the text still describes a later uncovered-fragment repair. Five more maintenance drafts that copy "the #9785 bump also remaps BTREE" will miss that refusal. One sentence per release keeps the obligation where the code changes.

Query fan-out still grows with the segment count until a merge. The report has to name K. The fresh test, the segment budget, and the second full-text certificate remain the necessary new state. No new stored coverage record is needed while graph writers withdraw coverage only by removing the fragment.

Validation

  • Head reviewed: 25a27f29d07aaaa483eba84b40a2643f9b605979. The pull request was open. Merge base with origin/main: da1c46368c33876d61615add0ceb6778f9ce2b58. git diff --stat 1ae0381dcbab 25a27f29d07a is one file, 80 insertions, 25 deletions.
  • python3 scripts/check-docs.py in a detached worktree at that commit: Documentation OK (406 Markdown files checked).
  • Lance 13.0.0 sources: crates.io lance-13.0.0, lance-index-13.0.0, lance-table-13.0.0.
  • Lance tag compares, gh api repos/lance-format/lance/compare/<tag>...<sha>: v14.0.0...65cc9daab3ee and v14.0.0-rc.2...65cc9daab3ee are behind, ahead_by 0. The same for 6e442848bf4a. v14.0.0...08b6af244849 and v14.0.0...012e98afb36f are diverged. 65cc9daab3ee...08b6af244849 is ahead, behind_by 0.
  • No .gqt case. The RFC changes no query rows. The 13.0.0 coverage claim is the existing assertion in compacting_a_chosen_fragment_drops_the_bytes_of_dropped_columns plus the apply it commits. I read both. I did not re-run the test. A pass would repeat the assertion. It would not show a behavior change. The Lance 14 claim is the v14.0.0 source of rewrite_files and manifest_build.rs, read against stage_compaction. I did not build Lance 14.

No review and no review comment on this head at post time. The two earlier issue comments are the reviews on 3aeb4663 and 1ae0381d.

Recommendation

Revise before acceptance. Split the Lance 14.0.0 compaction obligation from the later stale-entry bump, and name the row_addrs refusal as the failure current code produces. The 13.0.0 compaction paragraph can stand.

Inline on docs/rfcs/2026-10-25-incremental-index-reconciliation.md lines 335-342:

Blocking. Lance 14.0.0 already contains this change. The bump that contains lance#9785 does not have to be the one that introduces it.

gh api repos/lance-format/lance/compare/v14.0.0...65cc9daab3ee (#9481) and ...6e442848bf4a (#10041) return behind with ahead_by 0. Tag v14.0.0 is release commit 3a9665ec3cf6 (2026-10-26). The same compare for 08b6af244849 (#9872) and 012e98afb36f (#9785) returns diverged.

On v14.0.0, supports_eager_remap is a BTREE with index_version >= 1 (format/index.rs:186-190). Lance 13.0.0 writes version 0. Lance 14 writes version 1. rewrite_files then sets row_addrs for a stable-row-id table that holds that segment. TableStore::stage_compaction returns an error on row_addrs (table_store.rs:4353-4357) and does not commit the Rewrite.

drop_rewritten_fragments runs only after that check is removed. At v14.0.0 manifest_build.rs:1090-1134, an address-domain index absent from rewritten_indices loses the rewritten fragments. A version-0 BTREE still follows the fragments. Full text and vector are not eager-remapped on this tag.

State the remap, or the decision to keep the refusal, on the bump to Lance 14.0.0. Keep the moved-row fence for the later bump that contains #9785 and #9872. The new guard should expect a newly built BTREE to lose coverage, and full text and vector to keep it.

Inline on docs/rfcs/2026-10-25-incremental-index-reconciliation.md line 216:

Optional. foldable_index_lag leaves a complete scalar index alone, and a complete vector index with one segment (table_store.rs:2879-2883). A vector index with more than one segment is rebuilt when coverage is already complete. Name that limit here. The decision table already rebuilds vector past K.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant