nodedb_lite/storage/
pagedb_storage_fts.rs1#[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#[cfg(not(target_arch = "wasm32"))]
45const PAGE_BODY_CAP: usize = 4096 - 40;
46
47#[cfg(not(target_arch = "wasm32"))]
49const FTS_SEG_PREFIX: &str = "fts/seg/";
50
51#[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 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 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 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 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 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 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 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 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#[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 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}