nodedb_fts/backend/traits.rs
1// SPDX-License-Identifier: Apache-2.0
2
3use nodedb_types::Surrogate;
4
5use crate::posting::Posting;
6use crate::scope::IndexScope;
7
8/// Storage backend abstraction for the full-text search engine.
9///
10/// Origin implements this with redb (persistent). Lite implements with
11/// in-memory HashMap. All scoring, BMW, compression, and analysis logic
12/// works identically over any backend.
13///
14/// Every tenant-partitioned method takes `database_id: u64` and `tid: u64`
15/// as first-class parameters. Backends are required to isolate databases
16/// and tenants structurally — no boundary may depend on lexical-prefix
17/// ordering of a composed string key.
18///
19/// Per-index methods take an [`IndexScope`]: a collection's whole-document
20/// index or one of its field indexes. Backends key every per-index entry by
21/// both the collection and the scope's field key, so no two scopes share
22/// postings, lengths, stats, metadata, or segments. Collection-level
23/// configuration (analyzer, language, fuzzy) lives in the metadata of
24/// `IndexScope::document(collection)`.
25///
26/// Write methods take `&self` (not `&mut self`) because:
27/// - Redb provides transactional isolation internally — concurrent writes
28/// are safe through redb's MVCC.
29/// - MemoryBackend uses interior mutability (`RefCell`) to match the same
30/// trait signature, keeping the trait uniform.
31pub trait FtsBackend {
32 /// Error type for backend operations.
33 type Error: std::fmt::Display;
34
35 /// Read the posting list for a term in an index: one posting per
36 /// document.
37 fn read_postings(
38 &self,
39 database_id: u64,
40 tid: u64,
41 index: IndexScope<'_>,
42 term: &str,
43 ) -> Result<Vec<Posting>, Self::Error>;
44
45 /// Write/replace the posting list for a term in an index.
46 fn write_postings(
47 &self,
48 database_id: u64,
49 tid: u64,
50 index: IndexScope<'_>,
51 term: &str,
52 postings: &[Posting],
53 ) -> Result<(), Self::Error>;
54
55 /// Remove a term's posting list entirely.
56 fn remove_postings(
57 &self,
58 database_id: u64,
59 tid: u64,
60 index: IndexScope<'_>,
61 term: &str,
62 ) -> Result<(), Self::Error>;
63
64 /// Insert one document's posting into a term's list, replacing that
65 /// document's prior posting in the list.
66 ///
67 /// The default reads and rewrites the whole list, so it costs the list's
68 /// length. A backend that stores one entry per `(term, document)`
69 /// overrides it with a single write.
70 fn upsert_posting(
71 &self,
72 database_id: u64,
73 tid: u64,
74 index: IndexScope<'_>,
75 term: &str,
76 posting: &Posting,
77 ) -> Result<(), Self::Error> {
78 let mut list = self.read_postings(database_id, tid, index, term)?;
79 match list.iter_mut().find(|p| p.doc_id == posting.doc_id) {
80 Some(slot) => slot.clone_from(posting),
81 None => list.push(posting.clone()),
82 }
83 self.write_postings(database_id, tid, index, term, &list)
84 }
85
86 /// Remove one document's posting from a term's list. The list is removed
87 /// when this empties it. A document absent from the list is a no-op.
88 ///
89 /// The default reads and rewrites the whole list, so it costs the list's
90 /// length. A backend that stores one entry per `(term, document)`
91 /// overrides it with a single remove.
92 fn remove_posting(
93 &self,
94 database_id: u64,
95 tid: u64,
96 index: IndexScope<'_>,
97 term: &str,
98 doc_id: Surrogate,
99 ) -> Result<(), Self::Error> {
100 let mut list = self.read_postings(database_id, tid, index, term)?;
101 let before = list.len();
102 list.retain(|p| p.doc_id != doc_id);
103 if list.len() == before {
104 return Ok(());
105 }
106 if list.is_empty() {
107 self.remove_postings(database_id, tid, index, term)
108 } else {
109 self.write_postings(database_id, tid, index, term, &list)
110 }
111 }
112
113 /// Read the document length (token count) of a document in an index.
114 fn read_doc_length(
115 &self,
116 database_id: u64,
117 tid: u64,
118 index: IndexScope<'_>,
119 doc_id: Surrogate,
120 ) -> Result<Option<u32>, Self::Error>;
121
122 /// Read the document lengths of `doc_ids` in an index, parallel to
123 /// `doc_ids`. A backend with transactions reads them in one.
124 fn read_doc_lengths(
125 &self,
126 database_id: u64,
127 tid: u64,
128 index: IndexScope<'_>,
129 doc_ids: &[Surrogate],
130 ) -> Result<Vec<Option<u32>>, Self::Error> {
131 doc_ids
132 .iter()
133 .map(|doc_id| self.read_doc_length(database_id, tid, index, *doc_id))
134 .collect()
135 }
136
137 /// Write/replace the document length of a document in an index.
138 fn write_doc_length(
139 &self,
140 database_id: u64,
141 tid: u64,
142 index: IndexScope<'_>,
143 doc_id: Surrogate,
144 length: u32,
145 ) -> Result<(), Self::Error>;
146
147 /// Remove a document's length entry from an index.
148 fn remove_doc_length(
149 &self,
150 database_id: u64,
151 tid: u64,
152 index: IndexScope<'_>,
153 doc_id: Surrogate,
154 ) -> Result<(), Self::Error>;
155
156 /// Get all term names in an index (for fuzzy matching).
157 fn collection_terms(
158 &self,
159 database_id: u64,
160 tid: u64,
161 index: IndexScope<'_>,
162 ) -> Result<Vec<String>, Self::Error>;
163
164 /// Get total document count and sum of all document lengths of an index.
165 /// Returns `(doc_count, total_token_sum)`.
166 ///
167 /// Implementations should maintain these incrementally for O(1) lookup.
168 fn collection_stats(
169 &self,
170 database_id: u64,
171 tid: u64,
172 index: IndexScope<'_>,
173 ) -> Result<(u32, u64), Self::Error>;
174
175 /// Increment index stats after indexing a document.
176 /// `doc_len` is the number of tokens in the newly indexed document.
177 fn increment_stats(
178 &self,
179 database_id: u64,
180 tid: u64,
181 index: IndexScope<'_>,
182 doc_len: u32,
183 ) -> Result<(), Self::Error>;
184
185 /// Decrement index stats after removing a document.
186 /// `doc_len` is the token count of the removed document.
187 fn decrement_stats(
188 &self,
189 database_id: u64,
190 tid: u64,
191 index: IndexScope<'_>,
192 doc_len: u32,
193 ) -> Result<(), Self::Error>;
194
195 /// Read a metadata blob by sub-key (e.g., "docmap", "fieldnorms",
196 /// "analyzer", "language").
197 fn read_meta(
198 &self,
199 database_id: u64,
200 tid: u64,
201 index: IndexScope<'_>,
202 subkey: &str,
203 ) -> Result<Option<Vec<u8>>, Self::Error>;
204
205 /// Write a metadata blob by sub-key.
206 fn write_meta(
207 &self,
208 database_id: u64,
209 tid: u64,
210 index: IndexScope<'_>,
211 subkey: &str,
212 value: &[u8],
213 ) -> Result<(), Self::Error>;
214
215 /// Write a segment blob. `segment_id` is a stable per-index
216 /// identifier (e.g., `"L{level}:{id:016x}"`).
217 fn write_segment(
218 &self,
219 database_id: u64,
220 tid: u64,
221 index: IndexScope<'_>,
222 segment_id: &str,
223 data: &[u8],
224 ) -> Result<(), Self::Error>;
225
226 /// Read a segment blob. Returns None if not found.
227 fn read_segment(
228 &self,
229 database_id: u64,
230 tid: u64,
231 index: IndexScope<'_>,
232 segment_id: &str,
233 ) -> Result<Option<Vec<u8>>, Self::Error>;
234
235 /// List all segment ids of an index.
236 fn list_segments(
237 &self,
238 database_id: u64,
239 tid: u64,
240 index: IndexScope<'_>,
241 ) -> Result<Vec<String>, Self::Error>;
242
243 /// Remove a segment blob.
244 fn remove_segment(
245 &self,
246 database_id: u64,
247 tid: u64,
248 index: IndexScope<'_>,
249 segment_id: &str,
250 ) -> Result<(), Self::Error>;
251
252 /// Remove all entries of every index of a collection. Returns count of
253 /// removed entries.
254 fn purge_collection(
255 &self,
256 database_id: u64,
257 tid: u64,
258 collection: &str,
259 ) -> Result<usize, Self::Error>;
260
261 /// Remove all entries for a `(database_id, tid)` across every collection.
262 /// Returns count of removed entries. Implementations MUST use a structural
263 /// drop (e.g., tuple range `(db, tid, ..)..(db, tid+1, ..)`) rather than a
264 /// lexical-prefix scan.
265 fn purge_tenant(&self, database_id: u64, tid: u64) -> Result<usize, Self::Error>;
266}