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}