Skip to main content

nodedb_lite/engine/vector/
state.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared runtime state for HNSW vector search on Lite.
4//!
5//! Held as `Arc<VectorState<S>>` on both `NodeDbLite<S>` (user-facing
6//! entry points) and `LiteQueryEngine<S>` (PhysicalPlan executor) so
7//! the visitor pipeline can run vector ops without re-architecting the
8//! engine boundary.
9
10use std::collections::{HashMap, HashSet};
11use std::sync::{Arc, Mutex};
12
13use nodedb_mem::ScopedMemory;
14use nodedb_types::collection_config::VectorPrimaryConfig;
15use nodedb_vector::rerank::CodecSidecar;
16
17use crate::engine::vector::HnswIndex;
18use crate::engine::vector::id_map::VectorIdMap;
19use crate::storage::engine::StorageEngine;
20
21pub struct VectorState<S: StorageEngine> {
22    pub(crate) hnsw_indices: Mutex<HashMap<String, HnswIndex>>,
23    /// Per-index node ↔ document id bindings. Lock order: `hnsw_indices`
24    /// first, then this.
25    pub(crate) vector_id_map: Mutex<VectorIdMap>,
26    pub(crate) search_ef: usize,
27    pub(crate) storage: Arc<S>,
28    /// index_key → trained codec sidecar (populated by S2.a.11).
29    pub(crate) codec_sidecars: Arc<Mutex<HashMap<String, CodecSidecar>>>,
30    /// Per-(index_key) collection config — populated when a collection is
31    /// registered via DDL (C2c will wire that). Lookup is best-effort:
32    /// callers that don't find an entry default to F32 storage, matching
33    /// the previous behavior.
34    pub(crate) per_index_config: Arc<Mutex<HashMap<String, VectorPrimaryConfig>>>,
35    /// Index keys whose stored checkpoint exists but cannot be turned into a
36    /// usable index — unreadable checkpoint, or a segment that cannot serve the
37    /// graph with no durable vectors to rebuild from.
38    ///
39    /// Without this, `ensure_index_loaded` gives up WITHOUT caching anything, so
40    /// every later search on that collection repeats the entire cost — read the
41    /// checkpoint, deserialize the full graph, open and validate the segment,
42    /// scan the durable rows — and still finds nothing. On a collection with
43    /// thousands of nodes that turns one unusable segment into an operation that
44    /// pins a core indefinitely while reporting no progress.
45    ///
46    /// This is a NEGATIVE cache for the load path only. It is not consulted once
47    /// the collection is present in `hnsw_indices`, so a later insert (which
48    /// creates the index through `resident::lock_resident_or_create`) resolves
49    /// the collection normally without anything here needing to be cleared.
50    pub(crate) unloadable: Mutex<HashSet<String>>,
51    /// Index keys eviction dropped from `hnsw_indices` after writing their
52    /// checkpoint. An index absent from memory and named here must be loaded
53    /// back before any use: creating an empty one in its place hides every
54    /// vector it held. The lazy loader clears the mark once it has loaded
55    /// the index or found it unloadable. Lock order: `hnsw_indices` first.
56    pub(crate) evicted: Mutex<HashSet<String>>,
57    /// Memory scope for codec sidecar and rerank allocations owned by this state.
58    pub(crate) memory: ScopedMemory,
59}
60
61/// Restored state for [`VectorState::from_restored`].
62///
63/// Grouped into a struct because the constructor otherwise carries five
64/// unrelated arguments.
65pub struct RestoredVectorState<S: StorageEngine> {
66    pub storage: Arc<S>,
67    pub search_ef: usize,
68    pub indices: HashMap<String, HnswIndex>,
69    pub id_map: VectorIdMap,
70    pub memory: ScopedMemory,
71}
72
73impl<S: StorageEngine> VectorState<S> {
74    pub fn new(storage: Arc<S>, search_ef: usize, memory: ScopedMemory) -> Self {
75        Self {
76            hnsw_indices: Mutex::new(HashMap::new()),
77            vector_id_map: Mutex::new(VectorIdMap::new()),
78            search_ef,
79            storage,
80            codec_sidecars: Arc::new(Mutex::new(HashMap::new())),
81            per_index_config: Arc::new(Mutex::new(HashMap::new())),
82            unloadable: Mutex::new(HashSet::new()),
83            evicted: Mutex::new(HashSet::new()),
84            memory,
85        }
86    }
87
88    pub fn from_restored(restored: RestoredVectorState<S>) -> Self {
89        Self {
90            hnsw_indices: Mutex::new(restored.indices),
91            vector_id_map: Mutex::new(restored.id_map),
92            search_ef: restored.search_ef,
93            storage: restored.storage,
94            codec_sidecars: Arc::new(Mutex::new(HashMap::new())),
95            per_index_config: Arc::new(Mutex::new(HashMap::new())),
96            unloadable: Mutex::new(HashSet::new()),
97            evicted: Mutex::new(HashSet::new()),
98            memory: restored.memory,
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::query::engine::{test_governor, test_scoped_memory};
107    use crate::storage::pagedb_storage::PagedbStorageMem;
108
109    #[tokio::test]
110    async fn per_index_config_starts_empty() {
111        let storage = Arc::new(
112            PagedbStorageMem::open_in_memory()
113                .await
114                .expect("in-memory pagedb"),
115        );
116        let memory = test_scoped_memory(&test_governor(), nodedb_mem::EngineId::Vector);
117        let state = VectorState::new(storage, 100, memory);
118        let configs = state.per_index_config.lock().expect("lock");
119        assert!(
120            configs.is_empty(),
121            "per_index_config must be empty on construction"
122        );
123    }
124}