- Status: Accepted
- Date: 2026-06-23
- Deciders: Achref Soua
The engine is single-writer behind one lock at the server (ADR-0006): every
operation — reads included — was serialized behind a single Mutex<Database> and
offloaded with spawn_blocking. Vector search is CPU-bound ANN traversal, so
under read-heavy load that mutex serialized queries that could have run in
parallel. ADR-0053 designed the eventual lock-free MVCC read path (arc-swap
versioned snapshots) and explicitly named a reader–writer lock as the viable
intermediate step toward it.
Two things made reads need &mut self, blocking a plain RwLock swap:
- A read could rebuild the index.
search/hybrid_search/search_multi_vectorlazily rebuilt a collection's index when a prior write left itstale— a deliberate batching optimization (a bulk write defers one rebuild to the next read instead of rebuilding per row). - The full lock-free path is a larger change. A truly lock-free read that
returns payloads cannot share the live
Storeacross readers while the writer mutates it by&mut self— that is aliasing UB. Doing it correctly means the immutable snapshot must own its own segment readers, separate from the writer's store: a real restructuring ofquiver-core's read-ownership model plus epoch reclamation. That is the ADR-0053 XL, not a one-release change.
This ADR takes the honest intermediate step now and records the phased plan to the lock-free target.
Serve reads concurrently behind a RwLock<Database>; keep the single writer.
- Split the read API. Each search gains a
&self*_snapshotmethod (search_snapshot,hybrid_search_snapshot,search_multi_vector_snapshot) that reads the collection's current built index and returns the internalError::IndexStaleif a prior write deferred the rebuild.fetchand the accessors were already&self. The existing&mut selfsearch/hybrid_search/search_multi_vectorstay as thin convenience wrappers (search_with_retry) for embedded, single-threaded callers: they try the snapshot read and, onIndexStale, rebuild via the newDatabase::ensure_indexedand retry. So every existing caller — the in-process MCP server, the tests — is unchanged. - Lock split at the server.
Arc<Mutex<Database>>→Arc<RwLock<Database>>. Writes take the exclusive lock (write_blocking); ordinary reads take the shared lock (read_blocking) and run concurrently. A search usessearch_blocking: the hot path takes only the read lock; if the snapshot read reportsIndexStale, it takes the write lock once, callsensure_indexed, and searches while still holding it (no window for another writer to re-stale it between rebuild and read). After that one rebuild, reads are concurrent again. - Writer and durability unchanged. Still single-writer; the crash gate, WAL,
and
fsyncacknowledgement (ADR-0005) are byte-for-byte unchanged — this moves read visibility, not durability.IndexStaleis an internal control signal caught by the&mut selfwrappers and the server; it never reaches a client.
- + Read throughput scales with cores under read-heavy load instead of serializing on one mutex; the standing "single mutex" caveat is gone.
- + No change to the write path, durability, or crash recovery; no on-disk format change; no migration.
- + Snapshot/
ensure_indexedis the seam the lock-free successor slots into: the readers already call a&selfsnapshot method. - − A
RwLocklets reads run concurrently with each other but a write still excludes readers for its duration, and the rare stale read briefly takes the write lock to rebuild. The fully lock-free path (reads proceed during writes) is the next phase. - − Writer starvation is theoretically possible under a saturating read load
with
std::sync::RwLock(platform-dependent fairness). Acceptable for a write-then-read-heavy vector workload;parking_lot::RwLock(writer-preferring) is the drop-in upgrade if a real workload shows starvation.
- (this ADR) RwLock +
&selfsnapshot reads. Concurrent reads; writer and crash gate unchanged. Done. - Arc-swapped per-collection read snapshot. The writer publishes an
immutable
Arc<ReadSnapshot>(built index + id maps + the immutable sealed segment readers it needs to materialize records) and atomically swaps it on the rebuild/maintenance points. Readersload()theArcwith no lock and traverse it; the old version drops when its last reader finishes (Arc refcount is the coarse epoch guard). This requiresquiver-coreto expose immutable, shareable segment readers so a snapshot can read records without aliasing the writer's&mut Store. - Active-buffer handling + epoch reclamation hardening. Copy-on-write the
small pre-checkpoint active buffer into each snapshot (or layer the read over
immutable-snapshot ⊕ a lock-free read of the active buffer); validate the
swap/reclamation with
loomand property tests before flipping the default.
Granularity stays coarse (one snapshot per collection) — full per-row MVCC with a transaction manager remains rejected as over-built for vector search (ADR-0053).
- Jump straight to lock-free arc-swap (ADR-0053). The target, but it
restructures
quiver-core's read ownership and needs loom/property validation — a multi-release effort. Shipping the RwLock step first delivers concurrent reads now with no correctness risk to the storage engine, behind the same*_snapshotseam the lock-free path will reuse. - Keep the single mutex. Correct and simple, but serializes reads that could run in parallel — the ceiling this ADR lifts.
- Sharded/striped mutex per collection. Reduces cross-collection contention but not intra-collection; the RwLock already lets a hot collection's reads run concurrently.