Skip to main content

nodedb_lite/storage/
pagedb_storage_spatial.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `SpatialSegmentExt` implementation for `PagedbStorage<V>`.
4//!
5//! One pagedb segment per (collection, field) pair, storing the full
6//! CRC32C-wrapped R-tree checkpoint as a single blob.
7//! Segment name: `spatial/rtree/{collection}/{field}`.
8//!
9//! ## CRC32C envelope
10//!
11//! The R-tree bytes stored here are already wrapped with `checksum::wrap` by
12//! the caller (`checkpoint.rs`). The pagedb segment path does NOT apply a
13//! second wrap — it stores the already-wrapped bytes verbatim. On read,
14//! `checkpoint.rs` calls `checksum::unwrap` as it always has. This matches
15//! the legacy KV path exactly: no double-wrap, no missed unwrap.
16//!
17//! ## Page envelope
18//!
19//! Uses the same 8-byte little-endian length-prefix as FTS, columnar, and graph
20//! segment impls to survive pagedb's page-boundary zero-padding.
21//!
22//! ## Sentinel index
23//!
24//! `_collections` on the B+ tree already catalogs all (collection, field) pairs,
25//! so no auxiliary sentinel index is needed here (unlike graph/FTS). The restore
26//! path iterates `_collections` and tries `open_spatial_segment` per pair,
27//! falling back to the legacy KV blob if the segment is absent.
28
29#[cfg(not(target_arch = "wasm32"))]
30use async_trait::async_trait;
31#[cfg(not(target_arch = "wasm32"))]
32use pagedb::vfs::Vfs;
33#[cfg(not(target_arch = "wasm32"))]
34use pagedb::{RealmId, SegmentKind};
35
36#[cfg(not(target_arch = "wasm32"))]
37use crate::error::LiteError;
38#[cfg(not(target_arch = "wasm32"))]
39use crate::storage::pagedb_storage::PagedbStorage;
40#[cfg(not(target_arch = "wasm32"))]
41use crate::storage::spatial_segment_ext::SpatialSegmentExt;
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 R-tree checkpoint segments.
48#[cfg(not(target_arch = "wasm32"))]
49const SPATIAL_SEG_PREFIX: &str = "spatial/rtree/";
50
51/// Build the segment name for a (collection, field) pair.
52///
53/// Uses `/` as the separator between collection and field so that the full
54/// path is `spatial/rtree/{collection}/{field}` — consistent with the
55/// established `{engine}/{kind}/{identifier}` convention.
56#[cfg(not(target_arch = "wasm32"))]
57fn segment_name(collection: &str, field: &str) -> String {
58    format!("{SPATIAL_SEG_PREFIX}{collection}/{field}")
59}
60
61#[cfg(not(target_arch = "wasm32"))]
62#[async_trait]
63impl<V: Vfs + Clone + Send + Sync + 'static> SpatialSegmentExt for PagedbStorage<V>
64where
65    <V as Vfs>::LockHandle: Sync,
66    <V as Vfs>::File: Sync,
67{
68    async fn write_spatial_segment(
69        &self,
70        collection: &str,
71        field: &str,
72        bytes: &[u8],
73    ) -> Result<(), LiteError> {
74        // Prepend an 8-byte little-endian length so reads can recover the
75        // exact byte count after pagedb pads the last page to a full page size.
76        let byte_len = bytes.len() as u64;
77        let mut payload = Vec::with_capacity(8 + bytes.len());
78        payload.extend_from_slice(&byte_len.to_le_bytes());
79        payload.extend_from_slice(bytes);
80
81        let chunks: Vec<&[u8]> = payload.chunks(PAGE_BODY_CAP).collect();
82
83        let realm = RealmId::new([0u8; 16]);
84        let name = segment_name(collection, field);
85
86        let mut writer = self
87            .db
88            .create_segment(realm, SegmentKind::Unspecified)
89            .await
90            .map_err(LiteError::from)?;
91        writer
92            .append_extent(&chunks)
93            .await
94            .map_err(LiteError::from)?;
95        let meta = writer.seal().await.map_err(LiteError::from)?;
96
97        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
98
99        // Atomically replace if a segment already exists under this name.
100        let link_result = txn.link_segment(&name, &meta).await;
101        match link_result {
102            Ok(()) => {}
103            Err(pagedb::PagedbError::AlreadyLinked) => {
104                txn.replace_segment(&name, &meta)
105                    .await
106                    .map_err(LiteError::from)?;
107            }
108            Err(e) => return Err(LiteError::from(e)),
109        }
110
111        txn.commit().await.map(|_| ()).map_err(LiteError::from)
112    }
113
114    async fn open_spatial_segment(
115        &self,
116        collection: &str,
117        field: &str,
118    ) -> Result<Option<Box<[u8]>>, LiteError> {
119        let name = segment_name(collection, field);
120        let txn = self.db.begin_read().await.map_err(LiteError::from)?;
121
122        let reader = match txn.open_segment(&name).await {
123            Ok(r) => r,
124            Err(pagedb::PagedbError::NotFound) => return Ok(None),
125            Err(e) => return Err(LiteError::from(e)),
126        };
127
128        let meta_page_count = reader.meta().page_count;
129        let index_pages = u64::from(reader.index_page_count());
130        let data_page_count = meta_page_count
131            .checked_sub(2 + index_pages)
132            .ok_or_else(|| LiteError::Storage {
133                detail: format!(
134                    "spatial segment '{name}' page_count={meta_page_count} too small \
135                     (index_pages={index_pages})"
136                ),
137            })?;
138
139        if data_page_count == 0 {
140            return Ok(Some(Box::default()));
141        }
142
143        let count_u32 = u32::try_from(data_page_count).map_err(|_| LiteError::Storage {
144            detail: format!("spatial segment '{name}' has too many data pages: {data_page_count}"),
145        })?;
146
147        let pages = reader
148            .read_range(1, count_u32)
149            .await
150            .map_err(|e| LiteError::Storage {
151                detail: format!("pagedb spatial segment read_range failed for '{name}': {e}"),
152            })?;
153
154        let total: usize = pages.iter().map(|p| p.len()).sum();
155        let mut flat = Vec::with_capacity(total);
156        for page in pages {
157            flat.extend_from_slice(&page);
158        }
159
160        // Strip the 8-byte length prefix.
161        if flat.len() < 8 {
162            return Err(LiteError::Storage {
163                detail: format!(
164                    "spatial segment '{name}' too small to contain length prefix: {} bytes",
165                    flat.len()
166                ),
167            });
168        }
169        let byte_len = u64::from_le_bytes(flat[..8].try_into().expect("8-byte slice")) as usize;
170        let end = 8_usize
171            .checked_add(byte_len)
172            .ok_or_else(|| LiteError::Storage {
173                detail: format!("spatial segment '{name}' length prefix overflows: {byte_len}"),
174            })?;
175        if end > flat.len() {
176            return Err(LiteError::Storage {
177                detail: format!(
178                    "spatial segment '{name}' declared byte_len={byte_len} exceeds \
179                     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_spatial_segment(&self, collection: &str, field: &str) -> Result<(), LiteError> {
189        let name = segment_name(collection, field);
190        let mut txn = self.db.begin_write().await.map_err(LiteError::from)?;
191        match txn.unlink_segment(&name).await {
192            Ok(()) => {}
193            Err(pagedb::PagedbError::NotLinked) => {}
194            Err(e) => return Err(LiteError::from(e)),
195        }
196        txn.commit().await.map(|_| ()).map_err(LiteError::from)
197    }
198}
199
200// ─── Tests ───────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use crate::storage::engine::StorageEngine;
205    use crate::storage::pagedb_storage::PagedbStorage;
206    use crate::storage::spatial_segment_ext::SpatialSegmentExt;
207    use pagedb::vfs::memory::MemVfs;
208
209    async fn make_storage() -> PagedbStorage<MemVfs> {
210        PagedbStorage::open_in_memory().await.unwrap()
211    }
212
213    #[tokio::test]
214    async fn spatial_segment_roundtrip() {
215        let s = make_storage().await;
216        let payload: Vec<u8> = (0u8..=255).cycle().take(5000).collect();
217
218        s.write_spatial_segment("orders", "location", &payload)
219            .await
220            .unwrap();
221
222        let got = s
223            .open_spatial_segment("orders", "location")
224            .await
225            .unwrap()
226            .expect("segment must exist after write");
227
228        assert_eq!(
229            got.as_ref(),
230            payload.as_slice(),
231            "round-trip bytes must match"
232        );
233    }
234
235    #[tokio::test]
236    async fn spatial_segment_open_missing_returns_none() {
237        let s = make_storage().await;
238        let result = s.open_spatial_segment("nonexistent", "geom").await.unwrap();
239        assert!(result.is_none());
240    }
241
242    #[tokio::test]
243    async fn spatial_segment_delete_removes_segment() {
244        let s = make_storage().await;
245        s.write_spatial_segment("col", "field", b"some rtree data")
246            .await
247            .unwrap();
248        s.delete_spatial_segment("col", "field").await.unwrap();
249
250        let result = s.open_spatial_segment("col", "field").await.unwrap();
251        assert!(result.is_none(), "segment must be gone after delete");
252    }
253
254    #[tokio::test]
255    async fn spatial_segment_delete_nonexistent_is_noop() {
256        let s = make_storage().await;
257        s.delete_spatial_segment("ghost", "geom").await.unwrap();
258    }
259
260    #[tokio::test]
261    async fn spatial_segment_replace_updates_content() {
262        let s = make_storage().await;
263        s.write_spatial_segment("col", "field", b"original")
264            .await
265            .unwrap();
266        s.write_spatial_segment("col", "field", b"updated")
267            .await
268            .unwrap();
269
270        let got = s
271            .open_spatial_segment("col", "field")
272            .await
273            .unwrap()
274            .expect("segment must exist");
275        assert_eq!(got.as_ref(), b"updated", "second write must replace first");
276    }
277
278    #[tokio::test]
279    async fn spatial_segment_roundtrip_large_payload() {
280        let s = make_storage().await;
281        // Payload large enough to span multiple pagedb pages.
282        let payload: Vec<u8> = (0u8..=255).cycle().take(128 * 1024).collect();
283        s.write_spatial_segment("large_col", "geom", &payload)
284            .await
285            .unwrap();
286        let got = s
287            .open_spatial_segment("large_col", "geom")
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 spatial_segment_multiple_fields_same_collection() {
297        let s = make_storage().await;
298        let payload_a: Vec<u8> = vec![1u8; 100];
299        let payload_b: Vec<u8> = vec![2u8; 200];
300
301        s.write_spatial_segment("places", "point", &payload_a)
302            .await
303            .unwrap();
304        s.write_spatial_segment("places", "polygon", &payload_b)
305            .await
306            .unwrap();
307
308        let got_a = s
309            .open_spatial_segment("places", "point")
310            .await
311            .unwrap()
312            .expect("point segment must exist");
313        let got_b = s
314            .open_spatial_segment("places", "polygon")
315            .await
316            .unwrap()
317            .expect("polygon segment must exist");
318
319        assert_eq!(got_a.as_ref(), payload_a.as_slice());
320        assert_eq!(got_b.as_ref(), payload_b.as_slice());
321    }
322
323    #[tokio::test]
324    async fn spatial_segment_as_spatial_segment_ext_is_some() {
325        let s = make_storage().await;
326        assert!(
327            s.as_spatial_segment_ext().is_some(),
328            "PagedbStorage must return Some from as_spatial_segment_ext"
329        );
330    }
331}