1use 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
22const SHARD_READ_CONCURRENCY: usize = 16;
26
27const MAX_COMMIT_ATTEMPTS: usize = 5;
29
30#[derive(Clone, Debug)]
32enum Delta {
33 Upsert(RecordKey, Vec<f32>),
34 Remove(RecordKey),
35}
36
37fn 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
47fn 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
78fn 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
93pub struct RecordVectorIndex<S> {
109 store: Arc<S>,
110 key: RecordKey,
111 space: String,
112 dim: usize,
113 shards: NonZeroU16,
114 inner: MemoryVectorIndex,
115 meta: Meta,
117 last_seen: tokio::sync::Mutex<Option<Record>>,
124 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 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 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 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 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 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 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 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 pub fn store(&self) -> &S {
269 &self.store
270 }
271
272 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 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 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 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 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 async fn commit(&self, deltas: Vec<Delta>) -> Result<()> {
408 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 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 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 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 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 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 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 fn fs(dir: &TempDir) -> FsStore {
643 FsStore::new(dir.path())
644 }
645
646 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 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 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 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 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 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 idx.upsert(x.clone(), vec![0.0, 1.0, 0.0]).await.unwrap();
875 drop(idx);
876
877 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 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 enum ReuploadFault {
900 WrongHash,
904 Error,
906 }
907
908 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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.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 #[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 assert!(
1357 !report.freed.is_empty(),
1358 "the orphaned shard blob should be swept"
1359 );
1360 assert!(after < before);
1361
1362 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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 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 #[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]), (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 #[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 #[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 #[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}