1use 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
23pub struct FtsIndex<B: FtsBackend> {
39 pub(crate) backend: B,
40 pub(crate) bm25_params: Bm25Params,
41 pub(crate) memtable: Memtable,
42 next_segment_id: AtomicU64,
44 pub(crate) governor: Option<Arc<MemoryGovernor>>,
46}
47
48impl<B: FtsBackend> FtsIndex<B> {
49 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 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 pub fn set_governor(&mut self, governor: Arc<MemoryGovernor>) {
78 self.governor = Some(governor);
79 }
80
81 pub fn backend(&self) -> &B {
83 &self.backend
84 }
85
86 pub fn backend_mut(&mut self) -> &mut B {
88 &mut self.backend
89 }
90
91 pub fn memtable(&self) -> &Memtable {
93 &self.memtable
94 }
95
96 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 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 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 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 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 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
237pub(crate) fn memtable_key(database_id: u64, tid: u64, collection: &str, term: &str) -> String {
241 format!("{database_id}:{tid}:{collection}:{term}")
242}
243
244pub(crate) fn memtable_collection_prefix(database_id: u64, tid: u64, collection: &str) -> String {
247 format!("{database_id}:{tid}:{collection}:")
248}
249
250pub(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 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 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 #[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 #[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 #[test]
461 fn index_document_accepts_max_indexable_surrogate() {
462 let idx = make_index();
472 idx.index_document(DB, T, "docs", Surrogate(1), "boundary check")
474 .unwrap();
475 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 #[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}