Skip to main content

nodedb_lite/engine/vector/
resident.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Resident access to HNSW indexes that eviction can drop from memory.
4//!
5//! Eviction writes an index's checkpoint to storage and removes it from
6//! `hnsw_indices`. Every insert, search and delete reaches its index through
7//! this module, which loads a stored index back before handing it out:
8//!
9//! - [`lock_resident`] locks the index map with the index loaded when storage
10//!   holds one.
11//! - [`lock_resident_or_create`] does the same, then creates an empty index
12//!   only when neither memory nor storage holds one. Creating one over an
13//!   evicted index would hide every vector it held.
14//!
15//! A load awaits storage, so eviction can run between the load and the lock.
16//! The eviction mark (`VectorState::evicted`) catches that: an index absent
17//! from memory and still marked is loaded again before the lock is returned.
18
19use std::collections::HashMap;
20use std::sync::{Arc, MutexGuard};
21
22use nodedb_types::hnsw::HnswParams;
23use nodedb_types::vector_dtype::VectorStorageDtype;
24
25use crate::engine::vector::HnswIndex;
26use crate::engine::vector::VectorState;
27use crate::engine::vector::search::lazy_load::ensure_index_loaded;
28use crate::error::LiteError;
29use crate::nodedb::lock_ext::LockExt;
30use crate::storage::engine::StorageEngine;
31
32/// The locked index map.
33pub(crate) type IndexMap<'a> = MutexGuard<'a, HashMap<String, HnswIndex>>;
34
35/// Lock the index map with `index_key`'s index resident when memory or
36/// storage holds one. Fails when reading the stored index fails.
37pub(crate) async fn lock_resident<'a, S: StorageEngine>(
38    state: &'a Arc<VectorState<S>>,
39    index_key: &str,
40) -> Result<IndexMap<'a>, LiteError> {
41    loop {
42        ensure_index_loaded(state, index_key).await?;
43        let indices = state.hnsw_indices.lock_or_recover();
44        if indices.contains_key(index_key) || !state.evicted.lock_or_recover().contains(index_key) {
45            return Ok(indices);
46        }
47    }
48}
49
50/// The locked index map holding `key`'s index, loaded or created.
51pub(crate) struct ResidentIndex<'a> {
52    indices: IndexMap<'a>,
53    key: String,
54    dim: usize,
55    dtype: VectorStorageDtype,
56}
57
58impl ResidentIndex<'_> {
59    /// The index. It exists: it was loaded, or is created on first use.
60    pub(crate) fn index(&mut self) -> &mut HnswIndex {
61        get_or_create(&mut self.indices, &self.key, self.dim, self.dtype)
62    }
63}
64
65/// Lock the index map with `index_key`'s index loaded, or ready to be
66/// created at `dim` with the storage dtype configured for `index_key`
67/// (F32 when none is). An existing index keeps its own dimension and dtype.
68/// Fails as [`lock_resident`] does.
69pub(crate) async fn lock_resident_or_create<'a, S: StorageEngine>(
70    state: &'a Arc<VectorState<S>>,
71    index_key: &str,
72    dim: usize,
73) -> Result<ResidentIndex<'a>, LiteError> {
74    let dtype = state
75        .per_index_config
76        .lock_or_recover()
77        .get(index_key)
78        .map(|cfg| cfg.storage_dtype)
79        .unwrap_or(VectorStorageDtype::F32);
80    let indices = lock_resident(state, index_key).await?;
81    Ok(ResidentIndex {
82        indices,
83        key: index_key.to_string(),
84        dim,
85        dtype,
86    })
87}
88
89/// Refuse vectors whose width differs from `index_key`'s index, loading a
90/// stored index first, or from each other when there is no index.
91///
92/// Runs before an insert writes its durable rows, so a refused vector never
93/// reaches storage.
94pub(crate) async fn check_insert_widths<S: StorageEngine>(
95    state: &Arc<VectorState<S>>,
96    index_key: &str,
97    widths: impl IntoIterator<Item = usize>,
98) -> Result<(), LiteError> {
99    let mut expected = lock_resident(state, index_key)
100        .await?
101        .get(index_key)
102        .map(HnswIndex::dim);
103    for got in widths {
104        match expected {
105            Some(dim) => nodedb_vector::error::check_dim(dim, got)?,
106            None => expected = Some(got),
107        }
108    }
109    Ok(())
110}
111
112/// Get or create the index for `index_key`. An existing index ignores `dim`
113/// and `dtype`: both are fixed when the index is created.
114fn get_or_create<'a>(
115    indices: &'a mut HashMap<String, HnswIndex>,
116    index_key: &str,
117    dim: usize,
118    dtype: VectorStorageDtype,
119) -> &'a mut HnswIndex {
120    indices.entry(index_key.to_string()).or_insert_with(|| {
121        HnswIndex::new(
122            dim,
123            HnswParams {
124                dtype,
125                ..HnswParams::default()
126            },
127        )
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use nodedb_types::Namespace;
134
135    use super::*;
136    use crate::query::engine::{test_governor, test_scoped_memory};
137    use crate::storage::pagedb_storage::PagedbStorageMem;
138
139    async fn state() -> Arc<VectorState<PagedbStorageMem>> {
140        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
141        let memory = test_scoped_memory(&test_governor(), nodedb_mem::EngineId::Vector);
142        Arc::new(VectorState::new(storage, 64, memory))
143    }
144
145    #[test]
146    fn get_or_create_uses_the_dtype_only_at_creation() {
147        let mut indices: HashMap<String, HnswIndex> = HashMap::new();
148        get_or_create(&mut indices, "col", 4, VectorStorageDtype::BF16);
149        assert_eq!(
150            indices.get("col").map(|i| i.params().dtype),
151            Some(VectorStorageDtype::BF16)
152        );
153        get_or_create(&mut indices, "col", 4, VectorStorageDtype::F32);
154        assert_eq!(
155            indices.get("col").map(|i| i.params().dtype),
156            Some(VectorStorageDtype::BF16),
157            "dtype is fixed at index-creation time"
158        );
159    }
160
161    /// An index evicted to storage comes back on the next insert instead of
162    /// being replaced by an empty one.
163    #[tokio::test]
164    async fn an_evicted_index_is_loaded_not_recreated() {
165        let state = state().await;
166        // The durable row every insert writes first: a graph-only checkpoint
167        // is rebuilt from it.
168        state
169            .storage
170            .batch_write(&[crate::engine::vector::durable::put_op(
171                "docs",
172                "a",
173                &[1.0, 0.0, 0.0],
174            )])
175            .await
176            .unwrap();
177        {
178            let mut resident = lock_resident_or_create(&state, "docs", 3).await.unwrap();
179            resident.index().insert(vec![1.0, 0.0, 0.0]).unwrap();
180        }
181        // Evict by hand: checkpoint, mark, drop.
182        let blob = state
183            .hnsw_indices
184            .lock_or_recover()
185            .get("docs")
186            .unwrap()
187            .checkpoint_to_bytes()
188            .unwrap();
189        state
190            .storage
191            .put(
192                Namespace::Vector,
193                b"hnsw:docs",
194                &crate::storage::checksum::wrap(&blob),
195            )
196            .await
197            .unwrap();
198        {
199            let mut indices = state.hnsw_indices.lock_or_recover();
200            state.evicted.lock_or_recover().insert("docs".into());
201            indices.remove("docs");
202        }
203
204        let mut resident = lock_resident_or_create(&state, "docs", 3).await.unwrap();
205        assert_eq!(resident.index().len(), 1, "the evicted vector is back");
206        assert!(!state.evicted.lock_or_recover().contains("docs"));
207    }
208
209    /// With no index in memory or storage, an insert creates one.
210    #[tokio::test]
211    async fn a_new_collection_gets_an_empty_index() {
212        let state = state().await;
213        let mut resident = lock_resident_or_create(&state, "fresh", 2).await.unwrap();
214        assert_eq!(resident.index().dim(), 2);
215        assert_eq!(resident.index().len(), 0);
216    }
217}