Skip to main content

nodedb_lite/storage/
engine.rs

1//! `StorageEngine` trait: the async key-value blob interface.
2//!
3//! All persistent storage on the edge goes through this trait. pagedb is the
4//! backend on every target — native via platform async I/O and WASM via the
5//! OPFS worker. The engines above (HNSW, CSR, Loro) serialize their data to
6//! opaque blobs and store them here. The storage layer never interprets the
7//! data.
8
9use async_trait::async_trait;
10
11use crate::error::LiteError;
12use nodedb_types::Namespace;
13
14/// Key-value pair returned by scan operations (`scan_prefix`, `scan_range`).
15///
16/// First element is the key (without namespace prefix), second is the value.
17/// Defined here (not in `nodedb-types`) because it's specific to the
18/// `StorageEngine` trait's scan interface.
19pub type KvPair = (Vec<u8>, Vec<u8>);
20
21/// The budget that excludes the next matching record.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PrefixScanLimit {
24    Records,
25    Bytes,
26}
27
28/// Prefix records and the budget that excludes the next record.
29#[derive(Debug, Default)]
30pub struct PrefixScan {
31    pub entries: Vec<KvPair>,
32    pub limit: Option<PrefixScanLimit>,
33}
34
35pub(crate) fn check_prefix_cursor(
36    prefix: &[u8],
37    after_key: Option<&[u8]>,
38) -> Result<(), LiteError> {
39    if after_key.is_some_and(|key| !key.starts_with(prefix)) {
40        return Err(LiteError::BadRequest {
41            detail: format!(
42                "scan cursor {after_key:?} excludes prefix {prefix:?}: use a matching cursor"
43            ),
44        });
45    }
46    Ok(())
47}
48
49pub(crate) fn prefix_scan_budget_error(
50    prefix: &[u8],
51    after_key: Option<&[u8]>,
52    max_bytes: usize,
53) -> LiteError {
54    LiteError::Backpressure {
55        detail: format!(
56            "scan row after {after_key:?} under prefix {prefix:?} exceeds {max_bytes} key-plus-value bytes: increase the byte budget"
57        ),
58    }
59}
60
61/// Summary of what a [`StorageEngine::compact`] call reclaimed.
62///
63/// Lite-owned (not a pagedb type) so the trait doesn't force pagedb types on
64/// non-pagedb impls. The pagedb-backed engine maps `pagedb::CompactStats` into
65/// this; other engines return the `Default` (all-zero) value from the trait's
66/// default no-op `compact`.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct CompactionOutcome {
70    /// Number of underlying pages reclaimed (moved to free-list or freed by
71    /// repacking). Zero for engines with nothing to compact.
72    pub reclaimed_pages: u64,
73    /// Number of segment files repacked.
74    pub segments_repacked: u32,
75    /// Bytes truncated from the backing file by lowering the high-water mark.
76    pub file_bytes_freed: u64,
77    /// Retired segment files reclaimed.
78    ///
79    /// A segment replacement normally reclaims its predecessor as part of the
80    /// commit, so this counts the remainder: segments whose retirement was
81    /// deferred because a reader still pinned them, and files left behind by a
82    /// process that died mid-retirement.
83    pub reclaimed_segments: u64,
84    /// Bytes freed by deleting tombstoned segment files.
85    pub segment_bytes_freed: u64,
86}
87
88/// A write operation for batch writes.
89#[derive(Debug, Clone)]
90pub enum WriteOp {
91    /// Insert or update a key-value pair.
92    Put {
93        ns: Namespace,
94        key: Vec<u8>,
95        value: Vec<u8>,
96    },
97    /// Delete a key.
98    Delete { ns: Namespace, key: Vec<u8> },
99}
100
101/// Async key-value blob storage backend.
102///
103/// Implementations must be `Send + Sync + 'static` to be shareable across
104/// async tasks and engine threads.
105///
106/// All operations are keyed by `(Namespace, key)`. Values are opaque byte
107/// slices — the storage layer never interprets them.
108#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
109#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
110pub trait StorageEngine: Send + Sync + 'static {
111    /// Get a value by namespace and key.
112    ///
113    /// Returns `None` if the key does not exist.
114    async fn get(&self, ns: Namespace, key: &[u8]) -> Result<Option<Vec<u8>>, LiteError>;
115
116    /// Put (insert or overwrite) a value.
117    async fn put(&self, ns: Namespace, key: &[u8], value: &[u8]) -> Result<(), LiteError>;
118
119    /// Delete a key. No-op if the key does not exist.
120    async fn delete(&self, ns: Namespace, key: &[u8]) -> Result<(), LiteError>;
121
122    /// Scan all keys with a given prefix in a namespace.
123    ///
124    /// Returns `(key, value)` pairs ordered by key. The prefix match is
125    /// bytewise: `key.starts_with(prefix)`.
126    ///
127    /// If `prefix` is empty, returns all entries in the namespace.
128    async fn scan_prefix(&self, ns: Namespace, prefix: &[u8]) -> Result<Vec<KvPair>, LiteError>;
129
130    /// Read at most `limit` prefix records, ordered by key.
131    ///
132    /// Implementations stop inside storage before materializing additional records.
133    /// Zero returns no records. Positive limits require backend support.
134    async fn scan_prefix_bounded(
135        &self,
136        _ns: Namespace,
137        _prefix: &[u8],
138        limit: usize,
139    ) -> Result<Vec<KvPair>, LiteError> {
140        if limit == 0 {
141            return Ok(Vec::new());
142        }
143        Err(LiteError::Unsupported {
144            detail: "bounded prefix scans require backend support".into(),
145        })
146    }
147
148    /// Read prefix records within count and serialized key-plus-value budgets.
149    ///
150    /// Limits report remaining records without returning oversized values.
151    /// Budgets exclude allocation overhead and cached pages.
152    async fn scan_prefix_budgeted(
153        &self,
154        _ns: Namespace,
155        _prefix: &[u8],
156        max_records: usize,
157        _max_bytes: usize,
158    ) -> Result<PrefixScan, LiteError> {
159        if max_records == 0 {
160            return Ok(PrefixScan::default());
161        }
162        Err(LiteError::Unsupported {
163            detail: "budgeted prefix scans require backend support".into(),
164        })
165    }
166
167    /// Read prefix rows after an exclusive, namespace-free cursor within both budgets.
168    ///
169    /// The cursor must match `prefix`. Rows remain strictly increasing and prefix-confined.
170    /// Resume from the last retained key. Continuation never advances past an excluded row.
171    /// Empty byte-limited batches return an error when `max_records` is positive.
172    /// Zero records returns an empty batch and ignores the cursor.
173    /// Budgets count serialized keys plus values.
174    async fn scan_prefix_from_budgeted(
175        &self,
176        _ns: Namespace,
177        prefix: &[u8],
178        after_key: Option<&[u8]>,
179        max_records: usize,
180        _max_bytes: usize,
181    ) -> Result<PrefixScan, LiteError> {
182        if max_records == 0 {
183            return Ok(PrefixScan::default());
184        }
185        check_prefix_cursor(prefix, after_key)?;
186        Err(LiteError::Unsupported {
187            detail: "budgeted prefix continuation requires backend support".into(),
188        })
189    }
190
191    /// Atomically apply a batch of writes.
192    ///
193    /// All operations in the batch succeed or fail together (transaction).
194    /// This is the primary write path for engines that need to persist
195    /// multiple related blobs atomically (e.g., HNSW node + metadata).
196    async fn batch_write(&self, ops: &[WriteOp]) -> Result<(), LiteError>;
197
198    /// Count the number of entries in a namespace.
199    ///
200    /// Useful for cold-start progress reporting and memory governor decisions.
201    async fn count(&self, ns: Namespace) -> Result<u64, LiteError>;
202
203    /// Compact the backing store, reclaiming dead pages and (when possible)
204    /// truncating the file to bound on-disk growth.
205    ///
206    /// The default implementation is a no-op returning a zero
207    /// [`CompactionOutcome`], so engines with nothing to compact (in-memory
208    /// stores, test doubles) need not override it. The pagedb-backed engine
209    /// overrides this to drain the deferred-free list, truncate `main.db`, AND
210    /// delete tombstoned segment files.
211    ///
212    /// Reclaiming tombstones belongs here because compaction is the only
213    /// "reclaim disk" operation callers know to run, and tombstones are by far
214    /// the largest reclaimable thing: a segment replacement retires its
215    /// predecessor and nothing else ever deletes it.
216    async fn compact(&self) -> Result<CompactionOutcome, LiteError> {
217        Ok(CompactionOutcome::default())
218    }
219
220    /// Range scan: return up to `limit` entries where key >= `start`.
221    ///
222    /// Results are ordered by key (lexicographic byte order).
223    async fn scan_range(
224        &self,
225        ns: Namespace,
226        start: &[u8],
227        limit: usize,
228    ) -> Result<Vec<KvPair>, LiteError>;
229
230    /// Bounded range scan: return entries where `start <= key < end`.
231    ///
232    /// - `start = None` means the beginning of the namespace.
233    /// - `end = None` means the end of the namespace.
234    /// - `limit = None` means no cap.
235    ///
236    /// Results are ordered by key (lexicographic byte order).
237    async fn scan_range_bounded(
238        &self,
239        ns: Namespace,
240        start: Option<&[u8]>,
241        end: Option<&[u8]>,
242        limit: Option<usize>,
243    ) -> Result<Vec<KvPair>, LiteError>;
244
245    /// Return this engine's vector segment operations interface if supported.
246    ///
247    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
248    /// falling back to the legacy blob checkpoint path.
249    ///
250    /// Only available on non-WASM targets (mmap is required).
251    #[cfg(not(target_arch = "wasm32"))]
252    fn as_vector_segment_ext(
253        &self,
254    ) -> Option<&dyn crate::storage::vector_segment_ext::VectorSegmentExt> {
255        None
256    }
257
258    /// Return this engine's array segment operations interface if supported.
259    ///
260    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
261    /// falling back to the KV blob path.
262    ///
263    /// Only available on non-WASM targets.
264    #[cfg(not(target_arch = "wasm32"))]
265    fn as_array_segment_ext(
266        &self,
267    ) -> Option<&dyn crate::storage::array_segment_ext::ArraySegmentExt> {
268        None
269    }
270
271    /// Return this engine's FTS segment operations interface if supported.
272    ///
273    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
274    /// falling back to the KV blob path where each term's postings are stored
275    /// as a separate B+ tree entry.
276    ///
277    /// Only available on non-WASM targets.
278    #[cfg(not(target_arch = "wasm32"))]
279    fn as_fts_segment_ext(&self) -> Option<&dyn crate::storage::fts_segment_ext::FtsSegmentExt> {
280        None
281    }
282
283    /// Return this engine's columnar segment operations interface if supported.
284    ///
285    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
286    /// falling back to the KV blob path for large segment bytes.
287    ///
288    /// Only available on non-WASM targets.
289    #[cfg(not(target_arch = "wasm32"))]
290    fn as_columnar_segment_ext(
291        &self,
292    ) -> Option<&dyn crate::storage::columnar_segment_ext::ColumnarSegmentExt> {
293        None
294    }
295
296    /// Return this engine's graph segment operations interface if supported.
297    ///
298    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
299    /// falling back to the legacy `Namespace::Graph` KV blob path for CSR
300    /// adjacency checkpoints.
301    ///
302    /// Only available on non-WASM targets.
303    #[cfg(not(target_arch = "wasm32"))]
304    fn as_graph_segment_ext(
305        &self,
306    ) -> Option<&dyn crate::storage::graph_segment_ext::GraphSegmentExt> {
307        None
308    }
309
310    /// Return this engine's spatial segment operations interface if supported.
311    ///
312    /// `PagedbStorage` returns `Some(self)`. Test doubles return `None`,
313    /// falling back to the legacy `Namespace::Spatial` KV blob path for R-tree
314    /// checkpoint blobs.
315    ///
316    /// Only available on non-WASM targets.
317    #[cfg(not(target_arch = "wasm32"))]
318    fn as_spatial_segment_ext(
319        &self,
320    ) -> Option<&dyn crate::storage::spatial_segment_ext::SpatialSegmentExt> {
321        None
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn write_op_debug() {
331        let op = WriteOp::Put {
332            ns: Namespace::Vector,
333            key: vec![1, 2],
334            value: vec![3, 4],
335        };
336        let dbg = format!("{op:?}");
337        assert!(dbg.contains("Put"));
338        assert!(dbg.contains("Vector"));
339    }
340}