Buffer multi-dense offsets store to prevent reload corruption - #9501
Merged
Conversation
agourlay
force-pushed
the
buffered-offsets-proto
branch
from
June 17, 2026 15:44
bbb53b1 to
d0e52dd
Compare
The appendable multi-dense storage flushes its `vectors` and `offsets` chunked stores independently. Each flusher snapshots its `status.len` at creation time but msyncs chunk bytes at execution time. A re-upsert that grows a point past its reserved capacity rewrites the point's `offsets` entry in place to a freshly-appended row region; if that lands in a flush's creation-to-execution window, the relocated entry becomes durable while the `vectors` store's recorded length still predates the rows it references. On reload that point is unreadable and a WAL append reuses the rows, clobbering another point. Wrap the offsets store in a write-back buffer (`BufferedOffsets`) so the durable offsets can never reference rows beyond the durable `vectors` length. Offset writes stage in a pending overlay and only land in the durable store while a flush executes; the flusher snapshots the pending set at creation time, so any write after that stays buffered for the next flush. Both flushers snapshot at the same instant, yielding a consistent durable cut. Rows written after the cut are unreferenced garbage the next append overwrites. This prevents the skew at the source instead of patching it on reload, and also closes the residual offsets-length smear. The buffer follows the Gridstore flusher convention: pending writes live inside the single lock-guarded store, reads consult the overlay then the durable bytes under one lock, and the durable msync runs after releasing the write lock so it never stalls concurrent reads. Enable the "m" multivector in the collection model test now that its reload divergence is resolved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agourlay
force-pushed
the
buffered-offsets-proto
branch
from
June 17, 2026 15:56
d0e52dd to
fd47ea8
Compare
agourlay
marked this pull request as ready for review
June 18, 2026 08:49
timvisee
reviewed
Jun 18, 2026
Comment on lines
+184
to
+224
| // Apply + reconcile + build the backing flusher under the write lock, | ||
| // then release the lock and run the durable sync — Gridstore's | ||
| // `flush_tracker` likewise never holds the store lock across the msync, | ||
| // so concurrent reads are not stalled by the sync. | ||
| // | ||
| // The backing flusher runs on every flush, even with an empty snapshot: | ||
| // the underlying mmap sync only writes still-dirty pages, so a sync that | ||
| // failed on an earlier flush is retried on the next one. This matches | ||
| // Gridstore (its tracker store is synced unconditionally) and the sibling | ||
| // `vectors` store; short-circuiting an empty snapshot would instead leave | ||
| // a failed offsets sync stranded while `vectors` recovered, reopening the | ||
| // very skew this buffer closes. | ||
| let store_flusher = { | ||
| let mut inner = inner.write(); | ||
|
|
||
| // Apply in ascending key order so the backing store grows its | ||
| // chunks/length monotonically (appends sit at the current end). | ||
| // Dense key allocation (`new_id = len`) guarantees no gaps: any | ||
| // append key in the snapshot has every lower key already durable | ||
| // or applied in this same pass. | ||
| let mut items: Vec<(VectorOffsetType, MultivectorMmapOffset)> = | ||
| snapshot.iter().map(|(key, entry)| (*key, *entry)).collect(); | ||
| items.sort_unstable_by_key(|(key, _)| *key); | ||
|
|
||
| let hw_counter = HardwareCounterCell::disposable(); | ||
| for (key, entry) in &items { | ||
| inner.store.insert(*key, &[*entry], &hw_counter)?; | ||
| } | ||
|
|
||
| // Drop every key we just persisted from the live pending set, unless | ||
| // it was overwritten with a newer value after the snapshot was taken. | ||
| inner.pending.retain(|key, value| { | ||
| snapshot.get(key).is_none_or(|persisted| persisted != value) | ||
| }); | ||
|
|
||
| // Built here so its `status.len` snapshot covers the entries just | ||
| // applied; executed below, outside the lock. | ||
| inner.store.flusher() | ||
| }; | ||
|
|
||
| store_flusher() |
Member
There was a problem hiding this comment.
I was a little bit confused by the nesting. Hope this comment change is fine, as it clarified it better for me:
Suggested change
| // Apply + reconcile + build the backing flusher under the write lock, | |
| // then release the lock and run the durable sync — Gridstore's | |
| // `flush_tracker` likewise never holds the store lock across the msync, | |
| // so concurrent reads are not stalled by the sync. | |
| // | |
| // The backing flusher runs on every flush, even with an empty snapshot: | |
| // the underlying mmap sync only writes still-dirty pages, so a sync that | |
| // failed on an earlier flush is retried on the next one. This matches | |
| // Gridstore (its tracker store is synced unconditionally) and the sibling | |
| // `vectors` store; short-circuiting an empty snapshot would instead leave | |
| // a failed offsets sync stranded while `vectors` recovered, reopening the | |
| // very skew this buffer closes. | |
| let store_flusher = { | |
| let mut inner = inner.write(); | |
| // Apply in ascending key order so the backing store grows its | |
| // chunks/length monotonically (appends sit at the current end). | |
| // Dense key allocation (`new_id = len`) guarantees no gaps: any | |
| // append key in the snapshot has every lower key already durable | |
| // or applied in this same pass. | |
| let mut items: Vec<(VectorOffsetType, MultivectorMmapOffset)> = | |
| snapshot.iter().map(|(key, entry)| (*key, *entry)).collect(); | |
| items.sort_unstable_by_key(|(key, _)| *key); | |
| let hw_counter = HardwareCounterCell::disposable(); | |
| for (key, entry) in &items { | |
| inner.store.insert(*key, &[*entry], &hw_counter)?; | |
| } | |
| // Drop every key we just persisted from the live pending set, unless | |
| // it was overwritten with a newer value after the snapshot was taken. | |
| inner.pending.retain(|key, value| { | |
| snapshot.get(key).is_none_or(|persisted| persisted != value) | |
| }); | |
| // Built here so its `status.len` snapshot covers the entries just | |
| // applied; executed below, outside the lock. | |
| inner.store.flusher() | |
| }; | |
| store_flusher() | |
| // Apply + reconcile + build the backing flusher under the write lock, | |
| // then release the lock and run the durable sync — Gridstore's | |
| // `flush_tracker` likewise never holds the store lock across the msync, | |
| // so concurrent reads are not stalled by the sync. | |
| // | |
| // The backing flusher runs on every flush, even with an empty snapshot: | |
| // the underlying mmap sync only writes still-dirty pages, so a sync that | |
| // failed on an earlier flush is retried on the next one. This matches | |
| // Gridstore (its tracker store is synced unconditionally) and the sibling | |
| // `vectors` store; short-circuiting an empty snapshot would instead leave | |
| // a failed offsets sync stranded while `vectors` recovered, reopening the | |
| // very skew this buffer closes. | |
| let store_flusher = { | |
| // Apply in ascending key order so the backing store grows its | |
| // chunks/length monotonically (appends sit at the current end). | |
| // Dense key allocation (`new_id = len`) guarantees no gaps: any | |
| // append key in the snapshot has every lower key already durable | |
| // or applied in this same pass. | |
| let mut items: Vec<(VectorOffsetType, MultivectorMmapOffset)> = | |
| snapshot.iter().map(|(key, entry)| (*key, *entry)).collect(); | |
| items.sort_unstable_by_key(|(key, _)| *key); | |
| let mut inner = inner.write(); | |
| let hw_counter = HardwareCounterCell::disposable(); | |
| for (key, entry) in &items { | |
| inner.store.insert(*key, &[*entry], &hw_counter)?; | |
| } | |
| // Drop every key we just persisted from the live pending set, unless | |
| // it was overwritten with a newer value after the snapshot was taken. | |
| inner.pending.retain(|key, value| { | |
| snapshot.get(key).is_none_or(|persisted| persisted != value) | |
| }); | |
| // Create store flusher and drop inner guard before we actually flush | |
| inner.store.flusher() | |
| }; | |
| store_flusher() |
I also moved the inner.write() lock one line down. It is fine to lock it just when we need it, not before.
Member
Author
There was a problem hiding this comment.
Applied in 1e473bf — moved the snapshot build/sort above the write lock and took your comment wording. Thanks, agreed it reads clearer and shortens the lock hold.
timvisee
approved these changes
Jun 18, 2026
Build and sort the pending snapshot before taking the write lock; only the apply + reconcile need it. Shortens the lock hold so concurrent reads block less. Addresses review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
generall
pushed a commit
that referenced
this pull request
Aug 4, 2026
* Buffer multi-dense offsets store to prevent reload corruption The appendable multi-dense storage flushes its `vectors` and `offsets` chunked stores independently. Each flusher snapshots its `status.len` at creation time but msyncs chunk bytes at execution time. A re-upsert that grows a point past its reserved capacity rewrites the point's `offsets` entry in place to a freshly-appended row region; if that lands in a flush's creation-to-execution window, the relocated entry becomes durable while the `vectors` store's recorded length still predates the rows it references. On reload that point is unreadable and a WAL append reuses the rows, clobbering another point. Wrap the offsets store in a write-back buffer (`BufferedOffsets`) so the durable offsets can never reference rows beyond the durable `vectors` length. Offset writes stage in a pending overlay and only land in the durable store while a flush executes; the flusher snapshots the pending set at creation time, so any write after that stays buffered for the next flush. Both flushers snapshot at the same instant, yielding a consistent durable cut. Rows written after the cut are unreferenced garbage the next append overwrites. This prevents the skew at the source instead of patching it on reload, and also closes the residual offsets-length smear. The buffer follows the Gridstore flusher convention: pending writes live inside the single lock-guarded store, reads consult the overlay then the durable bytes under one lock, and the durable msync runs after releasing the write lock so it never stalls concurrent reads. Enable the "m" multivector in the collection model test now that its reload divergence is resolved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Narrow offsets flush write-lock scope Build and sort the pending snapshot before taking the write lock; only the apply + reconcile need it. Shortens the lock hold so concurrent reads block less. Addresses review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #9451 (comment)
Problem
The appendable multi-dense vector storage keeps two independently-flushed
ChunkedVectorsstores:vectors(flattened rows) andoffsets(point key ->{offset, count, capacity}).Each
ChunkedVectorsflusher snapshots itsstatus.lenat creation time but msyncs chunk bytes at execution time. When a re-upsert grows a point past its reserved capacity, the append path relocates the point's rows and rewrites itsoffsetsentry in place to point at the freshly-appended region. If that rewrite lands inside a flush's creation-to-execution window, the relocated offset entry becomes durable while thevectorsstore's recorded length still predates the rows it references.The result is a durable forward reference: on reload that point is unreadable, and a subsequent WAL append reuses the same row region, clobbering the head rows of another point. This surfaced as multi-dense reload divergence in the collection model test, which is why the
mvector was disabled there.Fix
Wrap the offsets store in a write-back buffer (
BufferedOffsets) so the durable offsets can never reference rows beyond the durablevectorslength:set) and only land in the durable store while a flush executes.vectorsflusher and the offsets flusher snapshot at the same instant (the storageflusher()call), yielding a consistent durable cut. Rows written after the cut are unreferenced garbage the next append harmlessly overwrites.This prevents the skew at the source rather than repairing an inconsistent state on reload, and as a side effect closes the residual offsets-length smear the storage had on its own.
Convention
BufferedOffsetsfollows the Gridstore flusher convention (lib/gridstore/src/gridstore/mod.rs,tracker/mod.rs):Tracker::get).flush_tracker).resolve_rows, mirroringTracker::iter).One deliberate divergence: on a cancelled flush (storage dropped) we return
Ok(())rather than Gridstore'sErr(FlushCancelled), sinceOperationErrorhas no cancellation variant and a dropped storage has nothing to persist.Testing
post_snapshot_write_does_not_leak_into_durable_store,deferred_write_commits_on_next_flush) and the storage level (relocation_after_flush_start_is_deferred_not_corrupt).mmultivector in the collection model test (INITIAL_ACTIVE+ theCreateVectorNameop filter); model validation passed.Notes for reviewers
Reads now go through a
parking_lot::RwLockoverlay instead of straight mmap. The hot batch paths resolve under a single lock per batch, but this is still one lock acquisition that did not exist before. The previous sharedread_only::iter_vectorsoffsets-side prefetch is dropped for the appendable variant (rows-side prefetch is retained). This is the cost tradeoff versus a reload-time repair; worth a benchmark if the read path is sensitive.🤖 Generated with Claude Code