Skip to main content

nodedb_lite/storage/
pagedb_storage_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `GraphSegmentExt` implementation for `PagedbStorage<V>`.
4//!
5//! One pagedb segment per graph collection, storing the full CSR adjacency
6//! checkpoint as a single blob.  Segment name: `graph/csr/{collection}`.
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::Graph`:
13//!
14//! - Key: `graph:_seg_idx:{collection}` → empty value (presence = segment exists)
15//!
16//! `write_graph_segment` inserts this sentinel; `delete_graph_segment` removes
17//! it.  The restore path in `nodedb/core/open.rs` can enumerate segments via
18//! the existing `META_CSR_COLLECTIONS` B+ tree key and simply prefer the
19//! segment path over the legacy KV blob.
20//!
21//! ## Page envelope
22//!
23//! Uses the same 8-byte little-endian length-prefix as the FTS and columnar
24//! segment impls to survive pagedb's page-boundary zero-padding.
25
26#[cfg(not(target_arch = "wasm32"))]
27use async_trait::async_trait;
28#[cfg(not(target_arch = "wasm32"))]
29use nodedb_types::Namespace;
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::graph_segment_ext::GraphSegmentExt;
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 CSR adjacency segments.
47#[cfg(not(target_arch = "wasm32"))]
48const GRAPH_SEG_PREFIX: &str = "graph/csr/";
49
50/// B+ tree key prefix for the auxiliary segment index sentinel keys.
51#[cfg(not(target_arch = "wasm32"))]
52const GRAPH_SEG_IDX_PREFIX: &str = "graph:_seg_idx:";
53
54#[cfg(not(target_arch = "wasm32"))]
55#[async_trait]
56impl<V: Vfs + Clone + Send + Sync + 'static> GraphSegmentExt for PagedbStorage<V>
57where
58    <V as Vfs>::LockHandle: Sync,
59    <V as Vfs>::File: Sync,
60{
61    async fn write_graph_segment(&self, collection: &str, bytes: &[u8]) -> Result<(), LiteError> {
62        // Prepend an 8-byte little-endian length so reads can recover the
63        // exact byte count after pagedb pads the last page to a full page size.
64        let byte_len = bytes.len() as u64;
65        let mut payload = Vec::with_capacity(8 + bytes.len());
66        payload.extend_from_slice(&byte_len.to_le_bytes());
67        payload.extend_from_slice(bytes);
68
69        let chunks: Vec<&[u8]> = payload.chunks(PAGE_BODY_CAP).collect();
70
71        let realm = RealmId::new([0u8; 16]);
72        let segment_name = format!("{GRAPH_SEG_PREFIX}{collection}");
73
74        let mut writer = self
75            .db
76            .create_segment(realm, SegmentKind::Unspecified)
77            .await
78            .map_err(LiteError::from)?;
79        writer
80            .append_extent(&chunks)
81            .await
82            .map_err(LiteError::from)?;
83        let meta = writer.seal().await.map_err(LiteError::from)?;
84
85        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
86
87        // Atomically replace if a segment already exists under this name.
88        let link_result = txn.link_segment(&segment_name, &meta).await;
89        match link_result {
90            Ok(()) => {}
91            Err(pagedb::PagedbError::AlreadyLinked) => {
92                txn.replace_segment(&segment_name, &meta)
93                    .await
94                    .map_err(LiteError::from)?;
95            }
96            Err(e) => return Err(LiteError::from(e)),
97        }
98
99        // Write the auxiliary segment-index sentinel to the B+ tree.
100        let sentinel_key = format!("{GRAPH_SEG_IDX_PREFIX}{collection}");
101        txn.put(
102            &crate::storage::pagedb_storage::prefix_key(Namespace::Graph, sentinel_key.as_bytes()),
103            &[],
104        )
105        .await
106        .map_err(LiteError::from)?;
107
108        txn.commit().await.map(|_| ()).map_err(LiteError::from)
109    }
110
111    async fn open_graph_segment(&self, collection: &str) -> Result<Option<Box<[u8]>>, LiteError> {
112        let segment_name = format!("{GRAPH_SEG_PREFIX}{collection}");
113        let txn = self.db.begin_read().await.map_err(LiteError::from)?;
114
115        let reader = match txn.open_segment(&segment_name).await {
116            Ok(r) => r,
117            Err(pagedb::PagedbError::NotFound) => return Ok(None),
118            Err(e) => return Err(LiteError::from(e)),
119        };
120
121        // Read all data pages and concatenate.
122        let meta_page_count = reader.meta().page_count;
123        let index_pages = u64::from(reader.index_page_count());
124        let data_page_count = meta_page_count
125            .checked_sub(2 + index_pages)
126            .ok_or_else(|| LiteError::Storage {
127                detail: format!(
128                    "graph segment '{collection}' page_count={meta_page_count} too small \
129                     (index_pages={index_pages})"
130                ),
131            })?;
132
133        if data_page_count == 0 {
134            return Ok(Some(Box::default()));
135        }
136
137        let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
138            detail: format!(
139                "graph segment '{collection}' has too many data pages: {data_page_count}"
140            ),
141        })?;
142
143        let pages = reader
144            .read_range(1, count_u32)
145            .await
146            .map_err(|e| LiteError::Storage {
147                detail: format!("pagedb graph segment read_range failed for '{collection}': {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                    "graph segment '{collection}' 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!("graph segment '{collection}' length prefix overflows: {byte_len}"),
170            })?;
171        if end > flat.len() {
172            return Err(LiteError::Storage {
173                detail: format!(
174                    "graph segment '{collection}' 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_graph_segment(&self, collection: &str) -> Result<(), LiteError> {
185        let segment_name = format!("{GRAPH_SEG_PREFIX}{collection}");
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!("{GRAPH_SEG_IDX_PREFIX}{collection}");
195        txn.delete(&crate::storage::pagedb_storage::prefix_key(
196            Namespace::Graph,
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
206// ─── Tests ───────────────────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod tests {
210    use crate::storage::engine::StorageEngine;
211    use crate::storage::graph_segment_ext::GraphSegmentExt;
212    use crate::storage::pagedb_storage::PagedbStorage;
213    use pagedb::vfs::memory::MemVfs;
214
215    async fn make_storage() -> PagedbStorage<MemVfs> {
216        PagedbStorage::open_in_memory().await.unwrap()
217    }
218
219    #[tokio::test]
220    async fn graph_segment_roundtrip() {
221        let s = make_storage().await;
222        let payload: Vec<u8> = (0u8..=255).cycle().take(5000).collect();
223
224        s.write_graph_segment("social_graph", &payload)
225            .await
226            .unwrap();
227
228        let got = s
229            .open_graph_segment("social_graph")
230            .await
231            .unwrap()
232            .expect("segment must exist after write");
233
234        assert_eq!(
235            got.as_ref(),
236            payload.as_slice(),
237            "round-trip bytes must match"
238        );
239    }
240
241    #[tokio::test]
242    async fn graph_segment_open_missing_returns_none() {
243        let s = make_storage().await;
244        let result = s.open_graph_segment("nonexistent").await.unwrap();
245        assert!(result.is_none());
246    }
247
248    #[tokio::test]
249    async fn graph_segment_delete_removes_segment() {
250        let s = make_storage().await;
251        s.write_graph_segment("col", b"some csr data")
252            .await
253            .unwrap();
254        s.delete_graph_segment("col").await.unwrap();
255
256        let result = s.open_graph_segment("col").await.unwrap();
257        assert!(result.is_none(), "segment must be gone after delete");
258    }
259
260    #[tokio::test]
261    async fn graph_segment_delete_nonexistent_is_noop() {
262        let s = make_storage().await;
263        s.delete_graph_segment("ghost").await.unwrap();
264    }
265
266    #[tokio::test]
267    async fn graph_segment_replace_updates_content() {
268        let s = make_storage().await;
269        s.write_graph_segment("col", b"original").await.unwrap();
270        s.write_graph_segment("col", b"updated").await.unwrap();
271
272        let got = s
273            .open_graph_segment("col")
274            .await
275            .unwrap()
276            .expect("segment must exist");
277        assert_eq!(got.as_ref(), b"updated", "second write must replace first");
278    }
279
280    #[tokio::test]
281    async fn graph_segment_roundtrip_large_payload() {
282        let s = make_storage().await;
283        // Payload large enough to span multiple pagedb pages.
284        let payload: Vec<u8> = (0u8..=255).cycle().take(128 * 1024).collect();
285        s.write_graph_segment("large_col", &payload).await.unwrap();
286        let got = s
287            .open_graph_segment("large_col")
288            .await
289            .unwrap()
290            .expect("segment must exist");
291        assert_eq!(got.len(), payload.len());
292        assert_eq!(got.as_ref(), payload.as_slice());
293    }
294
295    #[tokio::test]
296    async fn graph_segment_as_graph_segment_ext_is_some() {
297        let s = make_storage().await;
298        assert!(
299            s.as_graph_segment_ext().is_some(),
300            "PagedbStorage must return Some from as_graph_segment_ext"
301        );
302    }
303}