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