Skip to content

fix: block stale index entries after partial-schema merge_insert#6514

Open
westonpace wants to merge 1 commit intolance-format:mainfrom
westonpace:fix-index-corruption-partial-schema-merge
Open

fix: block stale index entries after partial-schema merge_insert#6514
westonpace wants to merge 1 commit intolance-format:mainfrom
westonpace:fix-index-corruption-partial-schema-merge

Conversation

@westonpace
Copy link
Copy Markdown
Member

@westonpace westonpace commented Apr 14, 2026

Summary

  • A partial-schema merge_insert that modifies indexed columns drops the affected fragments from the index's fragment_bitmap but leaves stale entries in the index data (btree, vector, inverted). Subsequent queries that use the index find rows via both the stale entries AND the unindexed-fragment scan, producing duplicates or errors.
  • Adds an invalidated_fragment_bitmap field to IndexMetadata (proto + Rust) that tracks fragments removed from the bitmap during prune_updated_fields_from_indices. At query time, all rows from these fragments are blocked by the deletion mask / prefilter so stale index entries are ignored. The bitmap is cleared when the index is rebuilt.
  • Fixes three error categories seen in production with partial-schema merge_insert + btree index:
    • Ambiguous merge inserts: multiple source rows match the same target row (221 occurrences)
    • fragment id N does not exist in the dataset (114 occurrences)
    • Attempt to merge two RecordBatch with different sizes: N != 0 (165 occurrences)
  • Also fixes duplicate results in vector search and full text search after partial-schema merge_insert

Test plan

  • 5 new regression tests covering all error categories:
    • test_partial_merge_insert_stale_index_ambiguous — second partial merge_insert finds same rows via stale btree + unindexed scan
    • test_partial_merge_insert_stale_index_fragment_not_exist — partial merge + update-all + partial merge hits deleted fragment
    • test_partial_merge_insert_stale_index_batch_size_mismatch — partial merge + partial update + partial merge hits deleted rows
    • test_partial_merge_insert_stale_vector_index_duplicates — KNN search returns duplicate rows after partial merge
    • test_partial_merge_insert_stale_fts_index_duplicates — FTS search returns duplicate rows after partial merge
  • All 131 existing merge_insert tests pass
  • Transaction, conflict resolver, scalar index, and lance-table format tests pass
  • Clippy clean

Closes #6283

🤖 Generated with Claude Code

@github-actions github-actions Bot added the bug Something isn't working label Apr 14, 2026
@westonpace
Copy link
Copy Markdown
Member Author

This feature has come up once or twice before. Is there a reason this couldn't be implemented at the object_store level? For example, I think you could enable something like https://github.com/foyer-rs/foyer with an object_store->opendal->foyer path? Then the lance change would just be adding easier ways to enable it? For example, a special URI or methods on the dataset open?

Or is this cache doing something more than pure data caching of the object_store layer?

@westonpace westonpace force-pushed the fix-index-corruption-partial-schema-merge branch 5 times, most recently from df35c75 to 663bdbd Compare April 14, 2026 21:19
@westonpace westonpace marked this pull request as ready for review April 14, 2026 21:21
@@ -132,6 +142,11 @@ impl DeepSizeOf for IndexMetadata {
.map(|fragment_bitmap| fragment_bitmap.serialized_size())
.unwrap_or(0)
+ self.files.deep_size_of_children(context)
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.

is line 144 sort of a no-op at this point? The last line is all that will be returned right and it doesn't make use of line 144?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These lines are all added together (there is a + at the start of line 145)

@westonpace westonpace force-pushed the fix-index-corruption-partial-schema-merge branch from 663bdbd to 92b17e9 Compare April 14, 2026 22:16
Copy link
Copy Markdown
Contributor

@justinrmiller justinrmiller left a comment

Choose a reason for hiding this comment

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

Just a couple of comments to consider.

created_at: curr_index_meta.created_at,
base_id: None,
files: curr_index_meta.files.clone(),
invalidated_fragment_bitmap: None,
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.

For Keep, should invalidated_fragment_bitmap be preserved?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point. Actually, we can go ahead and keep the bitmap in both cases in compaction. I've updated the code. I went ahead and added a unit test as well. If an fragment becomes invalidated we remove it from the index's fragment bitmap and this will prevent the fragment from compacting with other fragments so we should be safe on compaction.

/// Perform a partial-schema merge_insert (only id + value_a) targeting specific id ranges.
/// This causes touched fragments to drop from the index bitmap while btree data retains
/// stale entries.
async fn partial_merge_insert(
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.

invalidated_fragment_bitmap grows unbounded until a full rebuild right? Could that ever become an issue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It will never be larger than the number of fragments in the dataset which is reasonably bounded. In general, an index with any invalidated fragments is going to perform slightly worse than one without because we have to do the masking. That masking will be slightly more expensive when there are more fragments.

until a full rebuild

It will be cleared on an index update (optimize indices), not just a full rebuild.

Copy link
Copy Markdown
Contributor

@LuQQiu LuQQiu left a comment

Choose a reason for hiding this comment

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

Generally looks good, besides the key problems @justinrmiller pointed out

Comment thread rust/lance-table/src/format/index.rs Outdated
&& let Err(e) = bitmap.serialize_into(&mut invalidated_fragment_bitmap)
{
log::error!("Failed to serialize invalidated fragment bitmap: {}", e);
invalidated_fragment_bitmap.clear();
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.

not propograte the error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ah, yeah, this is dangerous. I guess it was copying the logic from above. Still seems we should error in both cases. We will just need to convert this into a TryFrom

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I have converted it to a TryFrom

@westonpace westonpace force-pushed the fix-index-corruption-partial-schema-merge branch 2 times, most recently from d0e534e to d3fd26b Compare April 15, 2026 13:16
A partial-schema merge_insert that modifies indexed columns drops the
affected fragments from the index's fragment_bitmap but leaves stale
entries in the index data (btree, vector, inverted). Subsequent queries
that use the index find rows via the stale entries AND via the
unindexed-fragment scan, producing duplicates or errors.

Add an `invalidated_fragment_bitmap` field to IndexMetadata that tracks
fragments removed from the bitmap. At query time, all rows from these
fragments are blocked by the deletion mask / prefilter so stale index
entries are ignored. The bitmap is cleared when the index is rebuilt.

Fixes three error categories seen in production:
- "Ambiguous merge inserts: multiple source rows match the same target row"
- "fragment id N does not exist in the dataset"
- "Attempt to merge two RecordBatch with different sizes: N != 0"

Also fixes duplicate results in vector search and full text search after
partial-schema merge_insert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@westonpace westonpace force-pushed the fix-index-corruption-partial-schema-merge branch from d3fd26b to 498c428 Compare April 15, 2026 13:50
@westonpace
Copy link
Copy Markdown
Member Author

Merge pending vote on #6529

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

Labels

bug Something isn't working java python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid results from FTS or vector search after a data replacement

4 participants