Skip to main content

nodedb_lite/storage/
pagedb_storage_columnar.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `ColumnarSegmentExt` implementation for `PagedbStorage<V>`.
4//!
5//! One pagedb segment per `(collection, segment_id)` pair, storing the
6//! compressed columnar bytes produced by `nodedb_columnar::SegmentWriter`.
7//! Segment name: `col/seg/{collection}/{segment_id}`.
8//!
9//! ## Key split
10//!
11//! | Data | Storage |
12//! |------|---------|
13//! | `{collection}:seg:{id}` bytes | pagedb segment (`col/seg/{collection}/{id}`) |
14//! | `{collection}:del:{id}` delete bitmap | B+ tree (`Namespace::Columnar`) |
15//! | `{collection}:meta` segment list | B+ tree (`Namespace::Columnar`) |
16//! | Schema in `Namespace::Meta` | B+ tree |
17//!
18//! Segment metadata already carries the full ordered list of segment IDs, so
19//! no auxiliary sentinel index is needed (unlike the FTS path).
20//!
21//! ## Page envelope
22//!
23//! Uses the same 8-byte little-endian length-prefix as the FTS and array
24//! segment impls to survive pagedb's page-boundary zero-padding.  The bytes
25//! the columnar engine sees are pristine `nodedb_columnar` NDBS bytes —
26//! format-bit-identical to what Origin writes to plain NDBS files.
27
28#[cfg(not(target_arch = "wasm32"))]
29use async_trait::async_trait;
30#[cfg(not(target_arch = "wasm32"))]
31use pagedb::vfs::Vfs;
32#[cfg(not(target_arch = "wasm32"))]
33use pagedb::{RealmId, SegmentKind};
34
35#[cfg(not(target_arch = "wasm32"))]
36use crate::error::LiteError;
37#[cfg(not(target_arch = "wasm32"))]
38use crate::storage::columnar_segment_ext::ColumnarSegmentExt;
39#[cfg(not(target_arch = "wasm32"))]
40use crate::storage::pagedb_storage::PagedbStorage;
41
42/// pagedb page body capacity in bytes: 4096 - 40 bytes AEAD/header envelope.
43#[cfg(not(target_arch = "wasm32"))]
44const PAGE_BODY_CAP: usize = 4096 - 40;
45
46/// pagedb segment name prefix for columnar segments.
47#[cfg(not(target_arch = "wasm32"))]
48const COL_SEG_PREFIX: &str = "col/seg/";
49
50#[cfg(not(target_arch = "wasm32"))]
51#[async_trait]
52impl<V: Vfs + Clone + Send + Sync + 'static> ColumnarSegmentExt for PagedbStorage<V>
53where
54    <V as Vfs>::LockHandle: Sync,
55    <V as Vfs>::File: Sync,
56{
57    async fn write_columnar_segment(
58        &self,
59        collection: &str,
60        segment_id: u32,
61        bytes: &[u8],
62    ) -> 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!("{COL_SEG_PREFIX}{collection}/{segment_id}");
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        txn.commit().await.map(|_| ()).map_err(LiteError::from)
101    }
102
103    async fn open_columnar_segment(
104        &self,
105        collection: &str,
106        segment_id: u32,
107    ) -> Result<Option<Box<[u8]>>, LiteError> {
108        let segment_name = format!("{COL_SEG_PREFIX}{collection}/{segment_id}");
109        let txn = self.db.begin_read().await.map_err(LiteError::from)?;
110
111        let reader = match txn.open_segment(&segment_name).await {
112            Ok(r) => r,
113            Err(pagedb::PagedbError::NotFound) => return Ok(None),
114            Err(e) => return Err(LiteError::from(e)),
115        };
116
117        // Read all data pages and concatenate.
118        let meta_page_count = reader.meta().page_count;
119        let index_pages = u64::from(reader.index_page_count());
120        let data_page_count = meta_page_count
121            .checked_sub(2 + index_pages)
122            .ok_or_else(|| LiteError::Storage {
123                detail: format!(
124                    "columnar segment '{collection}/{segment_id}' page_count={meta_page_count} \
125                     too small (index_pages={index_pages})"
126                ),
127            })?;
128
129        if data_page_count == 0 {
130            return Ok(Some(Box::default()));
131        }
132
133        let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
134            detail: format!(
135                "columnar segment '{collection}/{segment_id}' has too many data pages: \
136                 {data_page_count}"
137            ),
138        })?;
139
140        let pages = reader
141            .read_range(1, count_u32)
142            .await
143            .map_err(|e| LiteError::Storage {
144                detail: format!(
145                    "pagedb columnar segment read_range failed for \
146                     '{collection}/{segment_id}': {e}"
147                ),
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                    "columnar segment '{collection}/{segment_id}' too small to contain \
161                     length prefix: {} bytes",
162                    flat.len()
163                ),
164            });
165        }
166        let byte_len = u64::from_le_bytes(flat[..8].try_into().expect("8-byte slice")) as usize;
167        let end = 8_usize
168            .checked_add(byte_len)
169            .ok_or_else(|| LiteError::Storage {
170                detail: format!(
171                    "columnar segment '{collection}/{segment_id}' length prefix overflows: \
172                 {byte_len}"
173                ),
174            })?;
175        if end > flat.len() {
176            return Err(LiteError::Storage {
177                detail: format!(
178                    "columnar segment '{collection}/{segment_id}' declared byte_len={byte_len} \
179                     exceeds available data ({} bytes after prefix)",
180                    flat.len() - 8
181                ),
182            });
183        }
184
185        Ok(Some(flat[8..end].to_vec().into_boxed_slice()))
186    }
187
188    async fn delete_columnar_segment(
189        &self,
190        collection: &str,
191        segment_id: u32,
192    ) -> Result<(), LiteError> {
193        let segment_name = format!("{COL_SEG_PREFIX}{collection}/{segment_id}");
194        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
195        match txn.unlink_segment(&segment_name).await {
196            Ok(()) => {}
197            Err(pagedb::PagedbError::NotLinked) => {}
198            Err(e) => return Err(LiteError::from(e)),
199        }
200        txn.commit().await.map(|_| ()).map_err(LiteError::from)
201    }
202}
203
204// ─── Tests ───────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use crate::storage::columnar_segment_ext::ColumnarSegmentExt;
209    use crate::storage::engine::StorageEngine;
210    use crate::storage::pagedb_storage::PagedbStorage;
211    use pagedb::vfs::memory::MemVfs;
212
213    async fn make_storage() -> PagedbStorage<MemVfs> {
214        PagedbStorage::open_in_memory().await.unwrap()
215    }
216
217    #[tokio::test]
218    async fn columnar_segment_as_columnar_segment_ext_is_some() {
219        let s = make_storage().await;
220        assert!(
221            s.as_columnar_segment_ext().is_some(),
222            "PagedbStorage must return Some from as_columnar_segment_ext"
223        );
224    }
225
226    #[tokio::test]
227    async fn columnar_segment_roundtrip() {
228        let s = make_storage().await;
229        let payload: Vec<u8> = (0u8..=255).cycle().take(5000).collect();
230
231        s.write_columnar_segment("metrics", 1, &payload)
232            .await
233            .unwrap();
234
235        let got = s
236            .open_columnar_segment("metrics", 1)
237            .await
238            .unwrap()
239            .expect("segment must exist after write");
240
241        assert_eq!(
242            got.as_ref(),
243            payload.as_slice(),
244            "round-trip bytes must match"
245        );
246    }
247
248    #[tokio::test]
249    async fn columnar_segment_open_missing_returns_none() {
250        let s = make_storage().await;
251        let result = s.open_columnar_segment("nonexistent", 99).await.unwrap();
252        assert!(result.is_none());
253    }
254
255    #[tokio::test]
256    async fn columnar_segment_delete_removes_segment() {
257        let s = make_storage().await;
258        s.write_columnar_segment("events", 1, b"some column data")
259            .await
260            .unwrap();
261        s.delete_columnar_segment("events", 1).await.unwrap();
262
263        let result = s.open_columnar_segment("events", 1).await.unwrap();
264        assert!(result.is_none(), "segment must be gone after delete");
265    }
266
267    #[tokio::test]
268    async fn columnar_segment_delete_nonexistent_is_noop() {
269        let s = make_storage().await;
270        s.delete_columnar_segment("ghost", 42).await.unwrap();
271    }
272
273    #[tokio::test]
274    async fn columnar_segment_replace_updates_content() {
275        let s = make_storage().await;
276        s.write_columnar_segment("readings", 1, b"original data")
277            .await
278            .unwrap();
279        s.write_columnar_segment("readings", 1, b"updated data")
280            .await
281            .unwrap();
282
283        let got = s
284            .open_columnar_segment("readings", 1)
285            .await
286            .unwrap()
287            .expect("segment must exist");
288        assert_eq!(
289            got.as_ref(),
290            b"updated data",
291            "second write must replace first"
292        );
293    }
294
295    #[tokio::test]
296    async fn columnar_segment_roundtrip_large_payload() {
297        let s = make_storage().await;
298        // Payload large enough to span multiple pagedb pages.
299        let payload: Vec<u8> = (0u8..=255).cycle().take(128 * 1024).collect();
300        s.write_columnar_segment("large", 5, &payload)
301            .await
302            .unwrap();
303        let got = s
304            .open_columnar_segment("large", 5)
305            .await
306            .unwrap()
307            .expect("segment must exist");
308        assert_eq!(got.len(), payload.len());
309        assert_eq!(got.as_ref(), payload.as_slice());
310    }
311
312    #[tokio::test]
313    async fn columnar_segment_multiple_collections_isolated() {
314        let s = make_storage().await;
315        s.write_columnar_segment("col_a", 1, b"data for col_a seg 1")
316            .await
317            .unwrap();
318        s.write_columnar_segment("col_b", 1, b"data for col_b seg 1")
319            .await
320            .unwrap();
321        s.write_columnar_segment("col_a", 2, b"data for col_a seg 2")
322            .await
323            .unwrap();
324
325        let a1 = s
326            .open_columnar_segment("col_a", 1)
327            .await
328            .unwrap()
329            .expect("col_a/1 must exist");
330        let b1 = s
331            .open_columnar_segment("col_b", 1)
332            .await
333            .unwrap()
334            .expect("col_b/1 must exist");
335        let a2 = s
336            .open_columnar_segment("col_a", 2)
337            .await
338            .unwrap()
339            .expect("col_a/2 must exist");
340
341        assert_eq!(a1.as_ref(), b"data for col_a seg 1");
342        assert_eq!(b1.as_ref(), b"data for col_b seg 1");
343        assert_eq!(a2.as_ref(), b"data for col_a seg 2");
344    }
345}