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}