Skip to main content

sparse_vector/
sharded.rs

1//! `ShardedSparseHandle`: N [`SparseHandle`]s behind a [`ShardRouter`] and a
2//! pool of luciole actors — the sparse counterpart of `lucivy_core`'s
3//! `ShardedHandle`.
4//!
5//! A dot product is local to a shard: no global statistics are needed to
6//! merge results, so a search is a scatter of the same `(query, limit,
7//! filter)` to every shard and a k-way merge of `(id, score)` by score.
8//!
9//! Storage is abstracted by [`SparseShardStorage`]: a filesystem layout
10//! (`{base}/shard_{i}` plus root files) or a blob store (namespaces
11//! `Sparse_{name}/shard_{i}`, root files under `Sparse_{name}`), both built on
12//! lucistore's shard storages.
13//!
14//! Lifecycle contract, identical to the FTS handle: `commit()` persists every
15//! shard and the router; `close()` commits, stops the actors and makes the
16//! handle inert (every entry point answers `"handle is closed"`);
17//! `drop_index()` closes and destroys the storage.
18
19use 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";
36/// Blob namespace prefix, shared with `SparseHandle` so one store can hold
37/// FTS (`Lucivy_`) and sparse (`Sparse_`) indexes of the same name.
38const BLOB_PREFIX: &str = "Sparse_";
39
40// ---------------------------------------------------------------------------
41// Configuration
42// ---------------------------------------------------------------------------
43
44/// Persisted as `_sparse_config.json` at the root of the storage.
45#[derive(Clone, Debug, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ShardedSparseConfig {
48    /// Number of shards, at least 1.
49    pub shards: usize,
50    /// Routing balance, 0.0..=1.0: 1.0 is round-robin (the default), lower
51    /// values co-locate vectors sharing dimensions on the same shard.
52    #[serde(default = "default_balance_weight")]
53    pub balance_weight: f64,
54    /// Dimensions with a global document frequency above this are not
55    /// tracked for routing (they carry no locality information).
56    #[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
91// ---------------------------------------------------------------------------
92// Storage
93// ---------------------------------------------------------------------------
94
95/// Where the shards and the root files of a sharded sparse index live.
96pub 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    /// Destroy everything held for the index. Called by
103    /// [`ShardedSparseHandle::drop_index`] once the handle is closed.
104    fn drop_storage(&self, _num_shards: usize) -> Result<(), String> {
105        Err("dropping this storage backend is not supported".into())
106    }
107}
108
109/// Filesystem storage: `{base}/shard_{i}/` per shard, root files in `{base}`.
110pub 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
155/// Blob storage: shard `i` is the `SparseHandle` namespace
156/// `Sparse_{name}/shard_{i}`, root files live under `Sparse_{name}`. The
157/// local cache under `cache_base` is disposable; the store is the truth.
158pub 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
230// ---------------------------------------------------------------------------
231// Shard actor
232// ---------------------------------------------------------------------------
233
234enum 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    /// First insert error since the last commit, reported by `commit()`:
272    /// inserts are fire-and-forget, so their failures surface there.
273    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
327// ---------------------------------------------------------------------------
328// Handle
329// ---------------------------------------------------------------------------
330
331pub 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    // ── Construction ────────────────────────────────────────────────────
342
343    /// Create a new sharded index on a filesystem directory.
344    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    /// Open an existing sharded index from a filesystem directory.
349    pub fn open(base_path: &str) -> Result<Self, String> {
350        Self::open_with_storage(Box::new(FsSparseStorage::new(base_path)?))
351    }
352
353    /// Create a new sharded index whose truth is a blob store; `cache_base`
354    /// holds the disposable local mmap cache.
355    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    /// Open an existing sharded index from a blob store.
365    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        // Capacity 0 = unbounded mailbox: inserts are fire-and-forget.
421        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    // ── Introspection ───────────────────────────────────────────────────
437
438    pub fn num_shards(&self) -> usize {
439        self.shards.len()
440    }
441
442    pub fn config(&self) -> &ShardedSparseConfig {
443        &self.config
444    }
445
446    /// Number of vectors across shards, as of the last commit of each.
447    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    /// Shard holding `node_id`, if the router saw it inserted.
456    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    // ── Writes ──────────────────────────────────────────────────────────
469
470    /// Route the vector to a shard by its dimensions and queue the insert.
471    /// Failures inside the shard surface at the next `commit()`.
472    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                // Re-inserting an id keeps it on its shard: the upsert
483                // replaces the vector there instead of duplicating it.
484                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    /// Remove a vector. Returns whether it existed.
497    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            // Unknown to the router (index built before routing was
509            // persisted, or router lost): ask every shard.
510            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    /// Persist every shard and the router. Reports the first insert error
524    /// of each shard since its last commit.
525    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    // ── Reads ───────────────────────────────────────────────────────────
539
540    /// Top-`limit` by dot product across all shards.
541    pub fn search(&self, query: &SparseVector, limit: usize) -> Result<Vec<(u64, f32)>, String> {
542        self.search_inner(query, limit, None)
543    }
544
545    /// `search` restricted to `allowed_ids`.
546    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        // The router knows where every inserted id lives: give each shard
557        // only its share and leave the others idle. An id the router never
558        // saw (an index older than routing persistence) sends the whole set
559        // everywhere, as before.
560        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    // ── Lifecycle ───────────────────────────────────────────────────────
624
625    /// Commit, stop the shard actors, make the handle inert.
626    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    /// Close, then destroy the storage (shards and root files).
653    pub fn drop_index(self) -> Result<(), String> {
654        self.close()?;
655        let n = self.shards.len();
656        // The shard handles hold the local caches; release them (and the
657        // actor pool that shares them) before the storage removes what
658        // they point to.
659        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
671/// Merge per-shard top lists: score descending, then id ascending, at most
672/// `limit` entries.
673fn 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// ---------------------------------------------------------------------------
685// Tests
686// ---------------------------------------------------------------------------
687
688#[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    /// Deterministic corpus: 240 vectors over 64 dims, 6-12 non-zeros each,
700    /// with a few hub dimensions so some posting lists are long.
701    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    /// Single, unsharded handle over `docs`; `name` keeps concurrent tests
739    /// out of each other's directory.
740    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        // Every shard got something.
769        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        // The router came back: a known id is removed from its own shard.
818        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        // Another machine: fresh cache, same store.
841        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        // drop_index leaves nothing in the store.
850        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    /// `search_filtered` picks a seek path for small allowed sets and a
857    /// window path for large ones, per shard; every combination must give
858    /// the single-handle answer, and the answer must be the unfiltered
859    /// ranking restricted to the allowed ids.
860    #[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}