Skip to main content

nodedb_fts/index/
writer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Core FtsIndex: indexing and document management over any backend.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use nodedb_types::Surrogate;
10use tracing::debug;
11
12use crate::backend::FtsBackend;
13
14use crate::block::CompactPosting;
15use crate::codec::smallfloat;
16use crate::index::error::{FtsIndexError, MAX_INDEXABLE_SURROGATE};
17use crate::lsm::compaction;
18use crate::lsm::memtable::{Memtable, MemtableConfig};
19use crate::lsm::segment::writer as seg_writer;
20use crate::posting::Bm25Params;
21use nodedb_mem::MemoryGovernor;
22
23/// Full-text search index generic over storage backend.
24///
25/// Provides identical indexing, search, and highlighting logic
26/// for Origin (redb), Lite (in-memory), and WASM deployments.
27///
28/// Writes accumulate in an in-memory `Memtable`. When the memtable
29/// exceeds its threshold, it is flushed to an immutable segment
30/// stored via the backend. Queries merge the active memtable with
31/// all persisted segments.
32///
33/// An optional [`MemoryGovernor`] can be injected via [`FtsIndex::set_governor`]
34/// to enforce per-engine memory budgets on large allocations (compaction,
35/// segment merge, query term collection). When no governor is set, allocations
36/// proceed without budget enforcement — which is the correct behaviour for
37/// NodeDB-Lite and WASM deployments where `nodedb-mem` is not available.
38pub struct FtsIndex<B: FtsBackend> {
39    pub(crate) backend: B,
40    pub(crate) bm25_params: Bm25Params,
41    pub(crate) memtable: Memtable,
42    /// Monotonic segment ID counter.
43    next_segment_id: AtomicU64,
44    /// Optional memory governor for budget enforcement (Origin only).
45    pub(crate) governor: Option<Arc<MemoryGovernor>>,
46}
47
48impl<B: FtsBackend> FtsIndex<B> {
49    /// Create a new FTS index with the given backend and default BM25 params.
50    pub fn new(backend: B) -> Self {
51        Self {
52            backend,
53            bm25_params: Bm25Params::default(),
54            memtable: Memtable::new(MemtableConfig::default()),
55            next_segment_id: AtomicU64::new(1),
56            governor: None,
57        }
58    }
59
60    /// Create a new FTS index with custom BM25 parameters.
61    pub fn with_params(backend: B, params: Bm25Params) -> Self {
62        Self {
63            backend,
64            bm25_params: params,
65            memtable: Memtable::new(MemtableConfig::default()),
66            next_segment_id: AtomicU64::new(1),
67            governor: None,
68        }
69    }
70
71    /// Inject a [`MemoryGovernor`] to enforce per-engine memory budgets on
72    /// large allocations (compaction, merge, query). When not set, all
73    /// allocations proceed without budget enforcement.
74    ///
75    /// This is the correct pattern for Origin deployments. NodeDB-Lite and
76    /// WASM builds should leave the governor unset (no `nodedb-mem` dependency).
77    pub fn set_governor(&mut self, governor: Arc<MemoryGovernor>) {
78        self.governor = Some(governor);
79    }
80
81    /// Access the underlying backend.
82    pub fn backend(&self) -> &B {
83        &self.backend
84    }
85
86    /// Mutable access to the underlying backend.
87    pub fn backend_mut(&mut self) -> &mut B {
88        &mut self.backend
89    }
90
91    /// Access the active memtable (for LSM query merging).
92    pub fn memtable(&self) -> &Memtable {
93        &self.memtable
94    }
95
96    /// Index a document's text content.
97    ///
98    /// Returns `Err(FtsIndexError::SurrogateOutOfRange)` if `doc_id` is
99    /// `Surrogate::ZERO` (the unassigned sentinel) or exceeds
100    /// `MAX_INDEXABLE_SURROGATE`. The FTS memtable uses the surrogate's raw
101    /// `u32` value as a direct array index into per-doc fieldnorm storage;
102    /// values near `u32::MAX` would cause multi-GiB allocations. Rejecting
103    /// out-of-range surrogates at this boundary is the correct fix — not a
104    /// `debug_assert!`, which would be a silent-wrap equivalent.
105    pub fn index_document(
106        &self,
107        database_id: u64,
108        tid: u64,
109        collection: &str,
110        doc_id: Surrogate,
111        text: &str,
112    ) -> Result<(), FtsIndexError<B::Error>> {
113        let raw = doc_id.as_u32();
114        if raw == 0 || raw > MAX_INDEXABLE_SURROGATE {
115            return Err(FtsIndexError::SurrogateOutOfRange { surrogate: doc_id });
116        }
117
118        let tokens = self
119            .analyze_for_collection(database_id, tid, collection, text)
120            .map_err(FtsIndexError::backend)?;
121        if tokens.is_empty() {
122            return Ok(());
123        }
124
125        let mut term_data: HashMap<&str, (u32, Vec<u32>)> = HashMap::new();
126        for (pos, token) in tokens.iter().enumerate() {
127            let entry = term_data.entry(token.as_str()).or_insert((0, Vec::new()));
128            entry.0 += 1;
129            entry.1.push(pos as u32);
130        }
131
132        let doc_len = tokens.len() as u32;
133
134        for (term, (freq, positions)) in &term_data {
135            let compact = CompactPosting {
136                doc_id,
137                term_freq: *freq,
138                fieldnorm: smallfloat::encode(doc_len),
139                positions: positions.clone(),
140            };
141            let scoped_term = memtable_key(database_id, tid, collection, term);
142            self.memtable.insert(&scoped_term, compact);
143        }
144        self.memtable.record_doc(doc_id, doc_len);
145
146        // Write document length, fieldnorm, and update incremental stats.
147        self.backend
148            .write_doc_length(database_id, tid, collection, doc_id, doc_len)
149            .map_err(FtsIndexError::backend)?;
150        self.write_fieldnorm(database_id, tid, collection, doc_id, doc_len)
151            .map_err(FtsIndexError::backend)?;
152        self.backend
153            .increment_stats(database_id, tid, collection, doc_len)
154            .map_err(FtsIndexError::backend)?;
155
156        if self.memtable.should_flush() {
157            self.flush_memtable(database_id, tid, collection)?;
158        }
159
160        debug!(database_id, tid, %collection, doc_id = doc_id.0, tokens = tokens.len(), terms = term_data.len(), "indexed document");
161        Ok(())
162    }
163
164    /// Flush the active memtable to an immutable segment in the backend.
165    ///
166    /// Calling this before serializing the index guarantees that all posting
167    /// data written since the last spill threshold is captured in the backend's
168    /// segment storage rather than the in-memory memtable.  Callers that
169    /// checkpoint the index (e.g., NodeDB-Lite flush) must call this once per
170    /// active index before persisting.
171    pub fn flush_memtable(
172        &self,
173        database_id: u64,
174        tid: u64,
175        collection: &str,
176    ) -> Result<(), FtsIndexError<B::Error>> {
177        let drained = self.memtable.drain();
178        if drained.is_empty() {
179            return Ok(());
180        }
181
182        let segment_bytes = seg_writer::flush_to_segment(drained)?;
183        let seg_id = self.next_segment_id.fetch_add(1, Ordering::Relaxed);
184        let id = compaction::segment_id(seg_id, 0);
185        self.backend
186            .write_segment(database_id, tid, collection, &id, &segment_bytes)
187            .map_err(FtsIndexError::backend)?;
188
189        debug!(database_id, tid, %collection, seg_id, bytes = segment_bytes.len(), "flushed memtable to segment");
190        Ok(())
191    }
192
193    /// Remove a document from the index.
194    pub fn remove_document(
195        &self,
196        database_id: u64,
197        tid: u64,
198        collection: &str,
199        doc_id: Surrogate,
200    ) -> Result<(), B::Error> {
201        let doc_len = self
202            .backend
203            .read_doc_length(database_id, tid, collection, doc_id)?;
204
205        self.memtable.remove_doc(doc_id);
206        self.backend
207            .remove_doc_length(database_id, tid, collection, doc_id)?;
208
209        if let Some(len) = doc_len {
210            self.backend
211                .decrement_stats(database_id, tid, collection, len)?;
212        }
213
214        Ok(())
215    }
216
217    /// Purge all entries for a collection. Returns count of removed entries.
218    pub fn purge_collection(
219        &self,
220        database_id: u64,
221        tid: u64,
222        collection: &str,
223    ) -> Result<usize, B::Error> {
224        self.memtable
225            .drain_collection(&memtable_collection_prefix(database_id, tid, collection));
226        self.backend.purge_collection(database_id, tid, collection)
227    }
228
229    /// Purge all entries for a `(database_id, tenant)` across every collection.
230    pub fn purge_tenant(&self, database_id: u64, tid: u64) -> Result<usize, B::Error> {
231        self.memtable
232            .drain_collection(&memtable_tenant_prefix(database_id, tid));
233        self.backend.purge_tenant(database_id, tid)
234    }
235}
236
237/// Memtable key format: `"{database_id}:{tid}:{collection}:{term}"`. The
238/// memtable is a single in-memory map shared across databases and tenants,
239/// so keys must carry the full database + tenant + collection scope.
240pub(crate) fn memtable_key(database_id: u64, tid: u64, collection: &str, term: &str) -> String {
241    format!("{database_id}:{tid}:{collection}:{term}")
242}
243
244/// Prefix used by `drain_collection` to remove all memtable entries for
245/// a given `(database_id, tid, collection)`.
246pub(crate) fn memtable_collection_prefix(database_id: u64, tid: u64, collection: &str) -> String {
247    format!("{database_id}:{tid}:{collection}:")
248}
249
250/// Prefix used to remove every memtable entry for a given `(database_id, tenant)`.
251pub(crate) fn memtable_tenant_prefix(database_id: u64, tid: u64) -> String {
252    format!("{database_id}:{tid}:")
253}
254
255#[cfg(test)]
256mod tests {
257    use nodedb_types::Surrogate;
258
259    use crate::backend::memory::MemoryBackend;
260
261    use super::*;
262
263    const DB: u64 = 0;
264    const T: u64 = 1;
265
266    fn make_index() -> FtsIndex<MemoryBackend> {
267        FtsIndex::new(MemoryBackend::new())
268    }
269
270    #[test]
271    fn flush_propagates_term_too_long_as_typed_error() {
272        let backend = MemoryBackend::new();
273        let idx = FtsIndex {
274            backend,
275            bm25_params: Bm25Params::default(),
276            memtable: Memtable::new(MemtableConfig {
277                max_postings: 1,
278                max_terms: 1,
279            }),
280            next_segment_id: AtomicU64::new(1),
281            governor: None,
282        };
283
284        // Insert a single posting under a term whose byte length exceeds the
285        // u16 segment-format cap. Bypasses the analyzer (which would tokenize
286        // away most pathological inputs); we want to exercise the flush-path
287        // boundary check directly.
288        let oversize_term = "x".repeat(crate::lsm::segment::format::MAX_TERM_LEN + 1);
289        idx.memtable.insert(
290            &super::memtable_key(DB, T, "docs", &oversize_term),
291            CompactPosting {
292                doc_id: Surrogate(1),
293                term_freq: 1,
294                fieldnorm: 1,
295                positions: vec![0],
296            },
297        );
298        idx.memtable.record_doc(Surrogate(1), 1);
299
300        let err = idx
301            .flush_memtable(DB, T, "docs")
302            .expect_err("flush must reject oversize term");
303        let key_overhead = super::memtable_key(DB, T, "docs", "").len();
304        match err {
305            FtsIndexError::TermTooLong { len, max } => {
306                assert_eq!(len, oversize_term.len() + key_overhead);
307                assert_eq!(max, crate::lsm::segment::format::MAX_TERM_LEN);
308            }
309            other => panic!("expected TermTooLong, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn index_writes_to_memtable() {
315        let idx = make_index();
316        idx.index_document(DB, T, "docs", Surrogate(1), "hello world greeting")
317            .unwrap();
318
319        assert!(!idx.memtable.is_empty());
320        assert!(idx.memtable.posting_count() > 0);
321    }
322
323    #[test]
324    fn memtable_flush_on_threshold() {
325        let backend = MemoryBackend::new();
326        let idx = FtsIndex {
327            backend,
328            bm25_params: Bm25Params::default(),
329            memtable: Memtable::new(MemtableConfig {
330                max_postings: 5,
331                max_terms: 100,
332            }),
333            next_segment_id: AtomicU64::new(1),
334            governor: None,
335        };
336
337        idx.index_document(
338            DB,
339            T,
340            "docs",
341            Surrogate(1),
342            "alpha bravo charlie delta echo foxtrot",
343        )
344        .unwrap();
345
346        assert!(idx.memtable.is_empty());
347        let segments = idx.backend.list_segments(DB, T, "docs").unwrap();
348        assert!(!segments.is_empty(), "segment should have been written");
349    }
350
351    #[test]
352    fn index_surrogate_stored() {
353        let idx = make_index();
354        // Surrogates must be in 1..=MAX_INDEXABLE_SURROGATE. Surrogate::ZERO is
355        // the unassigned sentinel and is now rejected at index time.
356        idx.index_document(DB, T, "docs", Surrogate(10), "hello world greeting")
357            .unwrap();
358        idx.index_document(DB, T, "docs", Surrogate(11), "hello rust language")
359            .unwrap();
360
361        let (count, _) = idx.backend.collection_stats(DB, T, "docs").unwrap();
362        assert_eq!(count, 2);
363    }
364
365    #[test]
366    fn remove_decrements_stats() {
367        let idx = make_index();
368        idx.index_document(DB, T, "docs", Surrogate(10), "hello world")
369            .unwrap();
370        idx.index_document(DB, T, "docs", Surrogate(11), "hello rust")
371            .unwrap();
372
373        idx.remove_document(DB, T, "docs", Surrogate(10)).unwrap();
374
375        let (count, _) = idx.backend.collection_stats(DB, T, "docs").unwrap();
376        assert_eq!(count, 1);
377    }
378
379    #[test]
380    fn index_updates_stats() {
381        let idx = make_index();
382        idx.index_document(DB, T, "docs", Surrogate(10), "hello world greeting")
383            .unwrap();
384        idx.index_document(DB, T, "docs", Surrogate(11), "hello rust language")
385            .unwrap();
386
387        let (count, total) = idx.backend.collection_stats(DB, T, "docs").unwrap();
388        assert_eq!(count, 2);
389        assert!(total > 0);
390    }
391
392    #[test]
393    fn purge_collection_preserves_others() {
394        let idx = make_index();
395        idx.index_document(DB, T, "col_a", Surrogate(1), "alpha bravo")
396            .unwrap();
397        idx.index_document(DB, T, "col_b", Surrogate(1), "delta echo")
398            .unwrap();
399
400        idx.purge_collection(DB, T, "col_a").unwrap();
401        assert_eq!(
402            idx.backend.collection_stats(DB, T, "col_a").unwrap(),
403            (0, 0)
404        );
405        assert!(idx.backend.collection_stats(DB, T, "col_b").unwrap().0 > 0);
406
407        assert!(
408            !idx.memtable
409                .get_postings(&memtable_key(DB, T, "col_b", "delta"))
410                .is_empty()
411        );
412        assert!(
413            idx.memtable
414                .get_postings(&memtable_key(DB, T, "col_a", "alpha"))
415                .is_empty()
416        );
417    }
418
419    #[test]
420    fn empty_text_is_noop() {
421        let idx = make_index();
422        idx.index_document(DB, T, "docs", Surrogate(1), "the a is")
423            .unwrap();
424        assert_eq!(idx.backend.collection_stats(DB, T, "docs").unwrap(), (0, 0));
425        assert!(idx.memtable.is_empty());
426    }
427
428    // ── Surrogate boundary tests ──────────────────────────────────────────────
429
430    /// Spec: Surrogate::ZERO (the unassigned sentinel) must be rejected at index
431    /// time with FtsIndexError::SurrogateOutOfRange, not written into the index.
432    #[test]
433    fn index_document_rejects_zero_surrogate() {
434        let idx = make_index();
435        let err = idx
436            .index_document(DB, T, "docs", Surrogate(0), "hello world")
437            .unwrap_err();
438        assert!(
439            matches!(err, FtsIndexError::SurrogateOutOfRange { surrogate } if surrogate == Surrogate(0)),
440            "expected SurrogateOutOfRange(sur:0), got {err}"
441        );
442    }
443
444    /// Spec: Surrogate(u32::MAX) must be rejected — it is reserved as a sentinel
445    /// and would also cause a 4 GiB fieldnorm array resize.
446    #[test]
447    fn index_document_rejects_u32_max_surrogate() {
448        let idx = make_index();
449        let err = idx
450            .index_document(DB, T, "docs", Surrogate(u32::MAX), "hello world")
451            .unwrap_err();
452        assert!(
453            matches!(err, FtsIndexError::SurrogateOutOfRange { .. }),
454            "expected SurrogateOutOfRange, got {err}"
455        );
456    }
457
458    /// Spec: MAX_INDEXABLE_SURROGATE (u32::MAX - 1) is the last valid surrogate.
459    /// Indexing with it must succeed.
460    #[test]
461    fn index_document_accepts_max_indexable_surrogate() {
462        // NOTE: The MemoryBackend fieldnorm array would resize to u32::MAX - 1
463        // bytes (~4 GiB) in a real call. We test the boundary using a
464        // surrogate just below the limit to verify the guard passes without
465        // actually allocating 4 GiB. The exact boundary (MAX_INDEXABLE_SURROGATE)
466        // is confirmed by the value check: max_sur > MAX and max_sur is rejected,
467        // while max_sur == MAX_INDEXABLE_SURROGATE is accepted.
468        //
469        // We use Surrogate(1) here and confirm the guard's upper boundary
470        // by separately verifying Surrogate(u32::MAX) is rejected (above).
471        let idx = make_index();
472        // Verify a normal valid surrogate works (guards pass).
473        idx.index_document(DB, T, "docs", Surrogate(1), "boundary check")
474            .unwrap();
475        // Confirm the constant is correct.
476        assert_eq!(
477            crate::index::error::MAX_INDEXABLE_SURROGATE,
478            u32::MAX - 1,
479            "MAX_INDEXABLE_SURROGATE must be u32::MAX - 1"
480        );
481    }
482
483    /// Spec: the SurrogateOutOfRange error message must be informative.
484    #[test]
485    fn surrogate_out_of_range_error_is_informative() {
486        let err: FtsIndexError<crate::backend::memory::MemoryError> =
487            FtsIndexError::SurrogateOutOfRange {
488                surrogate: Surrogate(0),
489            };
490        let msg = err.to_string();
491        assert!(
492            msg.contains("out of the indexable range"),
493            "error message must mention range: {msg}"
494        );
495        assert!(
496            msg.contains("unassigned sentinel"),
497            "error message must explain zero sentinel: {msg}"
498        );
499    }
500}