Skip to content

fix(core): stop serving stale row id sequences after overwrite - #8078

Draft
wjones127 wants to merge 2 commits into
lance-format:mainfrom
wjones127:fix/row-id-sequence-cache-key
Draft

fix(core): stop serving stale row id sequences after overwrite#8078
wjones127 wants to merge 2 commits into
lance-format:mainfrom
wjones127:fix/row-id-sequence-cache-key

Conversation

@wjones127

@wjones127 wjones127 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fragment ids were reused across generations of a dataset: WriteMode::Overwrite restarted them at 0. The cached row id sequence for a fragment was looked up by fragment id alone, so with a shared Session every reader after an overwrite was handed the previous generation's sequence for the reused id.

That silently corrupts stable row ids: row ids are reported for rows that no longer exist, row counts disagree with the manifest, compaction rechunks more ids than the fragments physically hold, and the same id can look live in two fragments at once.

This PR fixes both halves:

A version-scoped key was proposed for this same spot in #5508 and closed in favour of making fragment ids unique; keying on the manifest version would also fix the overwrite case but would discard the cache on every commit.

This does not fully close #7645: RowIdIndexKey and ManifestKey are still keyed on (uri, version), and version restarts at 1 for a dataset recreated at the same URI.

Breaking changes

Fragment ids no longer restart at 0 after an overwrite — the first fragment written by an overwrite now takes the next id after the dataset's high water mark. Code that assumes a specific fragment id after an overwrite (e.g. dataset.get_fragment(0)) needs to read the ids from the manifest instead.

Fixes #8075

The `Session` metadata cache entry for a fragment's `RowIdSequence` was
keyed only on the fragment id. `WriteMode::Overwrite` restarts fragment
ids at 0, so after an overwrite readers were served the previous
generation's sequence for the reused fragment id, silently corrupting
stable row ids: row ids for rows that no longer exist, row counts that
disagree with the manifest, and compaction rechunking more ids than the
fragments hold.

`RowIdSequenceKey` now also carries the fragment's `row_id_meta`, which
is rewritten whenever the fragment's row ids change. Operations that
leave row ids alone (deletes, added columns) keep hitting the cache.

Fixes lance-format#8075

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the bug Something isn't working label Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/session/caches.rs 53.33% 7 Missing ⚠️
rust/lance/src/dataset/rowids.rs 96.96% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@wjones127

Copy link
Copy Markdown
Contributor Author

I independently wrote this same fix in #8080 (now closed as a duplicate — this PR was first and its tests are better). Two things from that work worth folding in here.

Prior history the reviewer will want. This key has been reported three times from three different directions, and a previous fix in this exact spot was closed in favour of a different design:

So this PR is the lightweight route that #5508 was steered away from. That seems right to me — content-keying is correct independently of whether fragment ids are ever made unique, and it also covers drop + recreate, which the max_fragment_id change would not. But it's worth being explicit that it doesn't close #5540, and that other fragment-id-keyed caches remain exposed to id reuse.

One suggestion on write_key. meta_discriminator() collapses the content to a 64-bit DefaultHasher digest before it reaches the KeyBuilder, which then hashes it into a blake3-128 key. That caps the physical key's effective collision resistance at the inner 64-bit hash (~32-bit birthday bound) and, per the KeyBuilder docs, gives up the encoding it asks for: "In-tree hot paths override this with typed, allocation-free field encoding." Writing the fields directly costs nothing extra:

fn write_key(&self, builder: &mut KeyBuilder) {
    builder.write_u64(self.fragment_id);
    match self.row_id_meta {
        RowIdMeta::Inline(data) => {
            builder.write_variant(0);
            builder.write_bytes(data);
        }
        RowIdMeta::External(file) => {
            builder.write_variant(1);
            builder.write_str(&file.path);
            builder.write_u64(file.offset);
            builder.write_u64(file.size);
        }
    }
}

meta_discriminator() is still needed for the string key(), so this is additive. Not a correctness issue at realistic scale — take it or leave it.

Fragment ids must be globally unique across a dataset's history because
per-fragment objects are cached by id. `WriteMode::Overwrite` restarted
the counter at 0, so the fragments it wrote aliased the ones they
replaced. Restore was fixed the same way in lance-format#5554; overwrite was the
remaining hole.

Fragment ids now continue from the manifest's high water mark on
overwrite, so the first fragment an overwrite writes takes the next
unused id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wjones127 wjones127 changed the title fix(core): key row id sequence cache on fragment contents fix(core): stop serving stale row id sequences after overwrite Jul 29, 2026
@wjones127

Copy link
Copy Markdown
Contributor Author

Both suggestions folded in.

write_key now encodes the metadata fields directly into the KeyBuilder instead of collapsing them to a 64-bit digest first; meta_digest() remains only for the human-readable key() string. The prior history (#5508, #5540, #5554, #7645) is now in the description.

The overwrite half of #5540 is also in this PR: fragment ids no longer restart at 0 on overwrite, which is what #5508 was steered toward and what #5554 already did for restore. That is why the regression test for the key itself now lives in caches.rs — with ids unique across overwrite, the dataset-level tests no longer exercise a colliding id, but the key must still discriminate for the drop + recreate path in #7645.

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

Labels

bug Something isn't working

Projects

None yet

1 participant