Skip to main content

gonzalo_vector/
record_index.rs

1//! A [`VectorIndex`] whose contents survive the process that built it (ADR 0027).
2//!
3//! Vectors live in content-addressed shard blobs named by one
4//! [`VectorManifest`] record. Queries are served from an in-memory
5//! [`MemoryVectorIndex`] hydrated at [`open`](RecordVectorIndex::open); writes
6//! go through to the store under OCC.
7//!
8//! **One writer per index.** A concurrent writer is detected and retried, not
9//! merged, and a second writer's in-memory view can be stale until it reopens.
10
11use crate::shard::{DEFAULT_SHARDS, decode_shard, encode_shard, shard_of};
12use crate::{Match, MemoryVectorIndex, VectorIndex};
13use async_trait::async_trait;
14use gonzalo_core::{
15    BlobStore, ContentHash, CoreError, Identity, KeyPrefix, MANIFEST_TOMBSTONE_RECREATE_REJECTED,
16    Meta, PutResult, Record, RecordKey, RecordKind, Result, Store, VectorManifest,
17};
18use std::collections::{BTreeMap, BTreeSet};
19use std::num::NonZeroU16;
20use std::sync::Arc;
21
22/// How many shard blobs to read at once when opening. Matches
23/// `LIST_READ_CONCURRENCY` in `gonzalo-store-s3`, which settled on 16 for the
24/// same reason: enough parallelism to hide latency, not enough to thrash.
25const SHARD_READ_CONCURRENCY: usize = 16;
26
27/// How many times a commit re-reads and retries before giving up.
28const MAX_COMMIT_ATTEMPTS: usize = 5;
29
30/// One pending change to apply and persist.
31#[derive(Clone, Debug)]
32enum Delta {
33    Upsert(RecordKey, Vec<f32>),
34    Remove(RecordKey),
35}
36
37/// The error a shard id names in its manifest entry but whose blob is absent.
38/// Shared between the single-shard reload path and the concurrent open path
39/// so both name the same failure the same way.
40fn missing_blob_error(key: &RecordKey, id: u16, hash: &ContentHash) -> CoreError {
41    CoreError::Backend(format!(
42        "vector index {key}: shard {id} names blob {} but it is absent",
43        hash.0
44    ))
45}
46
47/// Errors if `manifest`'s embedding space or dimension disagree with `space`
48/// and `dim`, naming both values.
49///
50/// Shared between `open_with_shards` (checking an existing manifest at open)
51/// and `commit`'s conflict arm (checking the winner's manifest, which this
52/// handle may never have read at open — it could have opened a *missing*
53/// index that another handle, declaring a different space, raced to create).
54/// Without the second call site a swapped embedder only gets caught when both
55/// handles happen to open onto an already-existing manifest; two handles that
56/// both open a missing index skip the check entirely and mix spaces silently.
57fn check_space_and_dim(
58    key: &RecordKey,
59    manifest: &VectorManifest,
60    space: &str,
61    dim: usize,
62) -> Result<()> {
63    if manifest.space != space {
64        return Err(CoreError::Invalid(format!(
65            "vector index {key}: stored embedding space is {:?}, opened as {:?}",
66            manifest.space, space
67        )));
68    }
69    if manifest.dim != dim {
70        return Err(CoreError::Invalid(format!(
71            "vector index {key}: stored dimension is {}, opened as {}",
72            manifest.dim, dim
73        )));
74    }
75    Ok(())
76}
77
78/// `put_str16` in `shard.rs` writes a key's namespace and collection length
79/// prefix as a `u16` (the `id` uses a `u32` length and is unaffected). A
80/// namespace or collection longer than 64 KiB would silently truncate that
81/// prefix while the bytes themselves are written in full, so the shard fails
82/// to decode and the WHOLE index becomes unopenable — not just the one bad
83/// entry. Reject such a key up front, before anything is staged.
84fn check_key_len(key: &RecordKey) -> Result<()> {
85    if key.namespace.len() > u16::MAX as usize || key.collection.len() > u16::MAX as usize {
86        return Err(CoreError::Invalid(format!(
87            "vector index: key {key} has a namespace or collection longer than 64 KiB"
88        )));
89    }
90    Ok(())
91}
92
93/// A [`VectorIndex`] whose contents live in `store` rather than only in
94/// process memory (ADR 0027): vectors are bucketed into content-addressed
95/// shard blobs named by one [`VectorManifest`] record, so a query is served
96/// from an in-memory [`MemoryVectorIndex`] hydrated at [`open`](Self::open),
97/// and a write goes through to `store` under optimistic concurrency before it
98/// is reflected in memory.
99///
100/// **One writer per index.** A handle serializes its own `upsert`/`remove`/
101/// `upsert_many` calls, but a *second* handle on the same key is only
102/// detected, not merged: its commit conflicts and retries, reloading whatever
103/// the winner changed. A second handle's queries can still be served from a
104/// shard it hasn't refreshed until it reopens.
105///
106/// **Requires a tokio runtime.** [`open`](Self::open) reads shard blobs
107/// concurrently via `tokio::task::JoinSet::spawn`, which panics outside one.
108pub struct RecordVectorIndex<S> {
109    store: Arc<S>,
110    key: RecordKey,
111    space: String,
112    dim: usize,
113    shards: NonZeroU16,
114    inner: MemoryVectorIndex,
115    /// The identity/origin stamped on manifest commits.
116    meta: Meta,
117    /// The manifest record this handle last observed committed — from `open`,
118    /// from its own last successful commit, or from reloading the winner after
119    /// a lost race. A commit's first attempt builds on this rather than a
120    /// fresh store read, which is what makes a second writer's stale view a
121    /// **real** OCC conflict: without it, every commit would re-read the
122    /// current record right before writing and could never lose a race.
123    last_seen: tokio::sync::Mutex<Option<Record>>,
124    /// Serializes `commit` calls on this handle. `upsert`/`remove` take
125    /// `&self`, so without this two calls on the same handle could interleave
126    /// their reads of `last_seen` and each other's staged writes, including
127    /// the `Committed` arm setting `last_seen` back to an older revision than
128    /// a commit that finished first. Held for the whole of `commit`, never
129    /// across a `std::sync` guard.
130    commit_lock: tokio::sync::Mutex<()>,
131}
132
133impl<S> std::fmt::Debug for RecordVectorIndex<S> {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("RecordVectorIndex")
136            .field("key", &self.key)
137            .field("space", &self.space)
138            .field("dim", &self.dim)
139            .field("shards", &self.shards)
140            .finish_non_exhaustive()
141    }
142}
143
144impl<S: Store + BlobStore + Send + Sync + 'static> RecordVectorIndex<S> {
145    /// Open the index at `key`, hydrating it from `store`.
146    ///
147    /// Errors if a manifest exists whose `space` or `dim` differs from the
148    /// declared one, naming both values, so a swapped embedder surfaces once at
149    /// startup rather than as quietly wrong rankings on every later query. A
150    /// missing manifest starts an empty index created on the first commit.
151    ///
152    /// **Errors if the index was deleted** and its tombstone still holds a
153    /// restore window (gonzalo#340). The error names the key and the two ways
154    /// out — `gonzalo undelete` to restore it, `gonzalo purge` to discard the
155    /// window — and there is deliberately no flag here that skips them: a
156    /// caller starting over purges first, which keeps discarding a restore
157    /// window a single explicit operator action (ADR 0029/0030).
158    pub async fn open(store: S, key: RecordKey, space: &str, dim: usize) -> Result<Self> {
159        Self::open_with_shards(store, key, space, dim, DEFAULT_SHARDS).await
160    }
161
162    /// As [`open`](Self::open), but choosing the shard count for a **new**
163    /// index. An existing manifest's shard count always wins.
164    pub async fn open_with_shards(
165        store: S,
166        key: RecordKey,
167        space: &str,
168        dim: usize,
169        shards: NonZeroU16,
170    ) -> Result<Self> {
171        let store = Arc::new(store);
172        // Read raw. `get` hides tombstones, so `open` used to succeed over a
173        // deleted index, hand back a handle onto an empty in-memory index, and
174        // fail only at the first commit — reporting the problem from a call that
175        // was not the wrong one (gonzalo#340). The trigger below matches
176        // `plan_put`'s exactly, so `open` refuses precisely when the first
177        // commit would have.
178        let existing = match store.get_raw(&key).await? {
179            Some(t) if t.is_tombstone() => {
180                if matches!(
181                    t.deleted_kind,
182                    Some(RecordKind::GraphManifest | RecordKind::VectorManifest)
183                ) {
184                    return Err(CoreError::Invalid(format!(
185                        "vector index {key}: {MANIFEST_TOMBSTONE_RECREATE_REJECTED}"
186                    )));
187                }
188                // A tombstone carrying no manifest kind has no restore window —
189                // one written before gonzalo#327, or by a non-manifest delete —
190                // and `plan_put` lets a create through at such a key. Behave
191                // exactly as before: start fresh, and leave `last_seen` as
192                // `None` so the first commit goes out as a create. Naming the
193                // tombstone's revision instead would make `plan_put` answer
194                // `NotFound`.
195                None
196            }
197            other => other,
198        };
199        let (shards, manifest) = match &existing {
200            None => (shards, None),
201            Some(record) => {
202                let m = VectorManifest::from_body(&record.body)?;
203                check_space_and_dim(&key, &m, space, dim)?;
204                // A stored shard count of zero means the manifest is corrupt.
205                // Surface it here rather than letting it reach `shard_of`.
206                let stored = NonZeroU16::new(m.shards).ok_or_else(|| {
207                    CoreError::Backend(format!("vector index {key}: manifest declares 0 shards"))
208                })?;
209                (stored, Some(m))
210            }
211        };
212
213        let index = Self {
214            store,
215            key,
216            space: space.to_string(),
217            dim,
218            shards,
219            inner: MemoryVectorIndex::new(),
220            meta: Meta::new(Identity::new("gonzalo-vector"), "gonzalo-vector"),
221            last_seen: tokio::sync::Mutex::new(existing),
222            commit_lock: tokio::sync::Mutex::new(()),
223        };
224
225        if let Some(m) = manifest {
226            let ids: Vec<u16> = m.entries.keys().copied().collect();
227            for batch in ids.chunks(SHARD_READ_CONCURRENCY) {
228                // Fetch phase: every read in the batch runs concurrently. A
229                // handle per task — an `Arc` clone is cheap, and a spawned task
230                // needs to own what it reads (`FsStore` is not `Clone`, and
231                // `store.get_blob` needs `&self` for the batch's whole
232                // lifetime otherwise). Mirrors `read_liveness` in
233                // `gonzalo-store-s3` (gonzalo#286).
234                let mut reads = tokio::task::JoinSet::new();
235                for &id in batch {
236                    let store = Arc::clone(&index.store);
237                    let hash = m
238                        .entries
239                        .get(&id)
240                        .expect("id came from these entries' own keys")
241                        .clone();
242                    let index_key = index.key.clone();
243                    reads.spawn(async move {
244                        let bytes = store
245                            .get_blob(&hash)
246                            .await?
247                            .ok_or_else(|| missing_blob_error(&index_key, id, &hash))?;
248                        Ok::<_, CoreError>((id, bytes))
249                    });
250                }
251
252                // Apply phase: decode and mutate `index.inner` sequentially, on
253                // the collected results, so there is no interleaving of writes
254                // to reason about.
255                let mut fetched = Vec::with_capacity(batch.len());
256                while let Some(joined) = reads.join_next().await {
257                    fetched.push(joined.map_err(|e| CoreError::Backend(e.to_string()))??);
258                }
259                for (id, bytes) in fetched {
260                    index.apply_shard_bytes(id, &bytes).await?;
261                }
262            }
263        }
264        Ok(index)
265    }
266
267    /// Borrow the underlying store.
268    pub fn store(&self) -> &S {
269        &self.store
270    }
271
272    /// Load one shard from `manifest` into the in-memory index, replacing
273    /// whatever that shard currently holds.
274    ///
275    /// Used by `commit`'s conflict path to bring memory in line with a shard
276    /// the winner changed since this handle last synced — one shard at a
277    /// time, since a conflict only ever needs to catch up on the handful of
278    /// shards that actually diverged, not the whole manifest the way
279    /// `open_with_shards`'s batch concurrency does.
280    async fn hydrate_shard(&self, manifest: &VectorManifest, id: u16) -> Result<()> {
281        let Some(hash) = manifest.entries.get(&id) else {
282            return self.clear_shard(id).await;
283        };
284        let bytes = self
285            .store
286            .get_blob(hash)
287            .await?
288            .ok_or_else(|| missing_blob_error(&self.key, id, hash))?;
289        self.apply_shard_bytes(id, &bytes).await
290    }
291
292    /// Remove every entry currently held in-memory for shard `id`.
293    async fn clear_shard(&self, id: u16) -> Result<()> {
294        for (key, _) in self.inner.collect_where(|k| shard_of(k, self.shards) == id) {
295            self.inner.remove(&key).await?;
296        }
297        Ok(())
298    }
299
300    /// Decode `bytes` as shard `id` and replace that shard's entries in the
301    /// in-memory index.
302    async fn apply_shard_bytes(&self, id: u16, bytes: &[u8]) -> Result<()> {
303        let (dim, entries) = decode_shard(bytes)?;
304        if dim != self.dim {
305            return Err(CoreError::Backend(format!(
306                "vector index {}: shard {id} has dimension {dim}, manifest says {}",
307                self.key, self.dim
308            )));
309        }
310        self.clear_shard(id).await?;
311        self.inner.upsert_many(entries).await
312    }
313
314    /// What shard `id` would contain if `deltas` committed, computed from the
315    /// *currently committed* in-memory state without mutating it.
316    ///
317    /// Memory only ever reflects a committed manifest (see `commit`), so this
318    /// starts from what's already there for `id` and layers `deltas` on top of
319    /// a copy — never on `self.inner` itself. That is what lets a failed
320    /// commit (a `put_blob`/`put` error, or every attempt conflicting) leave
321    /// the handle serving exactly what the store has, with nothing it never
322    /// actually wrote.
323    fn stage_shard(&self, id: u16, deltas: &[Delta]) -> Vec<(RecordKey, Vec<f32>)> {
324        let mut staged: BTreeMap<RecordKey, Vec<f32>> = self
325            .inner
326            .collect_where(|k| shard_of(k, self.shards) == id)
327            .into_iter()
328            .collect();
329        for delta in deltas {
330            match delta {
331                Delta::Upsert(key, vector) if shard_of(key, self.shards) == id => {
332                    staged.insert(key.clone(), vector.clone());
333                }
334                Delta::Remove(key) if shard_of(key, self.shards) == id => {
335                    staged.remove(key);
336                }
337                _ => {}
338            }
339        }
340        staged.into_iter().collect()
341    }
342
343    /// Confirm the shard blobs this commit referenced still exist, re-uploading
344    /// any that do not.
345    ///
346    /// A sweep can delete a blob between the upload and the manifest commit that
347    /// names it. GC's age filter covers a freshly uploaded blob, but not an
348    /// **old** blob a commit newly references — which content-addressed dedup
349    /// produces whenever a shard returns to earlier content (upsert `x`, then
350    /// remove `x`). See ADR 0028.
351    ///
352    /// Called after the commit lands, so the in-memory shard is the committed
353    /// content and the bytes can be re-encoded on demand. Re-staging is not used
354    /// and no staged bytes are held: retaining them for a 256-shard batch would
355    /// add roughly 150 MB of peak memory for a case that almost never fires.
356    async fn verify_shard_blobs(&self, written: &BTreeMap<u16, ContentHash>) -> Result<()> {
357        for (id, hash) in written {
358            if self.store.has_blob(hash).await? {
359                continue;
360            }
361            let entries = self
362                .inner
363                .collect_where(|k| shard_of(k, self.shards) == *id);
364            let bytes = encode_shard(self.dim, &entries);
365            let restored = self.store.put_blob(&bytes).await?;
366            if &restored != hash {
367                return Err(CoreError::Backend(format!(
368                    "vector index {}: the manifest commit already landed, but shard {id} \
369                     was swept and the re-encoded bytes hash to {} rather than the \
370                     committed {}; the index is unrecoverable",
371                    self.key, restored.0, hash.0
372                )));
373            }
374        }
375        Ok(())
376    }
377
378    /// Persist `deltas` as shard blobs plus an updated manifest, under OCC.
379    ///
380    /// The write is built from `self.last_seen` — what this handle last
381    /// observed committed, not a fresh read of the store — so a second
382    /// writer's stale view genuinely conflicts rather than quietly winning
383    /// because nothing raced it in the same instant.
384    ///
385    /// Memory is never mutated ahead of a commit: each dirty shard is staged
386    /// from the committed state already in memory (`stage_shard`), and
387    /// `deltas` only land in `self.inner` once `store.put` reports
388    /// `Committed`. That keeps memory consistent with the store even when a
389    /// `put_blob`/`put` call errors or every attempt conflicts.
390    ///
391    /// On a conflict, every shard the winner changed *relative to what this
392    /// handle last knew* — not just the shards this commit itself touches —
393    /// is reloaded from the winner's blob into memory, because a shard this
394    /// commit didn't touch but the winner did would otherwise stay stale in
395    /// `self.inner` while `last_seen` claims memory is current; the next
396    /// staged write from that shard would then silently drop the winner's
397    /// vectors with no conflict to catch it. `dirty` is reloaded too even
398    /// when unchanged, since it costs nothing and removes the shard from
399    /// needing separate reasoning. This commit's own deltas are re-staged on
400    /// top on retry, so a lost race costs a retry rather than either side's
401    /// vectors. Shard blobs orphaned by a lost race are left for `gc`,
402    /// exactly as the graph indexer does.
403    ///
404    /// `commit_lock` serializes this against any other `commit` on the same
405    /// handle, so `last_seen` can't be read by one call and clobbered by
406    /// another's `Committed` arm mid-flight.
407    async fn commit(&self, deltas: Vec<Delta>) -> Result<()> {
408        // An empty batch has nothing to persist. Without this, `upsert_many(vec![])`
409        // against an existing manifest still ran the whole commit loop and
410        // produced a real, no-op revision bump with byte-identical content.
411        if deltas.is_empty() {
412            return Ok(());
413        }
414
415        let _commit_guard = self.commit_lock.lock().await;
416
417        let mut dirty = BTreeSet::new();
418        for delta in &deltas {
419            let key = match delta {
420                Delta::Upsert(key, _) | Delta::Remove(key) => key,
421            };
422            dirty.insert(shard_of(key, self.shards));
423        }
424
425        for attempt in 1..=MAX_COMMIT_ATTEMPTS {
426            let base = self.last_seen.lock().await.clone();
427            let base_entries = match &base {
428                Some(record) => VectorManifest::from_body(&record.body)?.entries,
429                None => BTreeMap::new(),
430            };
431
432            let mut blobs = BTreeMap::new();
433            for &id in &dirty {
434                let staged = self.stage_shard(id, &deltas);
435                let hash = self
436                    .store
437                    .put_blob(&encode_shard(self.dim, &staged))
438                    .await?;
439                blobs.insert(id, hash);
440            }
441
442            let mut manifest = match &base {
443                Some(record) => VectorManifest::from_body(&record.body)?,
444                None => VectorManifest::new(&self.space, self.dim, self.shards.get()),
445            };
446            for (id, hash) in &blobs {
447                manifest.entries.insert(*id, hash.clone());
448            }
449
450            let body = manifest.to_body();
451            let (record, expected) = match &base {
452                Some(current) => (
453                    current.update(body, self.meta.clone()),
454                    Some(current.revision.clone()),
455                ),
456                None => (
457                    Record::create(
458                        self.key.clone(),
459                        RecordKind::VectorManifest,
460                        body,
461                        self.meta.clone(),
462                    ),
463                    None,
464                ),
465            };
466
467            match self.store.put(record.clone(), expected).await? {
468                PutResult::Committed(rev) => {
469                    self.apply(&deltas).await?;
470                    // The store's returned revision is authoritative, not the
471                    // one we locally built and sent — it can re-stamp it (for
472                    // example, recreating a record over a tombstone). Caching
473                    // our own guess here instead would make the next commit's
474                    // `expected` mismatch the store for no real reason; it
475                    // would recover on the resulting conflict, but there's no
476                    // reason to pay for a conflict that isn't real.
477                    let mut committed = record;
478                    committed.revision = rev;
479                    *self.last_seen.lock().await = Some(committed);
480                    self.verify_shard_blobs(&blobs).await?;
481                    return Ok(());
482                }
483                PutResult::Conflict(_) if attempt < MAX_COMMIT_ATTEMPTS => {
484                    // Someone else committed since we last synced with the
485                    // store. Bring memory in line with every shard the winner
486                    // changed relative to what we knew, plus every shard this
487                    // commit touches, then retry with our own deltas staged
488                    // back on top of the refreshed memory.
489                    let winner = self.store.get(&self.key).await?.ok_or_else(|| {
490                        CoreError::Backend(format!(
491                            "vector index {}: manifest vanished mid-commit",
492                            self.key
493                        ))
494                    })?;
495                    let winner_manifest = VectorManifest::from_body(&winner.body)?;
496
497                    // The winner may be a manifest this handle never actually
498                    // checked: an `open` onto a *missing* index skips the
499                    // space/dim/shards check entirely (there's nothing to
500                    // check yet), so two handles can each open a missing
501                    // index declaring different embedding spaces, dimensions,
502                    // or shard counts and only discover the disagreement here,
503                    // once one of them has already committed. A handle whose
504                    // configuration disagrees with the committed index must
505                    // stop rather than reload and write into it — reloading a
506                    // shard under a different shard count would compute
507                    // different shard ids for the same keys than the winner
508                    // did, corrupting the manifest; writing a different space
509                    // is exactly the silent cross-space mixing the space tag
510                    // exists to prevent.
511                    check_space_and_dim(&self.key, &winner_manifest, &self.space, self.dim)?;
512                    if winner_manifest.shards != self.shards.get() {
513                        return Err(CoreError::Invalid(format!(
514                            "vector index {}: stored shard count is {}, opened as {}",
515                            self.key,
516                            winner_manifest.shards,
517                            self.shards.get()
518                        )));
519                    }
520
521                    let mut to_hydrate = dirty.clone();
522                    let mut ids: BTreeSet<u16> = base_entries.keys().copied().collect();
523                    ids.extend(winner_manifest.entries.keys().copied());
524                    for id in ids {
525                        if base_entries.get(&id) != winner_manifest.entries.get(&id) {
526                            to_hydrate.insert(id);
527                        }
528                    }
529                    // If a `hydrate_shard` call below fails partway through,
530                    // memory ends up with some shards at the winner's state
531                    // and others still stale, while `last_seen` (set only
532                    // after the loop) stays at the old base. That's fine, not
533                    // a bug: the next commit will still use that same old
534                    // base, so it's guaranteed to conflict again and re-diff
535                    // against whatever the current manifest is at that point,
536                    // which recomputes the full set of shards that actually
537                    // differ — including the ones this attempt already
538                    // brought up to date, re-hydrated harmlessly.
539                    for id in to_hydrate {
540                        self.hydrate_shard(&winner_manifest, id).await?;
541                    }
542                    *self.last_seen.lock().await = Some(winner);
543                }
544                PutResult::Conflict(_) => {
545                    return Err(CoreError::Backend(format!(
546                        "vector index {}: gave up after {MAX_COMMIT_ATTEMPTS} conflicting commits",
547                        self.key
548                    )));
549                }
550            }
551        }
552        unreachable!("the loop returns on the final attempt")
553    }
554
555    /// Apply deltas to the in-memory index only. Called only after a commit
556    /// that produced them has actually landed in the store — see `commit`.
557    async fn apply(&self, deltas: &[Delta]) -> Result<()> {
558        for delta in deltas {
559            match delta {
560                Delta::Upsert(key, vector) => {
561                    self.inner.upsert(key.clone(), vector.clone()).await?
562                }
563                Delta::Remove(key) => self.inner.remove(key).await?,
564            }
565        }
566        Ok(())
567    }
568}
569
570#[async_trait]
571impl<S: Store + BlobStore + Send + Sync + 'static> VectorIndex for RecordVectorIndex<S> {
572    async fn upsert(&self, key: RecordKey, vector: Vec<f32>) -> Result<()> {
573        check_key_len(&key)?;
574        if vector.len() != self.dim {
575            return Err(CoreError::Invalid(format!(
576                "vector index {}: expected dimension {}, got {}",
577                self.key,
578                self.dim,
579                vector.len()
580            )));
581        }
582        self.commit(vec![Delta::Upsert(key, vector)]).await
583    }
584
585    async fn remove(&self, key: &RecordKey) -> Result<()> {
586        self.commit(vec![Delta::Remove(key.clone())]).await
587    }
588
589    /// Overrides the trait's looping default: one batch becomes one `commit`
590    /// call, so a bulk load stages every shard once and writes one manifest
591    /// revision instead of one per vector. Dimensions are validated up front,
592    /// before anything is staged, so a bad batch fails without touching the
593    /// store at all.
594    async fn upsert_many(&self, items: Vec<(RecordKey, Vec<f32>)>) -> Result<()> {
595        for (key, vector) in &items {
596            check_key_len(key)?;
597            if vector.len() != self.dim {
598                return Err(CoreError::Invalid(format!(
599                    "vector index {}: {key} has dimension {}, index is {}",
600                    self.key,
601                    vector.len(),
602                    self.dim
603                )));
604            }
605        }
606        self.commit(
607            items
608                .into_iter()
609                .map(|(key, vector)| Delta::Upsert(key, vector))
610                .collect(),
611        )
612        .await
613    }
614
615    async fn query(&self, query: &[f32], k: usize, filter: &KeyPrefix) -> Result<Vec<Match>> {
616        self.inner.query(query, k, filter).await
617    }
618
619    async fn keys(&self, filter: &KeyPrefix) -> Result<Vec<RecordKey>> {
620        self.inner.keys(filter).await
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use gonzalo_core::{
628        BlobStore, ContentHash, DeleteResult, Identity, Meta, PutResult, Record, RecordKind,
629        Revision, Store,
630    };
631    use gonzalo_store_fs::FsStore;
632    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
633    use std::time::Duration;
634    use tempfile::TempDir;
635
636    fn tmp() -> TempDir {
637        TempDir::new().unwrap()
638    }
639
640    /// A fresh handle onto the same directory. `FsStore` is not `Clone`, and a
641    /// second handle is what a restart actually looks like anyway.
642    fn fs(dir: &TempDir) -> FsStore {
643        FsStore::new(dir.path())
644    }
645
646    /// Wraps a real `FsStore` and instruments `get_blob` so a test can observe
647    /// how many reads were in flight at once, proving (or disproving) that
648    /// `open` actually overlaps its shard reads rather than merely claiming to.
649    struct CountingStore {
650        inner: FsStore,
651        in_flight: std::sync::Arc<AtomicUsize>,
652        max_in_flight: std::sync::Arc<AtomicUsize>,
653    }
654
655    #[async_trait]
656    impl Store for CountingStore {
657        async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
658            self.inner.get(key).await
659        }
660        async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
661            self.inner.put(record, expected).await
662        }
663        async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
664            self.inner.list(prefix).await
665        }
666        async fn delete_as(
667            &self,
668            key: &RecordKey,
669            expected: Option<Revision>,
670            author: Option<Identity>,
671        ) -> Result<DeleteResult> {
672            self.inner.delete_as(key, expected, author).await
673        }
674        async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
675            self.inner.get_raw(key).await
676        }
677        async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
678            self.inner.list_raw(prefix).await
679        }
680        async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
681            self.inner.put_raw(record, expected).await
682        }
683        async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
684            self.inner.purge(key, expected).await
685        }
686    }
687
688    #[async_trait]
689    impl BlobStore for CountingStore {
690        async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
691            self.inner.put_blob(content).await
692        }
693
694        async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
695            let current = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
696            self.max_in_flight.fetch_max(current, Ordering::SeqCst);
697            // Long enough that every task in a batch has had the chance to
698            // record its own increment before any of them proceeds, so the
699            // observed maximum reflects real overlap, not a lucky race.
700            tokio::time::sleep(Duration::from_millis(20)).await;
701            let result = self.inner.get_blob(hash).await;
702            self.in_flight.fetch_sub(1, Ordering::SeqCst);
703            result
704        }
705
706        async fn list_blobs(&self) -> Result<Vec<gonzalo_core::BlobEntry>> {
707            self.inner.list_blobs().await
708        }
709
710        async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
711            self.inner.delete_blob(hash).await
712        }
713    }
714
715    /// Wraps a real `FsStore` but fails every `put_blob`, so a commit's
716    /// shard-write step always errors. Used to prove a failed commit leaves
717    /// memory exactly matching the store: deltas must not have already landed
718    /// in `self.inner` when the write meant to persist them never lands.
719    struct FailingBlobStore {
720        inner: FsStore,
721    }
722
723    #[async_trait]
724    impl Store for FailingBlobStore {
725        async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
726            self.inner.get(key).await
727        }
728        async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
729            self.inner.put(record, expected).await
730        }
731        async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
732            self.inner.list(prefix).await
733        }
734        async fn delete_as(
735            &self,
736            key: &RecordKey,
737            expected: Option<Revision>,
738            author: Option<Identity>,
739        ) -> Result<DeleteResult> {
740            self.inner.delete_as(key, expected, author).await
741        }
742        async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
743            self.inner.get_raw(key).await
744        }
745        async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
746            self.inner.list_raw(prefix).await
747        }
748        async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
749            self.inner.put_raw(record, expected).await
750        }
751        async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
752            self.inner.purge(key, expected).await
753        }
754    }
755
756    #[async_trait]
757    impl BlobStore for FailingBlobStore {
758        async fn put_blob(&self, _content: &[u8]) -> Result<ContentHash> {
759            Err(CoreError::Backend("simulated blob store failure".into()))
760        }
761
762        async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
763            self.inner.get_blob(hash).await
764        }
765
766        async fn list_blobs(&self) -> Result<Vec<gonzalo_core::BlobEntry>> {
767            self.inner.list_blobs().await
768        }
769
770        async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
771            self.inner.delete_blob(hash).await
772        }
773    }
774
775    fn index_key() -> RecordKey {
776        VectorManifest::key("ns", "memories")
777    }
778
779    /// Deletes a chosen blob the moment a record `put` lands — a deterministic
780    /// stand-in for a GC sweep hitting the window between a writer's blob
781    /// upload and its manifest commit.
782    struct SweepsOnPut {
783        inner: FsStore,
784        victim: std::sync::Mutex<Option<ContentHash>>,
785    }
786
787    #[async_trait]
788    impl Store for SweepsOnPut {
789        async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
790            self.inner.get(key).await
791        }
792        async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
793            let victim = self.victim.lock().unwrap().take();
794            if let Some(hash) = victim {
795                self.inner.delete_blob(&hash).await?;
796            }
797            self.inner.put(record, expected).await
798        }
799        async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
800            self.inner.list(prefix).await
801        }
802        async fn delete_as(
803            &self,
804            key: &RecordKey,
805            expected: Option<Revision>,
806            author: Option<Identity>,
807        ) -> Result<DeleteResult> {
808            self.inner.delete_as(key, expected, author).await
809        }
810        async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
811            self.inner.get_raw(key).await
812        }
813        async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
814            self.inner.list_raw(prefix).await
815        }
816        async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
817            self.inner.put_raw(record, expected).await
818        }
819        async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
820            self.inner.purge(key, expected).await
821        }
822    }
823
824    #[async_trait]
825    impl BlobStore for SweepsOnPut {
826        async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
827            self.inner.put_blob(content).await
828        }
829
830        async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
831            self.inner.get_blob(hash).await
832        }
833
834        async fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
835            self.inner.has_blob(hash).await
836        }
837
838        async fn list_blobs(&self) -> Result<Vec<gonzalo_core::BlobEntry>> {
839            self.inner.list_blobs().await
840        }
841
842        async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
843            self.inner.delete_blob(hash).await
844        }
845    }
846
847    #[tokio::test]
848    async fn a_commit_restores_a_shard_blob_swept_behind_its_back() {
849        let dir = tmp();
850        let key = index_key();
851        let k = RecordKey::new("ns", "coll", "a");
852        // A second key in the SAME shard as `k`, so that removing it returns the
853        // shard to its earlier content and re-references the earlier blob.
854        let shard = shard_of(&k, DEFAULT_SHARDS);
855        let x = (0..)
856            .map(|n| RecordKey::new("ns", "coll", format!("x{n}")))
857            .find(|c| shard_of(c, DEFAULT_SHARDS) == shard)
858            .unwrap();
859
860        // Commit 1: shard holds {a}; its blob is B1.
861        let idx = RecordVectorIndex::open(fs(&dir), key.clone(), "space-a", 3)
862            .await
863            .unwrap();
864        idx.upsert(k.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
865        let manifest = fs(&dir).get(&key).await.unwrap().unwrap();
866        let b1 = VectorManifest::from_body(&manifest.body)
867            .unwrap()
868            .entries
869            .values()
870            .next()
871            .unwrap()
872            .clone();
873        // Commit 2: shard holds {a, x}; B1 is now unreferenced.
874        idx.upsert(x.clone(), vec![0.0, 1.0, 0.0]).await.unwrap();
875        drop(idx);
876
877        // Commit 3: remove x, returning the shard to {a} and re-referencing the
878        // old B1 — deleted by a sweep just as the manifest lands.
879        let store = SweepsOnPut {
880            inner: fs(&dir),
881            victim: std::sync::Mutex::new(Some(b1.clone())),
882        };
883        let idx = RecordVectorIndex::open(store, key.clone(), "space-a", 3)
884            .await
885            .unwrap();
886        idx.remove(&x).await.unwrap();
887        drop(idx);
888
889        // The re-check must have put the bytes back, so the index still opens.
890        let reopened = RecordVectorIndex::open(fs(&dir), key, "space-a", 3)
891            .await
892            .unwrap();
893        let keys = reopened.keys(&KeyPrefix::default()).await.unwrap();
894        assert_eq!(keys, vec![k]);
895        assert!(fs(&dir).has_blob(&b1).await.unwrap());
896    }
897
898    /// Which way the re-upload inside `verify_shard_blobs` is made to fail.
899    enum ReuploadFault {
900        /// The bytes are stored, but `put_blob` reports a hash that is not
901        /// theirs — standing in for a re-encode that does not reproduce the
902        /// committed shard.
903        WrongHash,
904        /// `put_blob` fails outright.
905        Error,
906    }
907
908    /// Sabotages only the re-upload that `verify_shard_blobs` performs.
909    ///
910    /// `verify_shard_blobs` is the sole caller of `has_blob` in this module,
911    /// and the commit path reaches `put_blob` without ever asking about
912    /// presence. So "start misbehaving once `has_blob` has been asked" targets
913    /// the verify path exactly, leaving the commit that precedes it honest —
914    /// without which the shard hashes under verification would themselves be
915    /// the corrupt ones, and the guard would never fire.
916    ///
917    /// `has_blob` also answers `false`, which is what a swept shard looks like
918    /// and what makes verify take the re-encode branch at all.
919    struct FailsTheReupload {
920        inner: FsStore,
921        fault: ReuploadFault,
922        verifying: AtomicBool,
923    }
924
925    impl FailsTheReupload {
926        fn new(inner: FsStore, fault: ReuploadFault) -> Self {
927            Self {
928                inner,
929                fault,
930                verifying: AtomicBool::new(false),
931            }
932        }
933    }
934
935    #[async_trait]
936    impl Store for FailsTheReupload {
937        async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
938            self.inner.get(key).await
939        }
940        async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
941            self.inner.put(record, expected).await
942        }
943        async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
944            self.inner.list(prefix).await
945        }
946        async fn delete_as(
947            &self,
948            key: &RecordKey,
949            expected: Option<Revision>,
950            author: Option<Identity>,
951        ) -> Result<DeleteResult> {
952            self.inner.delete_as(key, expected, author).await
953        }
954        async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
955            self.inner.get_raw(key).await
956        }
957        async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
958            self.inner.list_raw(prefix).await
959        }
960        async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
961            self.inner.put_raw(record, expected).await
962        }
963        async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
964            self.inner.purge(key, expected).await
965        }
966    }
967
968    #[async_trait]
969    impl BlobStore for FailsTheReupload {
970        async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
971            if !self.verifying.load(Ordering::SeqCst) {
972                return self.inner.put_blob(content).await;
973            }
974            match self.fault {
975                ReuploadFault::WrongHash => {
976                    self.inner.put_blob(content).await?;
977                    Ok(ContentHash::of(b"not the bytes that were stored"))
978                }
979                ReuploadFault::Error => Err(CoreError::Backend("blob store is unreachable".into())),
980            }
981        }
982
983        async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
984            self.inner.get_blob(hash).await
985        }
986
987        /// Answers `false` — a swept shard — and arms the fault.
988        async fn has_blob(&self, _hash: &ContentHash) -> Result<bool> {
989            self.verifying.store(true, Ordering::SeqCst);
990            Ok(false)
991        }
992
993        async fn list_blobs(&self) -> Result<Vec<gonzalo_core::BlobEntry>> {
994            self.inner.list_blobs().await
995        }
996
997        async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
998            self.inner.delete_blob(hash).await
999        }
1000    }
1001
1002    /// A shard swept after the manifest landed, whose re-encode does not
1003    /// reproduce the committed bytes, leaves the index unrecoverable: the
1004    /// manifest names a hash no one can now produce. The commit must say so
1005    /// rather than report success over an index that will not open.
1006    #[tokio::test]
1007    async fn a_reupload_that_hashes_differently_is_unrecoverable() {
1008        let dir = tmp();
1009        let k = RecordKey::new("ns", "coll", "a");
1010        let store = FailsTheReupload::new(fs(&dir), ReuploadFault::WrongHash);
1011        let idx = RecordVectorIndex::open(store, index_key(), "space-a", 3)
1012            .await
1013            .unwrap();
1014
1015        let err = idx
1016            .upsert(k.clone(), vec![1.0, 0.0, 0.0])
1017            .await
1018            .expect_err("a shard the manifest names but no one can reproduce is unrecoverable");
1019
1020        let msg = err.to_string();
1021        assert!(msg.contains("unrecoverable"), "{msg}");
1022        assert!(
1023            msg.contains(&format!("shard {}", shard_of(&k, DEFAULT_SHARDS))),
1024            "the message names the shard that cannot be restored: {msg}"
1025        );
1026    }
1027
1028    /// A re-upload that fails outright propagates rather than being swallowed.
1029    /// The shard really is missing, so reporting the commit as a success would
1030    /// leave the caller believing an index that cannot open.
1031    #[tokio::test]
1032    async fn a_failed_reupload_propagates() {
1033        let dir = tmp();
1034        let k = RecordKey::new("ns", "coll", "a");
1035        let store = FailsTheReupload::new(fs(&dir), ReuploadFault::Error);
1036        let idx = RecordVectorIndex::open(store, index_key(), "space-a", 3)
1037            .await
1038            .unwrap();
1039
1040        let err = idx
1041            .upsert(k, vec![1.0, 0.0, 0.0])
1042            .await
1043            .expect_err("a failed shard re-upload is not a successful commit");
1044        assert!(err.to_string().contains("unreachable"), "{err}");
1045    }
1046
1047    #[tokio::test]
1048    async fn opening_a_missing_index_starts_empty() {
1049        let dir = tmp();
1050        let s = fs(&dir);
1051        let idx = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1052            .await
1053            .unwrap();
1054        assert!(idx.keys(&KeyPrefix::default()).await.unwrap().is_empty());
1055    }
1056
1057    #[tokio::test]
1058    async fn opening_hydrates_vectors_from_shard_blobs() {
1059        let dir = tmp();
1060        let s = fs(&dir);
1061        let k = RecordKey::new("ns", "coll", "a");
1062
1063        // Hand-build a one-shard index so the load path is tested without
1064        // depending on the write path, which does not exist yet.
1065        let entries = vec![(k.clone(), vec![1.0, 0.0, 0.0])];
1066        let hash = s
1067            .put_blob(&crate::shard::encode_shard(3, &entries))
1068            .await
1069            .unwrap();
1070        let mut vm = VectorManifest::new("space-a", 3, DEFAULT_SHARDS.get());
1071        vm.entries.insert(shard_of(&k, DEFAULT_SHARDS), hash);
1072        let rec = Record::create(
1073            index_key(),
1074            RecordKind::VectorManifest,
1075            vm.to_body(),
1076            Meta::new(Identity::new("test"), "test"),
1077        );
1078        assert!(matches!(
1079            s.put(rec, None).await.unwrap(),
1080            PutResult::Committed(_)
1081        ));
1082
1083        let idx = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1084            .await
1085            .unwrap();
1086        let hits = idx
1087            .query(&[1.0, 0.0, 0.0], 5, &KeyPrefix::default())
1088            .await
1089            .unwrap();
1090        assert_eq!(hits.len(), 1);
1091        assert_eq!(hits[0].key, k);
1092    }
1093
1094    // The realistic failure this guards: a swapped embedder. Two models can share
1095    // a dimension (all-MiniLM-L6-v2 and bge-small are both 384), so dimension
1096    // alone would let mismatched vectors score against each other forever.
1097    #[tokio::test]
1098    async fn opening_with_a_different_space_errors_naming_both() {
1099        let dir = tmp();
1100        let s = fs(&dir);
1101        let vm = VectorManifest::new("space-a", 3, DEFAULT_SHARDS.get());
1102        let rec = Record::create(
1103            index_key(),
1104            RecordKind::VectorManifest,
1105            vm.to_body(),
1106            Meta::new(Identity::new("test"), "test"),
1107        );
1108        assert!(matches!(
1109            s.put(rec, None).await.unwrap(),
1110            PutResult::Committed(_)
1111        ));
1112
1113        let err = RecordVectorIndex::open(s, index_key(), "space-b", 3)
1114            .await
1115            .unwrap_err()
1116            .to_string();
1117        assert!(err.contains("space-a"), "stored space missing from: {err}");
1118        assert!(
1119            err.contains("space-b"),
1120            "declared space missing from: {err}"
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn opening_with_a_different_dim_errors_naming_both() {
1126        let dir = tmp();
1127        let s = fs(&dir);
1128        let vm = VectorManifest::new("space-a", 3, DEFAULT_SHARDS.get());
1129        let rec = Record::create(
1130            index_key(),
1131            RecordKind::VectorManifest,
1132            vm.to_body(),
1133            Meta::new(Identity::new("test"), "test"),
1134        );
1135        assert!(matches!(
1136            s.put(rec, None).await.unwrap(),
1137            PutResult::Committed(_)
1138        ));
1139
1140        let err = RecordVectorIndex::open(s, index_key(), "space-a", 8)
1141            .await
1142            .unwrap_err()
1143            .to_string();
1144        assert!(err.contains('3'), "stored dim missing from: {err}");
1145        assert!(err.contains('8'), "declared dim missing from: {err}");
1146    }
1147
1148    // A manifest naming a blob that is gone means gc swept a live blob or a write
1149    // was lost. Opening short and quiet would turn that into missing search hits.
1150    #[tokio::test]
1151    async fn a_missing_shard_blob_is_a_loud_error() {
1152        let dir = tmp();
1153        let s = fs(&dir);
1154        let mut vm = VectorManifest::new("space-a", 3, DEFAULT_SHARDS.get());
1155        vm.entries
1156            .insert(7, gonzalo_core::ContentHash::of(b"never stored"));
1157        let rec = Record::create(
1158            index_key(),
1159            RecordKind::VectorManifest,
1160            vm.to_body(),
1161            Meta::new(Identity::new("test"), "test"),
1162        );
1163        assert!(matches!(
1164            s.put(rec, None).await.unwrap(),
1165            PutResult::Committed(_)
1166        ));
1167
1168        let err = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1169            .await
1170            .unwrap_err()
1171            .to_string();
1172        assert!(err.contains('7'), "shard id missing from: {err}");
1173    }
1174
1175    // #323 fix-round-1: `SHARD_READ_CONCURRENCY` claimed shard reads on open
1176    // overlap. A chunked-but-sequential loop would make that false while still
1177    // naming the s3 constant it was modeled on, which is worse than an honest
1178    // sequential loop. Prove the overlap actually happens rather than trusting
1179    // the doc comment.
1180    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1181    async fn opening_reads_shard_blobs_concurrently() {
1182        let dir = tmp();
1183        let inner = fs(&dir);
1184        let in_flight = std::sync::Arc::new(AtomicUsize::new(0));
1185        let max_in_flight = std::sync::Arc::new(AtomicUsize::new(0));
1186        let s = CountingStore {
1187            inner,
1188            in_flight: std::sync::Arc::clone(&in_flight),
1189            max_in_flight: std::sync::Arc::clone(&max_in_flight),
1190        };
1191
1192        // Several independent shard blobs, so the open path has more than one
1193        // blob to fetch and a real chance to overlap them.
1194        let mut vm = VectorManifest::new("space-a", 3, DEFAULT_SHARDS.get());
1195        for shard_id in 0..4u16 {
1196            let k = RecordKey::new("ns", "coll", format!("k{shard_id}"));
1197            let entries = vec![(k, vec![1.0, 0.0, 0.0])];
1198            let hash = s
1199                .put_blob(&crate::shard::encode_shard(3, &entries))
1200                .await
1201                .unwrap();
1202            vm.entries.insert(shard_id, hash);
1203        }
1204        let rec = Record::create(
1205            index_key(),
1206            RecordKind::VectorManifest,
1207            vm.to_body(),
1208            Meta::new(Identity::new("test"), "test"),
1209        );
1210        assert!(matches!(
1211            s.put(rec, None).await.unwrap(),
1212            PutResult::Committed(_)
1213        ));
1214
1215        RecordVectorIndex::open(s, index_key(), "space-a", 3)
1216            .await
1217            .unwrap();
1218
1219        let observed = max_in_flight.load(Ordering::SeqCst);
1220        assert!(
1221            observed > 1,
1222            "expected overlapping shard reads, saw a max in-flight of {observed}"
1223        );
1224    }
1225
1226    // The headline claim of the whole ticket.
1227    #[tokio::test]
1228    async fn vectors_survive_reopening_the_index() {
1229        let dir = tmp();
1230        let s = fs(&dir);
1231        let k = RecordKey::new("ns", "coll", "a");
1232
1233        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1234            .await
1235            .unwrap();
1236        idx.upsert(k.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1237        drop(idx);
1238
1239        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1240            .await
1241            .unwrap();
1242        let hits = reopened
1243            .query(&[1.0, 0.0, 0.0], 5, &KeyPrefix::default())
1244            .await
1245            .unwrap();
1246        assert_eq!(hits.len(), 1);
1247        assert_eq!(hits[0].key, k);
1248    }
1249
1250    #[tokio::test]
1251    async fn a_removed_vector_stays_removed_after_reopening() {
1252        let dir = tmp();
1253        let s = fs(&dir);
1254        let k = RecordKey::new("ns", "coll", "a");
1255
1256        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1257            .await
1258            .unwrap();
1259        idx.upsert(k.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1260        idx.remove(&k).await.unwrap();
1261        drop(idx);
1262
1263        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1264            .await
1265            .unwrap();
1266        assert!(
1267            reopened
1268                .keys(&KeyPrefix::default())
1269                .await
1270                .unwrap()
1271                .is_empty()
1272        );
1273    }
1274
1275    // Two handles on one index is the case OCC exists for: the loser must not
1276    // drop the winner's vector, nor its own.
1277    //
1278    // Forced to a single shard: with the default 256 shards `a` (shard 121)
1279    // and `b` (shard 136) never contend for the same shard, so this can pass
1280    // without the conflict path ever running at all — it would only be
1281    // proving that two non-overlapping writes don't clobber each other, which
1282    // is true of a design with no OCC whatsoever. Pinning both writers to one
1283    // shard forces the actual collision.
1284    #[tokio::test]
1285    async fn a_concurrent_writer_loses_neither_sides_vectors() {
1286        let dir = tmp();
1287        let s = fs(&dir);
1288        let a = RecordKey::new("ns", "coll", "a");
1289        let b = RecordKey::new("ns", "coll", "b");
1290        let one_shard = std::num::NonZeroU16::new(1).unwrap();
1291
1292        let one =
1293            RecordVectorIndex::open_with_shards(fs(&dir), index_key(), "space-a", 3, one_shard)
1294                .await
1295                .unwrap();
1296        let two =
1297            RecordVectorIndex::open_with_shards(fs(&dir), index_key(), "space-a", 3, one_shard)
1298                .await
1299                .unwrap();
1300
1301        one.upsert(a.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1302        // `two` opened before that commit, so its manifest read is stale and
1303        // this commit must conflict, reload, and retry.
1304        two.upsert(b.clone(), vec![0.0, 1.0, 0.0]).await.unwrap();
1305
1306        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1307            .await
1308            .unwrap();
1309        let mut got = reopened.keys(&KeyPrefix::default()).await.unwrap();
1310        got.sort();
1311        assert_eq!(got, vec![a, b]);
1312    }
1313
1314    // A lost race leaves the loser's shard blob unreferenced. Nothing points at
1315    // it, so gc must reclaim it — and must not touch the shard that won.
1316    //
1317    // Forced to a single shard: with the default 256 shards `a` and `b` land in
1318    // different buckets, and two non-overlapping single-vector shard writes
1319    // never collide — each blob is exactly what the final manifest ends up
1320    // referencing, so nothing is ever orphaned. Pinning both writers to one
1321    // shard guarantees they contend for the same blob, which is the only way
1322    // a lost race actually leaves one behind.
1323    #[tokio::test]
1324    async fn a_shard_blob_orphaned_by_a_lost_race_is_reclaimed() {
1325        let dir = tmp();
1326        let s = fs(&dir);
1327        let a = RecordKey::new("ns", "coll", "a");
1328        let b = RecordKey::new("ns", "coll", "b");
1329        let one_shard = std::num::NonZeroU16::new(1).unwrap();
1330
1331        let one =
1332            RecordVectorIndex::open_with_shards(fs(&dir), index_key(), "space-a", 3, one_shard)
1333                .await
1334                .unwrap();
1335        let two =
1336            RecordVectorIndex::open_with_shards(fs(&dir), index_key(), "space-a", 3, one_shard)
1337                .await
1338                .unwrap();
1339        one.upsert(a.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1340        two.upsert(b.clone(), vec![0.0, 1.0, 0.0]).await.unwrap();
1341
1342        let before = s.list_blobs().await.unwrap().len();
1343        let report = gonzalo_core::gc::gc_blobs_with(
1344            &s,
1345            gonzalo_core::SweepPolicy {
1346                min_age: std::time::Duration::ZERO,
1347                now: std::time::SystemTime::now(),
1348            },
1349        )
1350        .await
1351        .unwrap();
1352        let after = s.list_blobs().await.unwrap().len();
1353
1354        // `GcReport.freed` is a Vec<ContentHash> of what was deleted, not a count
1355        // (crates/gonzalo-core/src/gc.rs:18).
1356        assert!(
1357            !report.freed.is_empty(),
1358            "the orphaned shard blob should be swept"
1359        );
1360        assert!(after < before);
1361
1362        // The surviving index must still be intact afterwards, holding both
1363        // vectors — not just `b`. Under a design that reloads only the shards
1364        // a commit itself touches (rather than every shard the winner
1365        // changed), the winner's own vector is exactly what a lost race can
1366        // silently drop, and asserting only `b` survives would miss that.
1367        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1368            .await
1369            .unwrap();
1370        let mut got = reopened.keys(&KeyPrefix::default()).await.unwrap();
1371        got.sort();
1372        assert_eq!(got, vec![a, b.clone()]);
1373        let hits = reopened
1374            .query(&[0.0, 1.0, 0.0], 1, &KeyPrefix::default())
1375            .await
1376            .unwrap();
1377        assert_eq!(hits[0].key, b);
1378    }
1379
1380    // Regression: a conflict reload that only catches up on the shards *this*
1381    // commit touches is not enough. Fixed 256-shard keys, chosen so `a` and
1382    // `c661` land in the same shard (121) while `b` lands in a different one
1383    // (136) -- verified directly against `shard_of`, not assumed.
1384    //
1385    //   1. `one` and `two` both open the empty index (R0).
1386    //   2. `one` commits `a` alone -- shard 121, manifest R1.
1387    //   3. `two`'s commit of `b` (shard 136) conflicts against its stale R0
1388    //      view. A reload that only refreshes `dirty` (136) leaves `two`'s
1389    //      memory for shard 121 empty even though the winner (R1) already has
1390    //      `a` there; a reload keyed off every shard the winner *changed*
1391    //      catches shard 121 too.
1392    //   4. `two` then commits `c661`, also shard 121. If step 3 left shard
1393    //      121 stale (empty) in `two`'s memory, this re-serialises shard 121
1394    //      as `{c661}` alone -- silently dropping `a` -- and commits clean,
1395    //      because `two`'s cached revision (R2) is already current: no
1396    //      conflict occurs to catch it.
1397    #[tokio::test]
1398    async fn a_conflict_reload_catches_up_shards_the_commit_itself_did_not_touch() {
1399        let dir = tmp();
1400        let s = fs(&dir);
1401        let a = RecordKey::new("ns", "coll", "a");
1402        let b = RecordKey::new("ns", "coll", "b");
1403        let c = RecordKey::new("ns", "coll", "c661");
1404        assert_eq!(shard_of(&a, DEFAULT_SHARDS), shard_of(&c, DEFAULT_SHARDS));
1405        assert_ne!(shard_of(&a, DEFAULT_SHARDS), shard_of(&b, DEFAULT_SHARDS));
1406
1407        let one = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1408            .await
1409            .unwrap();
1410        let two = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1411            .await
1412            .unwrap();
1413
1414        one.upsert(a.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1415        two.upsert(b.clone(), vec![0.0, 1.0, 0.0]).await.unwrap();
1416        two.upsert(c.clone(), vec![0.0, 0.0, 1.0]).await.unwrap();
1417
1418        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1419            .await
1420            .unwrap();
1421        let mut got = reopened.keys(&KeyPrefix::default()).await.unwrap();
1422        got.sort();
1423        let mut want = vec![a, b, c];
1424        want.sort();
1425        assert_eq!(
1426            got, want,
1427            "a lost race must not silently drop a shard the losing commit didn't itself touch"
1428        );
1429    }
1430
1431    // A failed commit (here, every `put_blob` errors) must leave the handle's
1432    // in-memory index exactly matching what the store actually has -- empty,
1433    // in this case -- never serving a vector that was never persisted.
1434    #[tokio::test]
1435    async fn a_failed_commit_leaves_memory_matching_the_store() {
1436        let dir = tmp();
1437        let s = FailingBlobStore { inner: fs(&dir) };
1438        let idx = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1439            .await
1440            .unwrap();
1441        let k = RecordKey::new("ns", "coll", "a");
1442
1443        let result = idx.upsert(k, vec![1.0, 0.0, 0.0]).await;
1444        assert!(
1445            result.is_err(),
1446            "a failing put_blob must surface as an error"
1447        );
1448        assert!(
1449            idx.keys(&KeyPrefix::default()).await.unwrap().is_empty(),
1450            "a failed commit must not leave an uncommitted vector visible in memory"
1451        );
1452    }
1453
1454    // Two handles can each `open` a *missing* index -- there's no manifest yet
1455    // to check `space`/`dim` against, so the check in `open_with_shards` never
1456    // runs for either of them. If the conflict path doesn't check the winner's
1457    // manifest either, the second handle's commit reloads and writes right
1458    // into an index declaring a different embedding space, with nothing to
1459    // catch it (dimensions match, so even the in-memory dimension check is
1460    // silent).
1461    #[tokio::test]
1462    async fn a_conflicting_commit_refuses_to_join_a_different_embedding_space() {
1463        let dir = tmp();
1464        let s = fs(&dir);
1465        let a = RecordKey::new("ns", "coll", "a");
1466        let b = RecordKey::new("ns", "coll", "b");
1467
1468        let one = RecordVectorIndex::open(fs(&dir), index_key(), "model-a", 3)
1469            .await
1470            .unwrap();
1471        let two = RecordVectorIndex::open(fs(&dir), index_key(), "model-b", 3)
1472            .await
1473            .unwrap();
1474
1475        one.upsert(a.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1476        // `two` opened before that commit, so it conflicts -- and only then
1477        // discovers the space disagreement.
1478        let err = two
1479            .upsert(b, vec![0.0, 1.0, 0.0])
1480            .await
1481            .unwrap_err()
1482            .to_string();
1483        assert!(
1484            err.contains("model-a"),
1485            "winner's space missing from: {err}"
1486        );
1487        assert!(
1488            err.contains("model-b"),
1489            "this handle's space missing from: {err}"
1490        );
1491
1492        // The refused write must not have landed: only `one`'s vector exists.
1493        let reopened = RecordVectorIndex::open(s, index_key(), "model-a", 3)
1494            .await
1495            .unwrap();
1496        assert_eq!(reopened.keys(&KeyPrefix::default()).await.unwrap(), vec![a]);
1497    }
1498
1499    // Same hole, for shard count: two `open_with_shards` handles that both
1500    // open a missing index each pick their own shard count, since there's no
1501    // existing manifest to adopt one from. If a conflicting commit doesn't
1502    // check the winner's shard count before reloading, it would compute shard
1503    // ids for its own keys under its *own* shard count while the winner's
1504    // blobs are addressed under a different one -- corrupting the manifest.
1505    #[tokio::test]
1506    async fn a_conflicting_commit_refuses_to_join_a_different_shard_count() {
1507        let dir = tmp();
1508        let s = fs(&dir);
1509        let a = RecordKey::new("ns", "coll", "a");
1510        let b = RecordKey::new("ns", "coll", "b");
1511
1512        let one = RecordVectorIndex::open_with_shards(
1513            fs(&dir),
1514            index_key(),
1515            "space-a",
1516            3,
1517            std::num::NonZeroU16::new(4).unwrap(),
1518        )
1519        .await
1520        .unwrap();
1521        let two = RecordVectorIndex::open_with_shards(
1522            fs(&dir),
1523            index_key(),
1524            "space-a",
1525            3,
1526            std::num::NonZeroU16::new(8).unwrap(),
1527        )
1528        .await
1529        .unwrap();
1530
1531        one.upsert(a.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1532        let err = two
1533            .upsert(b, vec![0.0, 1.0, 0.0])
1534            .await
1535            .unwrap_err()
1536            .to_string();
1537        assert!(
1538            err.contains('4'),
1539            "winner's shard count missing from: {err}"
1540        );
1541        assert!(
1542            err.contains('8'),
1543            "this handle's shard count missing from: {err}"
1544        );
1545
1546        // The refused write must not have landed: only `one`'s vector exists.
1547        // Reopening with `open` adopts the stored shard count (4).
1548        let reopened = RecordVectorIndex::open(s, index_key(), "space-a", 3)
1549            .await
1550            .unwrap();
1551        assert_eq!(reopened.keys(&KeyPrefix::default()).await.unwrap(), vec![a]);
1552    }
1553
1554    #[tokio::test]
1555    async fn rewriting_identical_content_reuses_the_same_blob() {
1556        let dir = tmp();
1557        let s = fs(&dir);
1558        let k = RecordKey::new("ns", "coll", "a");
1559
1560        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1561            .await
1562            .unwrap();
1563        idx.upsert(k.clone(), vec![1.0, 0.0, 0.0]).await.unwrap();
1564        let first = s.get(&index_key()).await.unwrap().unwrap();
1565
1566        idx.upsert(k, vec![1.0, 0.0, 0.0]).await.unwrap();
1567        let second = s.get(&index_key()).await.unwrap().unwrap();
1568
1569        let a = VectorManifest::from_body(&first.body).unwrap();
1570        let b = VectorManifest::from_body(&second.body).unwrap();
1571        assert_eq!(
1572            a.entries, b.entries,
1573            "identical content should hash the same"
1574        );
1575    }
1576
1577    // Committing once per vector would mean one manifest commit per vector, so a
1578    // bulk load of 100k chunks would be 100k commits. One batch, one revision.
1579    #[tokio::test]
1580    async fn upsert_many_commits_the_manifest_once() {
1581        let dir = tmp();
1582        let s = fs(&dir);
1583        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1584            .await
1585            .unwrap();
1586
1587        idx.upsert_many(
1588            (0..64)
1589                .map(|i| {
1590                    (
1591                        RecordKey::new("ns", "coll", i.to_string()),
1592                        vec![i as f32, 0.0, 0.0],
1593                    )
1594                })
1595                .collect(),
1596        )
1597        .await
1598        .unwrap();
1599
1600        let record = s.get(&index_key()).await.unwrap().unwrap();
1601        // `Record::create` stamps `Revision::initial`, whose counter is 0 (see
1602        // `gonzalo_core::revision::Revision::initial`), so the *first* commit
1603        // on a brand-new manifest lands at counter 0, not 1. What matters
1604        // here is that there is exactly one commit for 64 upserts: a looping
1605        // default would leave the counter at 63 (63 updates on top of the
1606        // initial create), not 0.
1607        assert_eq!(record.revision.counter, 0, "one batch must be one revision");
1608        assert_eq!(idx.keys(&KeyPrefix::default()).await.unwrap().len(), 64);
1609    }
1610
1611    // The `Arc<T>` delegation impl forwards `upsert_many` explicitly rather
1612    // than inheriting the default -- but `MemoryVectorIndex` (the only other
1613    // implementor) never overrides `upsert_many`, so before this override
1614    // existed, forwarding and inheriting the trait default were behaviourally
1615    // identical: both would loop over `upsert`. This is the first case where
1616    // they diverge, so it's the only test that can actually catch someone
1617    // later dropping the explicit forward from the `Arc` impl (which would
1618    // silently fall back to the looping default and defeat the batching this
1619    // task adds).
1620    #[tokio::test]
1621    async fn upsert_many_through_an_arc_still_commits_the_manifest_once() {
1622        let dir = tmp();
1623        let s = fs(&dir);
1624        let idx: std::sync::Arc<RecordVectorIndex<FsStore>> = std::sync::Arc::new(
1625            RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1626                .await
1627                .unwrap(),
1628        );
1629
1630        idx.upsert_many(
1631            (0..64)
1632                .map(|i| {
1633                    (
1634                        RecordKey::new("ns", "coll", i.to_string()),
1635                        vec![i as f32, 0.0, 0.0],
1636                    )
1637                })
1638                .collect(),
1639        )
1640        .await
1641        .unwrap();
1642
1643        let record = s.get(&index_key()).await.unwrap().unwrap();
1644        // See `upsert_many_commits_the_manifest_once`: the first commit on a
1645        // fresh manifest lands at counter 0 (`Revision::initial`), not 1.
1646        assert_eq!(
1647            record.revision.counter, 0,
1648            "one batch through Arc<RecordVectorIndex<_>> must still be one revision"
1649        );
1650        assert_eq!(idx.keys(&KeyPrefix::default()).await.unwrap().len(), 64);
1651    }
1652
1653    // F7 (#323 fix-round): `upsert_many` validates every vector's dimension
1654    // before staging anything, so one bad entry anywhere in the batch must
1655    // leave no trace at all -- not a partial commit of the good entries, and
1656    // not even a manifest record for a previously-empty index.
1657    #[tokio::test]
1658    async fn upsert_many_with_a_bad_dimension_among_good_ones_commits_nothing() {
1659        let dir = tmp();
1660        let s = fs(&dir);
1661        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1662            .await
1663            .unwrap();
1664
1665        let good_a = RecordKey::new("ns", "coll", "a");
1666        let bad = RecordKey::new("ns", "coll", "bad");
1667        let good_b = RecordKey::new("ns", "coll", "b");
1668
1669        let err = idx
1670            .upsert_many(vec![
1671                (good_a, vec![1.0, 0.0, 0.0]),
1672                (bad, vec![1.0, 0.0]), // wrong dimension: 2, index is 3
1673                (good_b, vec![0.0, 1.0, 0.0]),
1674            ])
1675            .await
1676            .unwrap_err()
1677            .to_string();
1678        assert!(err.contains("dimension"), "error should name it: {err}");
1679
1680        assert!(
1681            idx.keys(&KeyPrefix::default()).await.unwrap().is_empty(),
1682            "a rejected batch must leave the in-memory index untouched"
1683        );
1684        assert!(
1685            s.get(&index_key()).await.unwrap().is_none(),
1686            "a rejected batch on a previously-empty index must not create a manifest"
1687        );
1688    }
1689
1690    // F8 (#323 fix-round): an empty batch must be a true no-op. Before
1691    // `commit`'s early return, `upsert_many(vec![])` against an existing
1692    // manifest still ran the full commit loop and produced a real revision
1693    // bump with byte-identical shard content.
1694    #[tokio::test]
1695    async fn upsert_many_of_an_empty_batch_leaves_an_existing_manifests_revision_unchanged() {
1696        let dir = tmp();
1697        let s = fs(&dir);
1698        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1699            .await
1700            .unwrap();
1701        idx.upsert(RecordKey::new("ns", "coll", "a"), vec![1.0, 0.0, 0.0])
1702            .await
1703            .unwrap();
1704
1705        let before = s.get(&index_key()).await.unwrap().unwrap();
1706        idx.upsert_many(vec![]).await.unwrap();
1707        let after = s.get(&index_key()).await.unwrap().unwrap();
1708
1709        assert_eq!(
1710            before.revision, after.revision,
1711            "an empty batch must not advance the manifest's revision"
1712        );
1713    }
1714
1715    // F12 (#323 fix-round): `put_str16` in `shard.rs` writes a key's namespace
1716    // and collection length prefix as a `u16`. A namespace over 64 KiB would
1717    // silently truncate that prefix while its bytes are written in full,
1718    // corrupting the shard so it fails to decode -- taking down the WHOLE
1719    // index, not just the one bad entry. Reject it up front instead.
1720    #[tokio::test]
1721    async fn upsert_rejects_an_oversized_namespace_before_staging_anything() {
1722        let dir = tmp();
1723        let s = fs(&dir);
1724        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1725            .await
1726            .unwrap();
1727
1728        let huge_namespace = "n".repeat(u16::MAX as usize + 1);
1729        let key = RecordKey::new(huge_namespace, "coll", "a");
1730
1731        let err = idx
1732            .upsert(key, vec![1.0, 0.0, 0.0])
1733            .await
1734            .unwrap_err()
1735            .to_string();
1736        assert!(err.contains("64 KiB"), "error should name the limit: {err}");
1737        assert!(
1738            s.get(&index_key()).await.unwrap().is_none(),
1739            "a rejected key must not create a manifest"
1740        );
1741    }
1742
1743    // Same check, reached through `upsert_many` rather than `upsert`.
1744    #[tokio::test]
1745    async fn upsert_many_rejects_an_oversized_collection_before_staging_anything() {
1746        let dir = tmp();
1747        let s = fs(&dir);
1748        let idx = RecordVectorIndex::open(fs(&dir), index_key(), "space-a", 3)
1749            .await
1750            .unwrap();
1751
1752        let huge_collection = "c".repeat(u16::MAX as usize + 1);
1753        let key = RecordKey::new("ns", huge_collection, "a");
1754
1755        let err = idx
1756            .upsert_many(vec![(key, vec![1.0, 0.0, 0.0])])
1757            .await
1758            .unwrap_err()
1759            .to_string();
1760        assert!(err.contains("64 KiB"), "error should name the limit: {err}");
1761        assert!(s.get(&index_key()).await.unwrap().is_none());
1762    }
1763}