Skip to main content

nodedb_lite/storage/
pagedb_storage_fts.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `FtsSegmentExt` implementation for `PagedbStorage<V>`.
4//!
5//! One pagedb segment per FTS index key, storing all term postings for that
6//! index as a single blob.  Segment name: `fts/seg/{index_key}`.
7//!
8//! ## Segment index (B+ tree)
9//!
10//! pagedb's `ReadTxn::list_segments` returns `SegmentMeta` structs but does
11//! not expose the segment names.  Rather than adding a gap to pagedb, we
12//! maintain a small auxiliary index in the B+ tree under `Namespace::Fts`:
13//!
14//! - Key: `fts:_seg_idx:{index_key}` → empty value (presence = segment exists)
15//!
16//! `write_fts_segment` inserts this sentinel; `delete_fts_segment` removes it.
17//! `list_fts_segments(prefix)` scans `Namespace::Fts` with key prefix
18//! `fts:_seg_idx:{prefix}` and strips the leading `fts:_seg_idx:` tag.
19//!
20//! ## Page envelope
21//!
22//! Uses the same 8-byte little-endian length-prefix as the array segment impl
23//! to survive pagedb's page-boundary zero-padding.
24
25#[cfg(not(target_arch = "wasm32"))]
26use async_trait::async_trait;
27#[cfg(not(target_arch = "wasm32"))]
28use nodedb_types::Namespace;
29#[cfg(not(target_arch = "wasm32"))]
30use pagedb::vfs::Vfs;
31#[cfg(not(target_arch = "wasm32"))]
32use pagedb::{RealmId, SegmentKind};
33
34#[cfg(not(target_arch = "wasm32"))]
35use crate::error::LiteError;
36#[cfg(not(target_arch = "wasm32"))]
37use crate::storage::engine::StorageEngine;
38#[cfg(not(target_arch = "wasm32"))]
39use crate::storage::fts_segment_ext::FtsSegmentExt;
40#[cfg(not(target_arch = "wasm32"))]
41use crate::storage::pagedb_storage::PagedbStorage;
42
43/// pagedb page body capacity in bytes: 4096 - 40 bytes AEAD/header envelope.
44#[cfg(not(target_arch = "wasm32"))]
45const PAGE_BODY_CAP: usize = 4096 - 40;
46
47/// pagedb segment name prefix for FTS posting segments.
48#[cfg(not(target_arch = "wasm32"))]
49const FTS_SEG_PREFIX: &str = "fts/seg/";
50
51/// B+ tree key prefix for the auxiliary segment index sentinel keys.
52#[cfg(not(target_arch = "wasm32"))]
53const FTS_SEG_IDX_PREFIX: &str = "fts:_seg_idx:";
54
55#[cfg(not(target_arch = "wasm32"))]
56#[async_trait]
57impl<V: Vfs + Clone + Send + Sync + 'static> FtsSegmentExt for PagedbStorage<V>
58where
59    <V as Vfs>::LockHandle: Sync,
60    <V as Vfs>::File: Sync,
61{
62    async fn write_fts_segment(&self, index_key: &str, bytes: &[u8]) -> Result<(), LiteError> {
63        // Prepend an 8-byte little-endian length so reads can recover the
64        // exact byte count after pagedb pads the last page to a full page size.
65        let byte_len = bytes.len() as u64;
66        let mut payload = Vec::with_capacity(8 + bytes.len());
67        payload.extend_from_slice(&byte_len.to_le_bytes());
68        payload.extend_from_slice(bytes);
69
70        let chunks: Vec<&[u8]> = payload.chunks(PAGE_BODY_CAP).collect();
71
72        let realm = RealmId::new([0u8; 16]);
73        let segment_name = format!("{FTS_SEG_PREFIX}{index_key}");
74
75        let mut writer = self
76            .db
77            .create_segment(realm, SegmentKind::Unspecified)
78            .await
79            .map_err(LiteError::from)?;
80        writer
81            .append_extent(&chunks)
82            .await
83            .map_err(LiteError::from)?;
84        let meta = writer.seal().await.map_err(LiteError::from)?;
85
86        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
87
88        // Atomically replace if a segment already exists under this name.
89        let link_result = txn.link_segment(&segment_name, &meta).await;
90        match link_result {
91            Ok(()) => {}
92            Err(pagedb::PagedbError::AlreadyLinked) => {
93                txn.replace_segment(&segment_name, &meta)
94                    .await
95                    .map_err(LiteError::from)?;
96            }
97            Err(e) => return Err(LiteError::from(e)),
98        }
99
100        // Write the auxiliary segment-index sentinel to the B+ tree so that
101        // list_fts_segments can enumerate index keys without pagedb name enumeration.
102        let sentinel_key = format!("{FTS_SEG_IDX_PREFIX}{index_key}");
103        txn.put(
104            &crate::storage::pagedb_storage::prefix_key(Namespace::Fts, sentinel_key.as_bytes()),
105            &[],
106        )
107        .await
108        .map_err(LiteError::from)?;
109
110        txn.commit().await.map(|_| ()).map_err(LiteError::from)
111    }
112
113    async fn open_fts_segment(&self, index_key: &str) -> Result<Option<Box<[u8]>>, LiteError> {
114        let segment_name = format!("{FTS_SEG_PREFIX}{index_key}");
115        let txn = self.db.begin_read().await.map_err(LiteError::from)?;
116
117        let reader = match txn.open_segment(&segment_name).await {
118            Ok(r) => r,
119            Err(pagedb::PagedbError::NotFound) => return Ok(None),
120            Err(e) => return Err(LiteError::from(e)),
121        };
122
123        // Read all data pages and concatenate.
124        let meta_page_count = reader.meta().page_count;
125        let index_pages = u64::from(reader.index_page_count());
126        let data_page_count = meta_page_count
127            .checked_sub(2 + index_pages)
128            .ok_or_else(|| LiteError::Storage {
129                detail: format!(
130                    "fts segment '{index_key}' page_count={meta_page_count} too small \
131                     (index_pages={index_pages})"
132                ),
133            })?;
134
135        if data_page_count == 0 {
136            return Ok(Some(Box::default()));
137        }
138
139        let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
140            detail: format!("fts segment '{index_key}' has too many data pages: {data_page_count}"),
141        })?;
142
143        let pages = reader
144            .read_range(1, count_u32)
145            .await
146            .map_err(|e| LiteError::Storage {
147                detail: format!("pagedb fts segment read_range failed for '{index_key}': {e}"),
148            })?;
149
150        let total: usize = pages.iter().map(|p| p.len()).sum();
151        let mut flat = Vec::with_capacity(total);
152        for page in pages {
153            flat.extend_from_slice(&page);
154        }
155
156        // Strip the 8-byte length prefix.
157        if flat.len() < 8 {
158            return Err(LiteError::Storage {
159                detail: format!(
160                    "fts segment '{index_key}' too small to contain length prefix: {} bytes",
161                    flat.len()
162                ),
163            });
164        }
165        let byte_len = u64::from_le_bytes(flat[..8].try_into().expect("8-byte slice")) as usize;
166        let end = 8_usize
167            .checked_add(byte_len)
168            .ok_or_else(|| LiteError::Storage {
169                detail: format!("fts segment '{index_key}' length prefix overflows: {byte_len}"),
170            })?;
171        if end > flat.len() {
172            return Err(LiteError::Storage {
173                detail: format!(
174                    "fts segment '{index_key}' declared byte_len={byte_len} exceeds \
175                     available data ({} bytes after prefix)",
176                    flat.len() - 8
177                ),
178            });
179        }
180
181        Ok(Some(flat[8..end].to_vec().into_boxed_slice()))
182    }
183
184    async fn delete_fts_segment(&self, index_key: &str) -> Result<(), LiteError> {
185        let segment_name = format!("{FTS_SEG_PREFIX}{index_key}");
186        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
187        match txn.unlink_segment(&segment_name).await {
188            Ok(()) => {}
189            Err(pagedb::PagedbError::NotLinked) => {}
190            Err(e) => return Err(LiteError::from(e)),
191        }
192
193        // Remove the auxiliary segment-index sentinel from the B+ tree.
194        let sentinel_key = format!("{FTS_SEG_IDX_PREFIX}{index_key}");
195        txn.delete(&crate::storage::pagedb_storage::prefix_key(
196            Namespace::Fts,
197            sentinel_key.as_bytes(),
198        ))
199        .await
200        .map_err(LiteError::from)?;
201
202        txn.commit().await.map(|_| ()).map_err(LiteError::from)
203    }
204
205    async fn list_fts_segments(&self, prefix: &str) -> Result<Vec<String>, LiteError> {
206        // Scan the auxiliary B+ tree index for sentinel keys that match the prefix.
207        let scan_prefix = format!("{FTS_SEG_IDX_PREFIX}{prefix}");
208        let pairs = self
209            .scan_prefix(Namespace::Fts, scan_prefix.as_bytes())
210            .await?;
211        // Strip the sentinel prefix to recover bare index_key strings.
212        let keys = pairs
213            .into_iter()
214            .filter_map(|(k, _)| {
215                let s = String::from_utf8(k).ok()?;
216                s.strip_prefix(FTS_SEG_IDX_PREFIX)
217                    .map(|rest| rest.to_owned())
218            })
219            .collect();
220        Ok(keys)
221    }
222}
223
224// ─── Tests ───────────────────────────────────────────────────────────────────
225
226#[cfg(test)]
227mod tests {
228    use crate::storage::engine::StorageEngine;
229    use crate::storage::fts_segment_ext::FtsSegmentExt;
230    use crate::storage::pagedb_storage::PagedbStorage;
231    use pagedb::vfs::memory::MemVfs;
232
233    async fn make_storage() -> PagedbStorage<MemVfs> {
234        PagedbStorage::open_in_memory().await.unwrap()
235    }
236
237    #[tokio::test]
238    async fn fts_segment_roundtrip() {
239        let s = make_storage().await;
240        let payload: Vec<u8> = (0u8..=255).cycle().take(5000).collect();
241
242        s.write_fts_segment("articles:_doc", &payload)
243            .await
244            .unwrap();
245
246        let got = s
247            .open_fts_segment("articles:_doc")
248            .await
249            .unwrap()
250            .expect("segment must exist after write");
251
252        assert_eq!(
253            got.as_ref(),
254            payload.as_slice(),
255            "round-trip bytes must match"
256        );
257    }
258
259    #[tokio::test]
260    async fn fts_segment_open_missing_returns_none() {
261        let s = make_storage().await;
262        let result = s.open_fts_segment("nonexistent:_doc").await.unwrap();
263        assert!(result.is_none());
264    }
265
266    #[tokio::test]
267    async fn fts_segment_delete_removes_segment() {
268        let s = make_storage().await;
269        s.write_fts_segment("col:field", b"some posting data")
270            .await
271            .unwrap();
272        s.delete_fts_segment("col:field").await.unwrap();
273
274        let result = s.open_fts_segment("col:field").await.unwrap();
275        assert!(result.is_none(), "segment must be gone after delete");
276    }
277
278    #[tokio::test]
279    async fn fts_segment_delete_nonexistent_is_noop() {
280        let s = make_storage().await;
281        s.delete_fts_segment("ghost:_doc").await.unwrap();
282    }
283
284    #[tokio::test]
285    async fn fts_segment_replace_updates_content() {
286        let s = make_storage().await;
287        s.write_fts_segment("col:_doc", b"original").await.unwrap();
288        s.write_fts_segment("col:_doc", b"updated").await.unwrap();
289
290        let got = s
291            .open_fts_segment("col:_doc")
292            .await
293            .unwrap()
294            .expect("segment must exist");
295        assert_eq!(got.as_ref(), b"updated", "second write must replace first");
296    }
297
298    #[tokio::test]
299    async fn fts_segment_list_by_prefix() {
300        let s = make_storage().await;
301        s.write_fts_segment("news:_doc", b"a").await.unwrap();
302        s.write_fts_segment("news:title", b"b").await.unwrap();
303        s.write_fts_segment("blog:_doc", b"c").await.unwrap();
304
305        let mut news_segs = s.list_fts_segments("news:").await.unwrap();
306        news_segs.sort();
307        assert_eq!(news_segs.len(), 2, "expected 2 news segments");
308        assert!(news_segs.contains(&"news:_doc".to_owned()));
309        assert!(news_segs.contains(&"news:title".to_owned()));
310
311        let blog_segs = s.list_fts_segments("blog:").await.unwrap();
312        assert_eq!(blog_segs.len(), 1);
313        assert_eq!(blog_segs[0], "blog:_doc");
314    }
315
316    #[tokio::test]
317    async fn fts_segment_list_empty_prefix_returns_all() {
318        let s = make_storage().await;
319        s.write_fts_segment("a:_doc", b"x").await.unwrap();
320        s.write_fts_segment("b:_doc", b"y").await.unwrap();
321
322        let all = s.list_fts_segments("").await.unwrap();
323        assert_eq!(all.len(), 2);
324    }
325
326    #[tokio::test]
327    async fn fts_segment_list_after_delete_shrinks() {
328        let s = make_storage().await;
329        s.write_fts_segment("col:_doc", b"a").await.unwrap();
330        s.write_fts_segment("col:field", b"b").await.unwrap();
331
332        s.delete_fts_segment("col:_doc").await.unwrap();
333
334        let remaining = s.list_fts_segments("col:").await.unwrap();
335        assert_eq!(remaining.len(), 1);
336        assert_eq!(remaining[0], "col:field");
337    }
338
339    #[tokio::test]
340    async fn fts_segment_as_fts_segment_ext_is_some() {
341        let s = make_storage().await;
342        assert!(
343            s.as_fts_segment_ext().is_some(),
344            "PagedbStorage must return Some from as_fts_segment_ext"
345        );
346    }
347
348    #[tokio::test]
349    async fn fts_segment_roundtrip_large_payload() {
350        let s = make_storage().await;
351        // Payload large enough to span multiple pagedb pages.
352        let payload: Vec<u8> = (0u8..=255).cycle().take(128 * 1024).collect();
353        s.write_fts_segment("large:_doc", &payload).await.unwrap();
354        let got = s
355            .open_fts_segment("large:_doc")
356            .await
357            .unwrap()
358            .expect("segment must exist");
359        assert_eq!(got.len(), payload.len());
360        assert_eq!(got.as_ref(), payload.as_slice());
361    }
362}