Skip to content

Buffer multi-dense offsets store to prevent reload corruption - #9501

Merged
agourlay merged 2 commits into
devfrom
buffered-offsets-proto
Jun 18, 2026
Merged

Buffer multi-dense offsets store to prevent reload corruption#9501
agourlay merged 2 commits into
devfrom
buffered-offsets-proto

Conversation

@agourlay

@agourlay agourlay commented Jun 17, 2026

Copy link
Copy Markdown
Member

Fixes #9451 (comment)

Problem

The appendable multi-dense vector storage keeps two independently-flushed ChunkedVectors stores: vectors (flattened rows) and offsets (point key -> {offset, count, capacity}).

Each ChunkedVectors flusher snapshots its status.len at 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 its offsets entry 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 the vectors store'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 m vector was disabled there.

Fix

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 (set) 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 the vectors flusher and the offsets flusher snapshot at the same instant (the storage flusher() 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

BufferedOffsets follows the Gridstore flusher convention (lib/gridstore/src/gridstore/mod.rs, tracker/mod.rs):

  • Pending writes live inside the single lock-guarded store, so reads consult the overlay then the durable bytes under one lock (mirrors Tracker::get).
  • The flusher applies + reconciles pending and builds the backing flusher under the write lock, then runs the durable msync after releasing the lock, so the sync never stalls concurrent reads (mirrors flush_tracker).
  • Batch reads resolve all offsets under a single read lock (resolve_rows, mirroring Tracker::iter).

One deliberate divergence: on a cancelled flush (storage dropped) we return Ok(()) rather than Gridstore's Err(FlushCancelled), since OperationError has no cancellation variant and a dropped storage has nothing to persist.

Testing

  • New unit tests cover the corrupting in-window relocation at both the buffer level (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).
  • Re-enabled the m multivector in the collection model test (INITIAL_ACTIVE + the CreateVectorName op filter); model validation passed.

Notes for reviewers

Reads now go through a parking_lot::RwLock overlay 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 shared read_only::iter_vectors offsets-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

@agourlay
agourlay force-pushed the buffered-offsets-proto branch from bbb53b1 to d0e52dd Compare June 17, 2026 15:44
coderabbitai[bot]

This comment was marked as resolved.

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
agourlay force-pushed the buffered-offsets-proto branch from d0e52dd to fd47ea8 Compare June 17, 2026 15:56
@qdrant qdrant deleted a comment from coderabbitai Bot Jun 18, 2026
@agourlay
agourlay marked this pull request as ready for review June 18, 2026 08:49
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

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.

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.

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>
@agourlay
agourlay merged commit 0b64217 into dev Jun 18, 2026
21 of 22 checks passed
@agourlay
agourlay deleted the buffered-offsets-proto branch June 18, 2026 09:49
@qdrant qdrant deleted a comment from coderabbitai Bot Jun 18, 2026
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>
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.

2 participants