Skip to main content

nodedb_lite/engine/fts/manager/
registry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! [`FtsCollectionManager`] state: the index map, the dense surrogate maps,
4//! index-key naming, error mapping, and checkpoint accessors.
5//!
6//! Wraps `nodedb_fts::FtsIndex<MemoryBackend>` with per-collection management:
7//! - Incremental insert/remove on document put/delete
8//! - Per-collection keying: `"{collection}:{field}"` per field, plus the bare
9//!   `"{collection}"` for the whole-document index
10//! - BM25 search delegated directly to nodedb-fts (BMW, analyzers, fuzzy)
11//! - Persistent: checkpoint serialized to `Namespace::Fts` on `flush()`,
12//!   restored on `NodeDbLite::open` without re-tokenizing source documents.
13
14use std::collections::{HashMap, HashSet};
15use std::fmt::Display;
16use std::sync::Arc;
17
18use nodedb_fts::FtsIndex;
19use nodedb_fts::FtsIndexError;
20use nodedb_fts::backend::memory::MemoryBackend;
21use nodedb_fts::lsm::memtable::MemtableConfig;
22use nodedb_mem::MemoryGovernor;
23use nodedb_types::Surrogate;
24
25use crate::engine::fts::LiteFtsIndex;
26use crate::error::LiteError;
27
28/// A resolved FTS result with the original string doc_id restored.
29pub struct FtsResult {
30    pub doc_id: String,
31    pub score: f32,
32    pub fuzzy: bool,
33}
34
35/// Index key for `field` of `collection`. An empty `field` names the
36/// whole-document index.
37///
38/// The whole-document key is the bare collection name. Every field key
39/// carries a `:` after the collection name, so no non-empty field name —
40/// `_doc` included — can produce the whole-document key.
41pub fn index_key(collection: &str, field: &str) -> String {
42    if field.is_empty() {
43        collection.to_owned()
44    } else {
45        format!("{collection}:{field}")
46    }
47}
48
49/// Whether index `key` belongs to `collection`: its whole-document key or
50/// one of its `"{collection}:{field}"` keys.
51pub(crate) fn key_of_collection(key: &str, collection: &str) -> bool {
52    key.strip_prefix(collection)
53        .is_some_and(|rest| rest.is_empty() || rest.starts_with(':'))
54}
55
56/// Create an index that keeps every posting in its memtable.
57///
58/// Lite serves phrase positions and checkpoints postings from the memtable
59/// alone. A memtable that spilled into a backend segment would drop those
60/// postings from both, so the spill thresholds are set out of reach.
61pub(crate) fn resident_index(governor: Arc<MemoryGovernor>) -> LiteFtsIndex {
62    FtsIndex::with_memtable_config(
63        MemoryBackend::new(),
64        MemtableConfig {
65            max_postings: usize::MAX,
66            max_terms: usize::MAX,
67        },
68        governor,
69    )
70}
71
72/// Wrap an index-layer write failure as the typed error the write path
73/// propagates.
74pub(in crate::engine::fts) fn fts_err(collection: &str, e: impl Display) -> LiteError {
75    LiteError::FtsIndex {
76        collection: collection.to_owned(),
77        detail: e.to_string(),
78    }
79}
80
81/// Classify an index-layer read failure: a query the index cannot run is
82/// the caller's error, a memory budget refusal is backpressure, anything
83/// else is a failed search.
84pub(in crate::engine::fts) fn search_err<E: Display>(
85    collection: &str,
86    e: FtsIndexError<E>,
87) -> LiteError {
88    match e {
89        FtsIndexError::InvalidQuery(q) => LiteError::FtsQueryInvalid {
90            collection: collection.to_owned(),
91            detail: q.to_string(),
92        },
93        FtsIndexError::BudgetExhausted(m) => LiteError::Backpressure {
94            detail: format!("full-text search on {collection}: {m}"),
95        },
96        other => read_err(collection, other),
97    }
98}
99
100/// Wrap a backend read failure as a failed search.
101pub(in crate::engine::fts) fn read_err(collection: &str, e: impl Display) -> LiteError {
102    LiteError::FtsSearch {
103        collection: collection.to_owned(),
104        detail: e.to_string(),
105    }
106}
107
108/// Manages per-collection (and per-field) in-memory full-text search indexes.
109///
110/// Each `(collection, field)` pair gets its own `FtsIndex<MemoryBackend>`.
111/// The bare `"{collection}"` key holds whole-document text (all string
112/// fields concatenated), searched when a caller names no field.
113pub struct FtsCollectionManager {
114    pub(super) retained: HashMap<String, super::retained::RetainedIndex>,
115    pub(super) declarations:
116        std::collections::BTreeMap<String, crate::engine::fts::catalog::SearchDeclarationRecord>,
117    /// Key: `"{collection}:{field}"` → FTS index.
118    /// Whole-document index uses the bare key `"{collection}"`.
119    pub(in crate::engine::fts) indices: HashMap<String, FtsIndex<MemoryBackend>>,
120    /// Forward map: original string doc_id → dense u32 surrogate.
121    ///
122    /// Surrogates **must** be dense (0, 1, 2, …) because `nodedb_fts::Memtable`
123    /// uses them as direct indices into a `Vec<u8>` fieldnorm array
124    /// (`record_doc` calls `vec.resize(surrogate + 1, 0)`). Hashing strings
125    /// into the u32 space produced sparse surrogates near `u32::MAX`, which
126    /// allocated multi-gigabyte zero-filled vectors per insert and made
127    /// indexing effectively hang.
128    id_to_surrogate: HashMap<String, u32>,
129    /// Reverse map: surrogate u32 → original string doc_id.
130    pub(super) surrogate_to_id: HashMap<u32, String>,
131    /// Next surrogate to assign on first sighting of a doc_id.
132    next_surrogate: u32,
133    /// Reverse map: Origin global surrogate → Lite string doc_id.
134    ///
135    /// Populated when `FtsIndexDoc` frames arrive from Origin via the sync path.
136    /// Needed by `FtsDeleteDoc` to translate the Origin surrogate back to the
137    /// Lite string doc_id without dropping the whole collection.
138    pub(super) origin_surrogate_to_doc_id: HashMap<u32, String>,
139    /// Collection name → bound analyzer name, from `TextOp::SetTextConfig`.
140    ///
141    /// Retained so indexes created after the analyzer was bound inherit it —
142    /// DDL normally binds the analyzer before the first document is written,
143    /// when none of the collection's indexes exist yet. See
144    /// `super::super::analyzer` for the binding logic.
145    pub(in crate::engine::fts) collection_analyzers: HashMap<String, String>,
146    /// Collection name → default fuzzy matching, from `TextOp::SetTextConfig`.
147    ///
148    /// Retained for the same reason as `collection_analyzers`: the DDL that
149    /// sets it usually runs before any of the collection's indexes exist.
150    pub(in crate::engine::fts) collection_fuzzy_defaults: HashMap<String, bool>,
151    /// Memory governor bound into every `FtsIndex` this manager creates.
152    pub(in crate::engine::fts) governor: Arc<MemoryGovernor>,
153}
154
155impl FtsCollectionManager {
156    pub fn new(governor: Arc<MemoryGovernor>) -> Self {
157        Self {
158            retained: HashMap::new(),
159            declarations: std::collections::BTreeMap::new(),
160            indices: HashMap::new(),
161            id_to_surrogate: HashMap::new(),
162            surrogate_to_id: HashMap::new(),
163            // Start at 1: Surrogate(0) is the unassigned sentinel and is
164            // rejected by FtsIndex::index_document with SurrogateOutOfRange.
165            next_surrogate: 1,
166            origin_surrogate_to_doc_id: HashMap::new(),
167            collection_analyzers: HashMap::new(),
168            collection_fuzzy_defaults: HashMap::new(),
169            governor,
170        }
171    }
172
173    /// Look up or allocate a dense surrogate for a string `doc_id`.
174    ///
175    /// Returns the existing surrogate if `doc_id` has been indexed before,
176    /// otherwise assigns the next sequential u32 and records the mapping
177    /// in both directions. Fails when the u32 surrogate space is used up.
178    pub(super) fn surrogate_for(
179        &mut self,
180        collection: &str,
181        doc_id: &str,
182    ) -> Result<Surrogate, LiteError> {
183        if let Some(&s) = self.id_to_surrogate.get(doc_id) {
184            return Ok(Surrogate(s));
185        }
186        let s = self.next_surrogate;
187        self.next_surrogate = s.checked_add(1).ok_or_else(|| {
188            fts_err(
189                collection,
190                format!(
191                    "no surrogate left for document '{doc_id}': all u32 surrogates are assigned"
192                ),
193            )
194        })?;
195        self.id_to_surrogate.insert(doc_id.to_owned(), s);
196        self.surrogate_to_id.insert(s, doc_id.to_owned());
197        Ok(Surrogate(s))
198    }
199
200    /// Look up an existing surrogate without allocating one.
201    pub(super) fn lookup_surrogate(&self, doc_id: &str) -> Option<Surrogate> {
202        self.id_to_surrogate.get(doc_id).copied().map(Surrogate)
203    }
204
205    /// The string doc_id a surrogate from `key`'s postings stands for.
206    ///
207    /// Surrogates are never released, so a posting whose surrogate has no
208    /// doc_id means the index and the surrogate maps disagree.
209    pub(super) fn doc_id_of(&self, collection: &str, s: Surrogate) -> Result<&str, LiteError> {
210        self.surrogate_to_id
211            .get(&s.0)
212            .map(String::as_str)
213            .ok_or_else(|| {
214                read_err(
215                    collection,
216                    format!("posting references surrogate {} with no document id", s.0),
217                )
218            })
219    }
220
221    /// Returns true if no collections are indexed.
222    pub fn is_empty(&self) -> bool {
223        self.indices.is_empty()
224    }
225
226    /// Number of distinct collection prefixes with active indexes.
227    pub fn collection_count(&self) -> usize {
228        self.indices
229            .keys()
230            .map(|k| k.split(':').next().unwrap_or(k.as_str()))
231            .collect::<HashSet<_>>()
232            .len()
233    }
234
235    /// Keys of every index belonging to `collection`, per-field and
236    /// whole-document alike.
237    pub(in crate::engine::fts) fn collection_keys(&self, collection: &str) -> Vec<String> {
238        self.indices
239            .keys()
240            .filter(|k| key_of_collection(k, collection))
241            .cloned()
242            .collect()
243    }
244
245    /// Whether any document of `collection` was ever text-indexed.
246    pub fn has_text_index(&self, collection: &str) -> bool {
247        self.indices
248            .keys()
249            .any(|k| key_of_collection(k, collection))
250    }
251
252    /// Drop all FTS indexes for a collection (called on collection drop/truncate).
253    pub fn drop_collection(&mut self, collection: &str) {
254        self.remove_collection_postings(collection);
255    }
256
257    // ── Checkpoint helpers (used by core.rs flush/restore) ────────────────────
258
259    /// Borrow the index map, surrogate map, and next-surrogate counter for
260    /// serialization.  Called by `checkpoint::serialize_fts`.
261    pub(crate) fn checkpoint_data(
262        &self,
263    ) -> (
264        &HashMap<String, FtsIndex<MemoryBackend>>,
265        &HashMap<String, u32>,
266        u32,
267    ) {
268        (&self.indices, &self.id_to_surrogate, self.next_surrogate)
269    }
270
271    /// Replace internal state from a restored checkpoint.  Called by
272    /// `restore_fts_indices` when a valid checkpoint is found.
273    pub(crate) fn load_checkpoint(
274        &mut self,
275        indices: HashMap<String, FtsIndex<MemoryBackend>>,
276        id_to_surrogate: HashMap<String, u32>,
277        surrogate_to_id: HashMap<u32, String>,
278        next_surrogate: u32,
279    ) {
280        self.retained = indices
281            .iter()
282            .map(|(key, index)| {
283                (
284                    key.clone(),
285                    super::retained::RetainedIndex::restored(
286                        Arc::clone(&self.governor),
287                        key,
288                        index,
289                    ),
290                )
291            })
292            .collect();
293        self.indices = indices;
294        self.id_to_surrogate = id_to_surrogate;
295        self.surrogate_to_id = surrogate_to_id;
296        self.next_surrogate = next_surrogate;
297        // origin_surrogate_to_doc_id is not persisted across restarts because
298        // origin surrogates are only relevant for the lifetime of a sync session;
299        // FtsIndexDoc frames re-register the mapping on re-sync.
300    }
301}
302
303/// Build a real, uncapped governor for FTS tests across `engine::fts`.
304#[cfg(test)]
305pub(crate) fn test_governor() -> Arc<MemoryGovernor> {
306    use nodedb_mem::{EngineLimits, GovernorConfig};
307
308    let per_engine = usize::MAX / nodedb_mem::EngineId::ALL.len();
309    Arc::new(
310        MemoryGovernor::new(GovernorConfig {
311            global_ceiling: per_engine * nodedb_mem::EngineId::ALL.len(),
312            engine_limits: EngineLimits::uniform(per_engine),
313        })
314        .expect("test governor"),
315    )
316}
317
318#[cfg(test)]
319mod tests {
320    use super::{index_key, key_of_collection};
321
322    #[test]
323    fn no_field_name_produces_the_whole_document_key() {
324        let whole = index_key("col", "");
325        assert_eq!(whole, "col");
326        for field in ["_doc", ":", "doc", "col"] {
327            assert_ne!(index_key("col", field), whole, "field {field:?}");
328        }
329        assert_eq!(index_key("col", "_doc"), "col:_doc");
330    }
331
332    #[test]
333    fn keys_are_owned_by_their_collection_only() {
334        assert!(key_of_collection("col", "col"));
335        assert!(key_of_collection("col:title", "col"));
336        assert!(!key_of_collection("column", "col"));
337        assert!(!key_of_collection("column:title", "col"));
338    }
339}