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///
46/// Versioned and tolerant on purpose: it used to carry
47/// `deny_unknown_fields` and no version, so the day a field was added, the
48/// previous release could no longer read the file — the compatibility was
49/// structurally broken for this one file, while the shard router next to it
50/// does it right (magic `SHRD`, version, a range of readable versions).
51/// A field this build does not know is now ignored, and `version` says what
52/// wrote the file.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct ShardedSparseConfig {
55    /// Format of this file. Absent means the versionless original (0).
56    #[serde(default)]
57    pub version: u32,
58    /// Number of shards, at least 1.
59    pub shards: usize,
60    /// Routing balance, 0.0..=1.0: 1.0 is round-robin (the default), lower
61    /// values co-locate vectors sharing dimensions on the same shard.
62    #[serde(default = "default_balance_weight")]
63    pub balance_weight: f64,
64    /// Dimensions with a global document frequency above this are not
65    /// tracked for routing (they carry no locality information).
66    #[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
78/// What this build writes into `_sparse_config.json`.
79pub 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
111// ---------------------------------------------------------------------------
112// Storage
113// ---------------------------------------------------------------------------
114
115/// Where the shards and the root files of a sharded sparse index live.
116pub 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    /// Destroy everything held for the index. Called by
123    /// [`ShardedSparseHandle::drop_index`] once the handle is closed.
124    fn drop_storage(&self, _num_shards: usize) -> Result<(), String> {
125        Err("dropping this storage backend is not supported".into())
126    }
127}
128
129/// Filesystem storage: `{base}/shard_{i}/` per shard, root files in `{base}`.
130pub 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
175/// Blob storage: shard `i` is the `SparseHandle` namespace
176/// `Sparse_{name}/shard_{i}`, root files live under `Sparse_{name}`. The
177/// local cache under `cache_base` is disposable; the store is the truth.
178pub 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
250// ---------------------------------------------------------------------------
251// Shard actor
252// ---------------------------------------------------------------------------
253
254enum 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    /// First insert error since the last commit, reported by `commit()`:
292    /// inserts are fire-and-forget, so their failures surface there.
293    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
347// ---------------------------------------------------------------------------
348// Handle
349// ---------------------------------------------------------------------------
350
351pub 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    // ── Construction ────────────────────────────────────────────────────
362
363    /// Create a new sharded index on a filesystem directory.
364    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    /// Open an existing sharded index from a filesystem directory.
369    pub fn open(base_path: &str) -> Result<Self, String> {
370        Self::open_with_storage(Box::new(FsSparseStorage::new(base_path)?))
371    }
372
373    /// Create a new sharded index whose truth is a blob store; `cache_base`
374    /// holds the disposable local mmap cache.
375    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    /// Open an existing sharded index from a blob store.
385    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        // Capacity 0 = unbounded mailbox: inserts are fire-and-forget.
441        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    // ── Introspection ───────────────────────────────────────────────────
457
458    pub fn num_shards(&self) -> usize {
459        self.shards.len()
460    }
461
462    pub fn config(&self) -> &ShardedSparseConfig {
463        &self.config
464    }
465
466    /// Number of vectors across shards, as of the last commit of each.
467    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    /// Shard holding `node_id`, if the router saw it inserted.
476    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    // ── Writes ──────────────────────────────────────────────────────────
489
490    /// Route the vector to a shard by its dimensions and queue the insert.
491    /// Failures inside the shard surface at the next `commit()`.
492    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                // Re-inserting an id keeps it on its shard: the upsert
503                // replaces the vector there instead of duplicating it.
504                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    /// Remove a vector. Returns whether it existed.
517    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            // Unknown to the router (index built before routing was
529            // persisted, or router lost): ask every shard.
530            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    /// Persist every shard and the router. Reports the first insert error
544    /// of each shard since its last commit.
545    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    // ── Reads ───────────────────────────────────────────────────────────
559
560    /// Top-`limit` by dot product across all shards.
561    pub fn search(&self, query: &SparseVector, limit: usize) -> Result<Vec<(u64, f32)>, String> {
562        self.search_inner(query, limit, None)
563    }
564
565    /// `search` restricted to `allowed_ids`.
566    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        // The router knows where every inserted id lives: give each shard
577        // only its share and leave the others idle. An id the router never
578        // saw (an index older than routing persistence) sends the whole set
579        // everywhere, as before.
580        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    // ── Lifecycle ───────────────────────────────────────────────────────
644
645    /// Commit, stop the shard actors, make the handle inert.
646    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    /// Close, then destroy the storage (shards and root files).
673    pub fn drop_index(self) -> Result<(), String> {
674        self.close()?;
675        let n = self.shards.len();
676        // The shard handles hold the local caches; release them (and the
677        // actor pool that shares them) before the storage removes what
678        // they point to.
679        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
691/// Merge per-shard top lists: score descending, then id ascending, at most
692/// `limit` entries.
693fn 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// ---------------------------------------------------------------------------
705// Tests
706// ---------------------------------------------------------------------------
707
708#[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    /// Deterministic corpus: 240 vectors over 64 dims, 6-12 non-zeros each,
720    /// with a few hub dimensions so some posting lists are long.
721    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    /// Single, unsharded handle over `docs`; `name` keeps concurrent tests
759    /// out of each other's directory.
760    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        // Every shard got something.
789        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        // The router came back: a known id is removed from its own shard.
838        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        // Another machine: fresh cache, same store.
861        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        // drop_index leaves nothing in the store.
870        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    /// `search_filtered` picks a seek path for small allowed sets and a
877    /// window path for large ones, per shard; every combination must give
878    /// the single-handle answer, and the answer must be the unfiltered
879    /// ranking restricted to the allowed ids.
880    #[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        // A field this build does not know is ignored, not fatal: the file
920        // used to carry `deny_unknown_fields` and no version, so adding one
921        // field made it unreadable by the previous release.
922        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        // What this build writes says so, and a newer format is refused —
928        // clearly, rather than by a missing field.
929        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}