1use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22
23use lucistore::blob_store::BlobStore;
24use lucistore::shard_router::ShardRouter;
25use lucistore::shard_storage::{
26 BlobShardStorage, FsShardStorage, ShardStorage as RootStorage,
27};
28use luciole::{Actor, ActorStatus, Pool, Priority, Reply};
29use serde::{Deserialize, Serialize};
30
31use crate::handle::SparseHandle;
32use crate::index::SparseVector;
33
34const CONFIG_FILE: &str = "_sparse_config.json";
35const ROUTER_FILE: &str = "_sparse_router.bin";
36const BLOB_PREFIX: &str = "Sparse_";
39
40#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct ShardedSparseConfig {
55 #[serde(default)]
57 pub version: u32,
58 pub shards: usize,
60 #[serde(default = "default_balance_weight")]
63 pub balance_weight: f64,
64 #[serde(default = "default_df_threshold")]
67 pub df_threshold: u32,
68}
69
70fn default_balance_weight() -> f64 {
71 1.0
72}
73
74fn default_df_threshold() -> u32 {
75 5000
76}
77
78pub const CONFIG_VERSION: u32 = 1;
80
81impl ShardedSparseConfig {
82 pub fn new(shards: usize) -> Self {
83 Self {
84 version: CONFIG_VERSION,
85 shards,
86 balance_weight: default_balance_weight(),
87 df_threshold: default_df_threshold(),
88 }
89 }
90
91 pub fn validate(&self) -> Result<(), String> {
92 if self.version > CONFIG_VERSION {
93 return Err(format!(
94 "_sparse_config.json was written by a newer version ({}, this build writes {CONFIG_VERSION})",
95 self.version,
96 ));
97 }
98 if self.shards == 0 {
99 return Err("'shards' must be at least 1".into());
100 }
101 if !(0.0..=1.0).contains(&self.balance_weight) {
102 return Err(format!(
103 "'balance_weight' must be within 0.0..=1.0, got {}",
104 self.balance_weight
105 ));
106 }
107 Ok(())
108 }
109}
110
111pub trait SparseShardStorage: Send + Sync {
117 fn create_shard(&self, shard_id: usize) -> Result<SparseHandle, String>;
118 fn open_shard(&self, shard_id: usize) -> Result<SparseHandle, String>;
119 fn write_root_file(&self, name: &str, data: &[u8]) -> Result<(), String>;
120 fn read_root_file(&self, name: &str) -> Result<Vec<u8>, String>;
121 fn root_file_exists(&self, name: &str) -> bool;
122 fn drop_storage(&self, _num_shards: usize) -> Result<(), String> {
125 Err("dropping this storage backend is not supported".into())
126 }
127}
128
129pub struct FsSparseStorage {
131 inner: FsShardStorage,
132}
133
134impl FsSparseStorage {
135 pub fn new(base_path: impl Into<PathBuf>) -> Result<Self, String> {
136 Ok(Self {
137 inner: FsShardStorage::new(base_path)?,
138 })
139 }
140
141 pub fn base_path(&self) -> &Path {
142 self.inner.base_path()
143 }
144}
145
146impl SparseShardStorage for FsSparseStorage {
147 fn create_shard(&self, shard_id: usize) -> Result<SparseHandle, String> {
148 let path = self.inner.shard_path(shard_id);
149 SparseHandle::create(&path.to_string_lossy())
150 }
151
152 fn open_shard(&self, shard_id: usize) -> Result<SparseHandle, String> {
153 let path = self.inner.shard_path(shard_id);
154 SparseHandle::open(&path.to_string_lossy())
155 }
156
157 fn write_root_file(&self, name: &str, data: &[u8]) -> Result<(), String> {
158 self.inner.write_root_file(name, data)
159 }
160
161 fn read_root_file(&self, name: &str) -> Result<Vec<u8>, String> {
162 self.inner.read_root_file(name)
163 }
164
165 fn root_file_exists(&self, name: &str) -> bool {
166 self.inner.root_file_exists(name)
167 }
168
169 fn drop_storage(&self, _num_shards: usize) -> Result<(), String> {
170 std::fs::remove_dir_all(self.inner.base_path())
171 .map_err(|e| format!("cannot remove {}: {e}", self.inner.base_path().display()))
172 }
173}
174
175pub struct BlobSparseStorage<S: BlobStore> {
179 store: Arc<S>,
180 inner: BlobShardStorage<S>,
181 name: String,
182 cache_base: PathBuf,
183}
184
185impl<S: BlobStore> BlobSparseStorage<S> {
186 pub fn new(store: Arc<S>, name: impl Into<String>, cache_base: impl Into<PathBuf>) -> Self {
187 let name = name.into();
188 let cache_base = cache_base.into();
189 let inner = BlobShardStorage::new(
190 store.clone(),
191 format!("{BLOB_PREFIX}{name}"),
192 Some(cache_base.clone()),
193 );
194 Self {
195 store,
196 inner,
197 name,
198 cache_base,
199 }
200 }
201
202 fn shard_index_name(&self, shard_id: usize) -> String {
203 format!("{}/shard_{shard_id}", self.name)
204 }
205}
206
207impl<S: BlobStore> SparseShardStorage for BlobSparseStorage<S> {
208 fn create_shard(&self, shard_id: usize) -> Result<SparseHandle, String> {
209 let store: Arc<dyn BlobStore> = self.store.clone();
210 SparseHandle::create_with_store(store, &self.shard_index_name(shard_id), &self.cache_base)
211 }
212
213 fn open_shard(&self, shard_id: usize) -> Result<SparseHandle, String> {
214 let store: Arc<dyn BlobStore> = self.store.clone();
215 SparseHandle::open_with_store(store, &self.shard_index_name(shard_id), &self.cache_base)
216 }
217
218 fn write_root_file(&self, name: &str, data: &[u8]) -> Result<(), String> {
219 self.inner.write_root_file(name, data)
220 }
221
222 fn read_root_file(&self, name: &str) -> Result<Vec<u8>, String> {
223 self.inner.read_root_file(name)
224 }
225
226 fn root_file_exists(&self, name: &str) -> bool {
227 self.inner.root_file_exists(name)
228 }
229
230 fn drop_storage(&self, num_shards: usize) -> Result<(), String> {
231 let mut namespaces: Vec<String> = (0..num_shards)
232 .map(|i| format!("{BLOB_PREFIX}{}", self.shard_index_name(i)))
233 .collect();
234 namespaces.push(format!("{BLOB_PREFIX}{}", self.name));
235 for ns in namespaces {
236 let files = self
237 .store
238 .list(&ns)
239 .map_err(|e| format!("cannot list {ns}: {e}"))?;
240 for f in files {
241 self.store
242 .delete(&ns, &f)
243 .map_err(|e| format!("cannot delete {ns}/{f}: {e}"))?;
244 }
245 }
246 Ok(())
247 }
248}
249
250enum SparseShardMsg {
255 Insert {
256 node_id: u64,
257 vector: SparseVector,
258 },
259 Remove {
260 node_id: u64,
261 reply: Reply<Result<bool, String>>,
262 },
263 Search {
264 query: Arc<SparseVector>,
265 limit: usize,
266 filter: Option<Arc<Vec<u64>>>,
267 reply: Reply<Vec<(u64, f32)>>,
268 },
269 Commit {
270 reply: Reply<Result<(), String>>,
271 },
272 Drain(luciole::DrainMsg),
273 Shutdown(luciole::ShutdownMsg),
274}
275
276impl From<luciole::DrainMsg> for SparseShardMsg {
277 fn from(d: luciole::DrainMsg) -> Self {
278 SparseShardMsg::Drain(d)
279 }
280}
281
282impl From<luciole::ShutdownMsg> for SparseShardMsg {
283 fn from(s: luciole::ShutdownMsg) -> Self {
284 SparseShardMsg::Shutdown(s)
285 }
286}
287
288struct SparseShardActor {
289 shard_id: usize,
290 handle: Arc<SparseHandle>,
291 pending_error: Option<String>,
294}
295
296impl Actor for SparseShardActor {
297 type Msg = SparseShardMsg;
298
299 fn name(&self) -> &'static str {
300 "sparse_shard"
301 }
302
303 fn priority(&self) -> Priority {
304 Priority::Medium
305 }
306
307 fn handle(&mut self, msg: SparseShardMsg, ctx: &luciole::ActorContext) -> ActorStatus {
308 match msg {
309 SparseShardMsg::Insert { node_id, vector } => {
310 if let Err(e) = self.handle.insert(node_id, &vector) {
311 self.pending_error
312 .get_or_insert_with(|| format!("shard_{}: insert {node_id}: {e}", self.shard_id));
313 }
314 }
315 SparseShardMsg::Remove { node_id, reply } => {
316 reply.send(self.handle.remove(node_id));
317 }
318 SparseShardMsg::Search { query, limit, filter, reply } => {
319 ctx.set_activity(format!("search sparse_shard_{}", self.shard_id));
320 let hits = match filter {
321 Some(ids) => self.handle.search_filtered(&query, limit, &ids),
322 None => self.handle.search(&query, limit),
323 };
324 reply.send(hits);
325 }
326 SparseShardMsg::Commit { reply } => {
327 ctx.set_activity(format!("commit sparse_shard_{}", self.shard_id));
328 let result = match self.pending_error.take() {
329 Some(e) => Err(e),
330 None => self
331 .handle
332 .commit_inner()
333 .map_err(|e| format!("shard_{}: commit: {e}", self.shard_id)),
334 };
335 reply.send(result);
336 }
337 SparseShardMsg::Drain(d) => d.ack(),
338 SparseShardMsg::Shutdown(s) => {
339 s.ack();
340 return ActorStatus::Stop;
341 }
342 }
343 ActorStatus::Continue
344 }
345}
346
347pub struct ShardedSparseHandle {
352 storage: Box<dyn SparseShardStorage>,
353 config: ShardedSparseConfig,
354 shards: Vec<Arc<SparseHandle>>,
355 router: Mutex<ShardRouter>,
356 pool: Pool<SparseShardMsg>,
357 closed: AtomicBool,
358}
359
360impl ShardedSparseHandle {
361 pub fn create(base_path: &str, config: &ShardedSparseConfig) -> Result<Self, String> {
365 Self::create_with_storage(Box::new(FsSparseStorage::new(base_path)?), config)
366 }
367
368 pub fn open(base_path: &str) -> Result<Self, String> {
370 Self::open_with_storage(Box::new(FsSparseStorage::new(base_path)?))
371 }
372
373 pub fn create_with_store<S: BlobStore>(
376 store: Arc<S>,
377 name: &str,
378 cache_base: &Path,
379 config: &ShardedSparseConfig,
380 ) -> Result<Self, String> {
381 Self::create_with_storage(Box::new(BlobSparseStorage::new(store, name, cache_base)), config)
382 }
383
384 pub fn open_with_store<S: BlobStore>(
386 store: Arc<S>,
387 name: &str,
388 cache_base: &Path,
389 ) -> Result<Self, String> {
390 Self::open_with_storage(Box::new(BlobSparseStorage::new(store, name, cache_base)))
391 }
392
393 pub fn create_with_storage(
394 storage: Box<dyn SparseShardStorage>,
395 config: &ShardedSparseConfig,
396 ) -> Result<Self, String> {
397 config.validate()?;
398 if storage.root_file_exists(CONFIG_FILE) {
399 return Err(format!("a sharded sparse index already exists here ({CONFIG_FILE} present)"));
400 }
401 let config_json = serde_json::to_vec_pretty(config)
402 .map_err(|e| format!("cannot serialize config: {e}"))?;
403 storage.write_root_file(CONFIG_FILE, &config_json)?;
404
405 let mut shards = Vec::with_capacity(config.shards);
406 for i in 0..config.shards {
407 shards.push(Arc::new(storage.create_shard(i)?));
408 }
409 let router = ShardRouter::with_options(config.shards, config.df_threshold, config.balance_weight);
410 storage.write_root_file(ROUTER_FILE, &router.to_bytes())?;
411
412 Ok(Self::assemble(storage, config.clone(), shards, router))
413 }
414
415 pub fn open_with_storage(storage: Box<dyn SparseShardStorage>) -> Result<Self, String> {
416 let config_json = storage.read_root_file(CONFIG_FILE)?;
417 let config: ShardedSparseConfig = serde_json::from_slice(&config_json)
418 .map_err(|e| format!("invalid {CONFIG_FILE}: {e}"))?;
419 config.validate()?;
420
421 let mut shards = Vec::with_capacity(config.shards);
422 for i in 0..config.shards {
423 shards.push(Arc::new(storage.open_shard(i)?));
424 }
425 let router = if storage.root_file_exists(ROUTER_FILE) {
426 ShardRouter::from_bytes(&storage.read_root_file(ROUTER_FILE)?)?
427 } else {
428 ShardRouter::with_options(config.shards, config.df_threshold, config.balance_weight)
429 };
430 Ok(Self::assemble(storage, config, shards, router))
431 }
432
433 fn assemble(
434 storage: Box<dyn SparseShardStorage>,
435 config: ShardedSparseConfig,
436 shards: Vec<Arc<SparseHandle>>,
437 router: ShardRouter,
438 ) -> Self {
439 let handles = shards.clone();
440 let pool = Pool::spawn(shards.len(), 0, |i| SparseShardActor {
442 shard_id: i,
443 handle: Arc::clone(&handles[i]),
444 pending_error: None,
445 });
446 Self {
447 storage,
448 config,
449 shards,
450 router: Mutex::new(router),
451 pool,
452 closed: AtomicBool::new(false),
453 }
454 }
455
456 pub fn num_shards(&self) -> usize {
459 self.shards.len()
460 }
461
462 pub fn config(&self) -> &ShardedSparseConfig {
463 &self.config
464 }
465
466 pub fn len(&self) -> usize {
468 self.shards.iter().map(|s| s.len()).sum()
469 }
470
471 pub fn is_empty(&self) -> bool {
472 self.len() == 0
473 }
474
475 pub fn shard_for_node_id(&self, node_id: u64) -> Option<usize> {
477 self.router.lock().ok()?.shard_for_node_id(node_id)
478 }
479
480 fn ensure_open(&self) -> Result<(), String> {
481 if self.closed.load(Ordering::Acquire) {
482 Err("handle is closed".to_string())
483 } else {
484 Ok(())
485 }
486 }
487
488 pub fn insert(&self, node_id: u64, vector: &SparseVector) -> Result<(), String> {
493 self.ensure_open()?;
494 let hashes: Vec<u64> = vector
495 .indices
496 .iter()
497 .map(|d| ShardRouter::hash_bytes(&d.to_le_bytes()))
498 .collect();
499 let shard_id = {
500 let mut router = self.router.lock().map_err(|_| "router lock poisoned")?;
501 if let Some(known) = router.shard_for_node_id(node_id) {
502 known
505 } else {
506 let sid = router.route(&hashes);
507 router.record_node_id(node_id, sid);
508 sid
509 }
510 };
511 self.pool
512 .send_to(shard_id, SparseShardMsg::Insert { node_id, vector: vector.clone() })
513 .map_err(|e| format!("shard_{shard_id}: {e}"))
514 }
515
516 pub fn remove(&self, node_id: u64) -> Result<bool, String> {
518 self.ensure_open()?;
519 let known = self
520 .router
521 .lock()
522 .map_err(|_| "router lock poisoned")?
523 .remove_node_id(node_id);
524 match known {
525 Some(sid) => self
526 .pool
527 .request_to(sid, |r| SparseShardMsg::Remove { node_id, reply: r }, "sparse_remove")?,
528 None => {
531 let results = self
532 .pool
533 .scatter(|r| SparseShardMsg::Remove { node_id, reply: r }, "sparse_remove_all");
534 let mut removed = false;
535 for r in results {
536 removed |= r?;
537 }
538 Ok(removed)
539 }
540 }
541 }
542
543 pub fn commit(&self) -> Result<(), String> {
546 self.ensure_open()?;
547 self.pool.drain("sparse_drain");
548 let results = self
549 .pool
550 .scatter(|r| SparseShardMsg::Commit { reply: r }, "sparse_commit");
551 for r in results {
552 r?;
553 }
554 let router = self.router.lock().map_err(|_| "router lock poisoned")?;
555 self.storage.write_root_file(ROUTER_FILE, &router.to_bytes())
556 }
557
558 pub fn search(&self, query: &SparseVector, limit: usize) -> Result<Vec<(u64, f32)>, String> {
562 self.search_inner(query, limit, None)
563 }
564
565 pub fn search_filtered(
567 &self,
568 query: &SparseVector,
569 limit: usize,
570 allowed_ids: &[u64],
571 ) -> Result<Vec<(u64, f32)>, String> {
572 self.ensure_open()?;
573 if allowed_ids.is_empty() || limit == 0 || query.indices.is_empty() {
574 return Ok(Vec::new());
575 }
576 let per_shard: Option<Vec<Vec<u64>>> = {
581 let router = self.router.lock().map_err(|_| "router lock poisoned")?;
582 let mut groups: Vec<Vec<u64>> = vec![Vec::new(); self.shards.len()];
583 let mut all_known = true;
584 for &id in allowed_ids {
585 match router.shard_for_node_id(id) {
586 Some(sid) if sid < groups.len() => groups[sid].push(id),
587 _ => {
588 all_known = false;
589 break;
590 }
591 }
592 }
593 all_known.then_some(groups)
594 };
595 let Some(groups) = per_shard else {
596 return self.search_inner(query, limit, Some(Arc::new(allowed_ids.to_vec())));
597 };
598 let targets: Vec<usize> = (0..groups.len()).filter(|&i| !groups[i].is_empty()).collect();
599 if targets.is_empty() {
600 return Ok(Vec::new());
601 }
602 let query = Arc::new(query.clone());
603 let shares: Vec<Option<Arc<Vec<u64>>>> = groups
604 .into_iter()
605 .map(|g| (!g.is_empty()).then(|| Arc::new(g)))
606 .collect();
607 let per_shard = self.pool.scatter_to(
608 &targets,
609 |sid, r| SparseShardMsg::Search {
610 query: Arc::clone(&query),
611 limit,
612 filter: shares[sid].clone(),
613 reply: r,
614 },
615 "sparse_search_routed",
616 );
617 Ok(merge_top_k(per_shard.into_iter().map(|(_, hits)| hits).collect(), limit))
618 }
619
620 fn search_inner(
621 &self,
622 query: &SparseVector,
623 limit: usize,
624 filter: Option<Arc<Vec<u64>>>,
625 ) -> Result<Vec<(u64, f32)>, String> {
626 self.ensure_open()?;
627 if limit == 0 || query.indices.is_empty() {
628 return Ok(Vec::new());
629 }
630 let query = Arc::new(query.clone());
631 let per_shard = self.pool.scatter(
632 |r| SparseShardMsg::Search {
633 query: Arc::clone(&query),
634 limit,
635 filter: filter.clone(),
636 reply: r,
637 },
638 "sparse_search",
639 );
640 Ok(merge_top_k(per_shard, limit))
641 }
642
643 pub fn close(&self) -> Result<(), String> {
647 if self.closed.swap(true, Ordering::AcqRel) {
648 return Ok(());
649 }
650 self.pool.drain("sparse_close_drain");
651 let results = self
652 .pool
653 .scatter(|r| SparseShardMsg::Commit { reply: r }, "sparse_close_commit");
654 let mut first_err = None;
655 for r in results {
656 if let Err(e) = r {
657 first_err.get_or_insert(e);
658 }
659 }
660 if let Ok(router) = self.router.lock() {
661 if let Err(e) = self.storage.write_root_file(ROUTER_FILE, &router.to_bytes()) {
662 first_err.get_or_insert(e);
663 }
664 }
665 self.pool.shutdown("sparse_close_shards");
666 match first_err {
667 Some(e) => Err(e),
668 None => Ok(()),
669 }
670 }
671
672 pub fn drop_index(self) -> Result<(), String> {
674 self.close()?;
675 let n = self.shards.len();
676 let Self {
680 storage,
681 shards,
682 pool,
683 ..
684 } = self;
685 drop(pool);
686 drop(shards);
687 storage.drop_storage(n)
688 }
689}
690
691fn merge_top_k(per_shard: Vec<Vec<(u64, f32)>>, limit: usize) -> Vec<(u64, f32)> {
694 let mut all: Vec<(u64, f32)> = per_shard.into_iter().flatten().collect();
695 all.sort_by(|a, b| {
696 b.1.partial_cmp(&a.1)
697 .unwrap_or(std::cmp::Ordering::Equal)
698 .then(a.0.cmp(&b.0))
699 });
700 all.truncate(limit);
701 all
702}
703
704#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::blob_store::MemBlobStore;
712
713 fn tmp(name: &str) -> PathBuf {
714 let p = std::env::temp_dir().join(format!("sparse_sharded_{name}"));
715 let _ = std::fs::remove_dir_all(&p);
716 p
717 }
718
719 fn corpus() -> Vec<(u64, SparseVector)> {
722 let mut state = 0x9E37_79B9_7F4A_7C15u64;
723 let mut next = move || {
724 state ^= state << 13;
725 state ^= state >> 7;
726 state ^= state << 17;
727 state
728 };
729 (0..240u64)
730 .map(|id| {
731 let n = 6 + (next() % 7) as usize;
732 let mut indices: Vec<u32> = Vec::new();
733 while indices.len() < n {
734 let d = if next() % 3 == 0 { (next() % 4) as u32 } else { (next() % 64) as u32 };
735 if !indices.contains(&d) {
736 indices.push(d);
737 }
738 }
739 indices.sort_unstable();
740 let values: Vec<f32> = indices
741 .iter()
742 .map(|_| ((next() % 1000) as f32 / 100.0) - 2.0)
743 .collect();
744 (id, SparseVector::new(indices, values))
745 })
746 .collect()
747 }
748
749 fn queries() -> Vec<SparseVector> {
750 vec![
751 SparseVector::new(vec![0, 1, 2], vec![1.0, 0.5, 0.25]),
752 SparseVector::new(vec![3, 17, 40, 63], vec![2.0, 1.0, 1.0, 0.5]),
753 SparseVector::new(vec![1, 9], vec![-1.0, 3.0]),
754 SparseVector::new(vec![50], vec![1.0]),
755 ]
756 }
757
758 fn reference(docs: &[(u64, SparseVector)], name: &str) -> SparseHandle {
761 let h = SparseHandle::create(&tmp(name).to_string_lossy()).unwrap();
762 for (id, v) in docs {
763 h.insert(*id, v).unwrap();
764 }
765 h.commit_inner().unwrap();
766 h
767 }
768
769 fn assert_same(label: &str, got: &[(u64, f32)], want: &[(u64, f32)]) {
770 assert_eq!(got.len(), want.len(), "{label}: {got:?} vs {want:?}");
771 for (g, w) in got.iter().zip(want) {
772 assert_eq!(g.0, w.0, "{label}: ids differ: {got:?} vs {want:?}");
773 assert!((g.1 - w.1).abs() < 1e-4, "{label}: scores differ: {got:?} vs {want:?}");
774 }
775 }
776
777 #[test]
778 fn sharded_equals_single_handle_fs() {
779 let docs = corpus();
780 let single = reference(&docs, "reference_fs4");
781 let base = tmp("fs4");
782 let h = ShardedSparseHandle::create(&base.to_string_lossy(), &ShardedSparseConfig::new(4)).unwrap();
783 for (id, v) in &docs {
784 h.insert(*id, v).unwrap();
785 }
786 h.commit().unwrap();
787 assert_eq!(h.len(), docs.len());
788 let counts: Vec<usize> = h.shards.iter().map(|s| s.len()).collect();
790 assert!(counts.iter().all(|&c| c > 0), "{counts:?}");
791
792 for (i, q) in queries().iter().enumerate() {
793 let got = h.search(q, 10).unwrap();
794 let want = single.search(q, 10);
795 assert_same(&format!("query {i}"), &got, &want);
796 }
797 let allowed: Vec<u64> = (0..240).filter(|id| id % 3 == 0).collect();
798 for (i, q) in queries().iter().enumerate() {
799 let got = h.search_filtered(q, 10, &allowed).unwrap();
800 let want = single.search_filtered(q, 10, &allowed);
801 assert_same(&format!("filtered query {i}"), &got, &want);
802 assert!(got.iter().all(|(id, _)| id % 3 == 0));
803 }
804 h.close().unwrap();
805 }
806
807 #[test]
808 fn sharded_remove_reopen_and_closed_refusal() {
809 let docs = corpus();
810 let base = tmp("fs_reopen");
811 let q = &queries()[0];
812 let before;
813 {
814 let h = ShardedSparseHandle::create(&base.to_string_lossy(), &ShardedSparseConfig::new(3)).unwrap();
815 for (id, v) in &docs {
816 h.insert(*id, v).unwrap();
817 }
818 h.commit().unwrap();
819 let top = h.search(q, 5).unwrap();
820 let victim = top[0].0;
821 assert!(h.shard_for_node_id(victim).is_some());
822 assert!(h.remove(victim).unwrap());
823 assert!(!h.remove(victim).unwrap(), "second remove finds nothing");
824 h.commit().unwrap();
825 before = h.search(q, 5).unwrap();
826 assert!(before.iter().all(|(id, _)| *id != victim));
827 h.close().unwrap();
828 assert!(h.search(q, 5).unwrap_err().contains("closed"));
829 assert!(h.insert(999, &docs[0].1).unwrap_err().contains("closed"));
830 assert!(h.commit().unwrap_err().contains("closed"));
831 }
832 let h = ShardedSparseHandle::open(&base.to_string_lossy()).unwrap();
833 assert_eq!(h.num_shards(), 3);
834 assert_eq!(h.len(), docs.len() - 1);
835 let after = h.search(q, 5).unwrap();
836 assert_same("after reopen", &after, &before);
837 let id = after[0].0;
839 assert!(h.shard_for_node_id(id).is_some());
840 assert!(h.remove(id).unwrap());
841 h.close().unwrap();
842 }
843
844 #[test]
845 fn sharded_blob_store_is_the_truth() {
846 let docs = corpus();
847 let single = reference(&docs, "reference_blob");
848 let store = Arc::new(MemBlobStore::new());
849 let cache_a = tmp("blob_cache_a");
850 let cache_b = tmp("blob_cache_b");
851 let cfg = ShardedSparseConfig::new(2);
852 {
853 let h = ShardedSparseHandle::create_with_store(store.clone(), "vectors", &cache_a, &cfg).unwrap();
854 for (id, v) in &docs {
855 h.insert(*id, v).unwrap();
856 }
857 h.commit().unwrap();
858 h.close().unwrap();
859 }
860 let _ = std::fs::remove_dir_all(&cache_a);
862 let h = ShardedSparseHandle::open_with_store(store.clone(), "vectors", &cache_b).unwrap();
863 assert_eq!(h.len(), docs.len());
864 for (i, q) in queries().iter().enumerate() {
865 let got = h.search(q, 10).unwrap();
866 let want = single.search(q, 10);
867 assert_same(&format!("blob query {i}"), &got, &want);
868 }
869 h.drop_index().unwrap();
871 for ns in ["Sparse_vectors", "Sparse_vectors/shard_0", "Sparse_vectors/shard_1"] {
872 assert!(store.list(ns).unwrap().is_empty(), "{ns} not empty");
873 }
874 }
875
876 #[test]
881 fn filtered_search_agrees_across_paths_and_sizes() {
882 let docs = corpus();
883 let single = reference(&docs, "paths");
884 let base = tmp("fs_paths");
885 let h = ShardedSparseHandle::create(&base.to_string_lossy(), &ShardedSparseConfig::new(4)).unwrap();
886 for (id, v) in &docs {
887 h.insert(*id, v).unwrap();
888 }
889 h.commit().unwrap();
890 for (qi, q) in queries().iter().enumerate() {
891 let full = h.search(q, 240).unwrap();
892 for size in [1usize, 2, 5, 17, 60, 240] {
893 let allowed: Vec<u64> = (0..240u64).filter(|id| (id * 7 + qi as u64) % 240 < size as u64).collect();
894 let got = h.search_filtered(q, 10, &allowed).unwrap();
895 let want = single.search_filtered(q, 10, &allowed);
896 assert_same(&format!("q{qi} size {size}"), &got, &want);
897 let expect: Vec<(u64, f32)> = full
898 .iter()
899 .filter(|(id, _)| allowed.contains(id))
900 .take(10)
901 .copied()
902 .collect();
903 assert_same(&format!("q{qi} size {size} vs unfiltered"), &got, &expect);
904 }
905 }
906 h.close().unwrap();
907 }
908
909 #[test]
910 fn config_is_checked() {
911 let base = tmp("bad_config");
912 let mut cfg = ShardedSparseConfig::new(0);
913 let err = ShardedSparseHandle::create(&base.to_string_lossy(), &cfg).err().unwrap();
914 assert!(err.contains("shards"), "{err}");
915 cfg.shards = 2;
916 cfg.balance_weight = 3.0;
917 let err = ShardedSparseHandle::create(&base.to_string_lossy(), &cfg).err().unwrap();
918 assert!(err.contains("balance_weight"), "{err}");
919 let newer: ShardedSparseConfig =
923 serde_json::from_str(r#"{"shards": 2, "a_field_from_the_future": 4}"#).unwrap();
924 assert_eq!(newer.shards, 2);
925 assert_eq!(newer.version, 0, "a file without a version is the original one");
926 assert!(newer.validate().is_ok());
927 assert_eq!(ShardedSparseConfig::new(2).version, CONFIG_VERSION);
930 let future = ShardedSparseConfig { version: CONFIG_VERSION + 1, ..ShardedSparseConfig::new(2) };
931 let err = future.validate().err().unwrap();
932 assert!(err.contains("newer version"), "{err}");
933 }
934}