Skip to main content

nodedb_lite/sync/array/
op_log_store.rs

1//! Storage-backed [`OpLog`] implementation for the array CRDT sync subsystem.
2//!
3//! # Storage layout
4//!
5//! One entry per op, stored under [`Namespace::ArrayOpLog`].
6//!
7//! **Composite key** = `[array_name_len: u8] [array_name_bytes] [hlc_bytes: 18]`
8//!
9//! The leading length byte allows:
10//! - Per-array prefix scans (build `[len, name_bytes]` as prefix).
11//! - Bounded range reads for `scan_range`: the HLC suffix is big-endian, so
12//!   `[make_key(array, from), successor(make_key(array, to)))` holds exactly
13//!   the array's ops in the HLC range.
14//!
15//! **Name length constraint**: `u8` accommodates names up to 255 bytes.
16//! The array schema validator enforces this upper bound. If a name longer
17//! than 255 bytes arrives at [`KvOpLogStore::append`], the method returns
18//! [`ArrayError::SegmentCorruption`] rather than silently truncating.
19//!
20//! # Runtime requirement
21//!
22//! The `OpLog` trait has synchronous methods. This implementation bridges into
23//! async storage via `tokio::task::block_in_place`, which requires the
24//! multi-thread Tokio runtime. The array sync subsystem is
25//! `#[cfg(not(target_arch = "wasm32"))]` only, and the embedder runtimes
26//! (FFI, CLI) are all multi-thread. Tests in this module use
27//! `#[tokio::test(flavor = "multi_thread")]` for the same reason.
28
29use std::future::Future;
30use std::sync::Arc;
31
32use nodedb_array::error::{ArrayError, ArrayResult};
33use nodedb_array::sync::hlc::Hlc;
34use nodedb_array::sync::op::ArrayOp;
35use nodedb_array::sync::op_codec;
36use nodedb_array::sync::op_log::{OpIter, OpLog};
37use nodedb_types::Namespace;
38
39use crate::error::LiteError;
40use crate::storage::engine::{StorageEngine, WriteOp};
41
42/// Storage-backed append-only operation log for the array CRDT sync subsystem.
43///
44/// Generic over any [`StorageEngine`] implementation. Bridges the sync
45/// `OpLog` trait into async storage via `tokio::task::block_in_place`.
46///
47/// See the module-level documentation for the composite key layout.
48pub struct KvOpLogStore<S: StorageEngine> {
49    storage: Arc<S>,
50}
51
52impl<S: StorageEngine> KvOpLogStore<S> {
53    /// Wrap an existing storage backend.
54    pub fn new(storage: Arc<S>) -> Self {
55        Self { storage }
56    }
57}
58
59// ─── Key helpers ──────────────────────────────────────────────────────────────
60
61/// Build the full composite key `[name_len: u8][name_bytes][hlc: 18]`.
62///
63/// Returns [`ArrayError::SegmentCorruption`] if the array name is empty or
64/// exceeds 255 bytes.
65fn make_key(array: &str, hlc: Hlc) -> ArrayResult<Vec<u8>> {
66    let name = array.as_bytes();
67    if name.is_empty() {
68        return Err(ArrayError::SegmentCorruption {
69            detail: "op_log: array name must not be empty".into(),
70        });
71    }
72    if name.len() > 255 {
73        return Err(ArrayError::SegmentCorruption {
74            detail: "op_log: array name exceeds 255 bytes".into(),
75        });
76    }
77    let mut key = Vec::with_capacity(1 + name.len() + 18);
78    key.push(name.len() as u8);
79    key.extend_from_slice(name);
80    key.extend_from_slice(&hlc.to_bytes());
81    Ok(key)
82}
83
84/// Build the array prefix `[name_len: u8][name_bytes]` used for prefix scans.
85///
86/// Returns [`ArrayError::SegmentCorruption`] on invalid name (same rules as
87/// [`make_key`]).
88fn make_array_prefix(array: &str) -> ArrayResult<Vec<u8>> {
89    let name = array.as_bytes();
90    if name.is_empty() {
91        return Err(ArrayError::SegmentCorruption {
92            detail: "op_log: array name must not be empty".into(),
93        });
94    }
95    if name.len() > 255 {
96        return Err(ArrayError::SegmentCorruption {
97            detail: "op_log: array name exceeds 255 bytes".into(),
98        });
99    }
100    let mut prefix = Vec::with_capacity(1 + name.len());
101    prefix.push(name.len() as u8);
102    prefix.extend_from_slice(name);
103    Ok(prefix)
104}
105
106/// Compute the exclusive upper bound for a range scan that ends at `prefix`.
107///
108/// Increments the last byte of `prefix`, carrying into earlier bytes. Returns
109/// `None` if `prefix` is all-`0xFF` bytes. The scan then runs to the end of
110/// the namespace.
111fn next_prefix(prefix: &[u8]) -> Option<Vec<u8>> {
112    let mut upper = prefix.to_vec();
113    for byte in upper.iter_mut().rev() {
114        if *byte < 0xFF {
115            *byte += 1;
116            return Some(upper);
117        }
118        *byte = 0x00;
119    }
120    None
121}
122
123/// Parse a composite key back into `(array_name, Hlc)`.
124///
125/// Returns `None` on malformed input (too short, name_len truncated, etc.)
126/// rather than panicking.
127fn split_key(key: &[u8]) -> Option<(String, Hlc)> {
128    if key.is_empty() {
129        return None;
130    }
131    let name_len = key[0] as usize;
132    let name_end = 1 + name_len;
133    if key.len() < name_end + 18 {
134        return None;
135    }
136    let name = std::str::from_utf8(&key[1..name_end]).ok()?;
137    let hlc_bytes: &[u8; 18] = key[name_end..name_end + 18].try_into().ok()?;
138    Some((name.to_owned(), Hlc::from_bytes(hlc_bytes)))
139}
140
141/// Map a [`LiteError`] to [`ArrayError::SegmentCorruption`].
142fn lite_err_to_array(e: LiteError) -> ArrayError {
143    ArrayError::SegmentCorruption {
144        detail: format!("op_log: {e}"),
145    }
146}
147
148// ─── OpLog impl ───────────────────────────────────────────────────────────────
149//
150// The `OpLog` trait has synchronous methods. We bridge into async storage via
151// `tokio::task::block_in_place`, which runs the future on the current thread
152// without yielding the multi-thread runtime. Requires multi-thread flavor.
153
154/// Run an async closure synchronously via `block_in_place`.
155///
156/// Panics if called outside a multi-thread Tokio runtime (which is guaranteed
157/// by the array sync subsystem's runtime contract).
158fn block<F, T>(f: F) -> T
159where
160    F: Future<Output = T>,
161{
162    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
163}
164
165impl<S: StorageEngine> OpLog for KvOpLogStore<S> {
166    /// Append an operation to the log.
167    ///
168    /// Idempotent: re-appending the same `(array, hlc)` overwrites the stored
169    /// bytes with identical bytes — a semantic no-op.
170    fn append(&self, op: &ArrayOp) -> ArrayResult<()> {
171        let key = make_key(&op.header.array, op.header.hlc)?;
172        let value = op_codec::encode_op(op)?;
173        block(self.storage.put(Namespace::ArrayOpLog, &key, &value)).map_err(lite_err_to_array)
174    }
175
176    /// Return all ops with `hlc >= from`, across all arrays, in composite-key
177    /// order (array name, then HLC).
178    ///
179    /// All matching entries are collected upfront so the storage handle is not
180    /// held across the returned iterator lifetime.
181    fn scan_from<'a>(&'a self, from: Hlc) -> ArrayResult<OpIter<'a>> {
182        let pairs = block(
183            self.storage
184                .scan_range(Namespace::ArrayOpLog, &[], usize::MAX),
185        )
186        .map_err(lite_err_to_array)?;
187
188        let ops: Vec<ArrayResult<ArrayOp>> = pairs
189            .into_iter()
190            .filter_map(|(key, value)| {
191                let (_, hlc) = split_key(&key)?;
192                if hlc < from {
193                    return None;
194                }
195                Some(op_codec::decode_op(&value))
196            })
197            .collect();
198
199        Ok(Box::new(ops.into_iter()))
200    }
201
202    /// Return ops for `array` with `from <= hlc <= to`, in HLC order.
203    ///
204    /// Reads only the keys in `[make_key(array, from), make_key(array, to)]`.
205    /// `Hlc::to_bytes` is big-endian in `Ord` field order, so key order
206    /// equals HLC order inside one array prefix.
207    fn scan_range<'a>(&'a self, array: &str, from: Hlc, to: Hlc) -> ArrayResult<OpIter<'a>> {
208        let start = make_key(array, from)?;
209        if from > to {
210            return Ok(Box::new(std::iter::empty()));
211        }
212        // Every key under one array prefix has the same length. The
213        // successor of the `to` key is the exclusive end of the range.
214        let end = next_prefix(&make_key(array, to)?);
215
216        let pairs = block(self.storage.scan_range_bounded(
217            Namespace::ArrayOpLog,
218            Some(&start),
219            end.as_deref(),
220            None,
221        ))
222        .map_err(lite_err_to_array)?;
223
224        let prefix = make_array_prefix(array)?;
225        let ops: Vec<ArrayResult<ArrayOp>> = pairs
226            .into_iter()
227            .take_while(|(key, _)| key.starts_with(&prefix))
228            .filter_map(|(key, value)| {
229                let (_, hlc) = split_key(&key)?;
230                if hlc < from || hlc > to {
231                    return None;
232                }
233                Some(op_codec::decode_op(&value))
234            })
235            .collect();
236
237        Ok(Box::new(ops.into_iter()))
238    }
239
240    /// Return the total number of ops across all arrays.
241    fn len(&self) -> ArrayResult<u64> {
242        block(self.storage.count(Namespace::ArrayOpLog)).map_err(lite_err_to_array)
243    }
244
245    /// Delete all ops with `hlc < hlc` and return the count deleted.
246    fn drop_below(&self, hlc: Hlc) -> ArrayResult<u64> {
247        let pairs = block(
248            self.storage
249                .scan_range(Namespace::ArrayOpLog, &[], usize::MAX),
250        )
251        .map_err(lite_err_to_array)?;
252
253        let to_delete: Vec<WriteOp> = pairs
254            .into_iter()
255            .filter_map(|(key, _)| {
256                let (_, entry_hlc) = split_key(&key)?;
257                if entry_hlc < hlc {
258                    Some(WriteOp::Delete {
259                        ns: Namespace::ArrayOpLog,
260                        key,
261                    })
262                } else {
263                    None
264                }
265            })
266            .collect();
267
268        let count = to_delete.len() as u64;
269        if !to_delete.is_empty() {
270            block(self.storage.batch_write(&to_delete)).map_err(lite_err_to_array)?;
271        }
272        Ok(count)
273    }
274}
275
276// ─── Tests ────────────────────────────────────────────────────────────────────
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use nodedb_array::sync::op::{ArrayOpHeader, ArrayOpKind};
282    use nodedb_array::sync::replica_id::ReplicaId;
283    use nodedb_array::types::cell_value::value::CellValue;
284    use nodedb_array::types::coord::value::CoordValue;
285
286    use crate::storage::pagedb_storage::{PagedbStorageDefault, PagedbStorageMem};
287
288    fn replica() -> ReplicaId {
289        ReplicaId::new(1)
290    }
291
292    fn hlc(ms: u64, logical: u16) -> Hlc {
293        Hlc::new(ms, logical, replica()).unwrap()
294    }
295
296    fn make_op(array: &str, ms: u64) -> ArrayOp {
297        ArrayOp {
298            header: ArrayOpHeader {
299                array: array.into(),
300                hlc: hlc(ms, 0),
301                schema_hlc: hlc(1, 0),
302                valid_from_ms: 0,
303                valid_until_ms: -1,
304                system_from_ms: ms as i64,
305            },
306            kind: ArrayOpKind::Put,
307            coord: vec![CoordValue::Int64(ms as i64)],
308            attrs: Some(vec![CellValue::Null]),
309        }
310    }
311
312    async fn make_log() -> KvOpLogStore<PagedbStorageMem> {
313        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
314        KvOpLogStore::new(storage)
315    }
316
317    // All tests that exercise KvOpLogStore methods must run on the multi-thread
318    // Tokio runtime because block_in_place requires it.
319
320    #[tokio::test(flavor = "multi_thread")]
321    async fn append_then_scan_returns_op() {
322        let log = make_log().await;
323        log.append(&make_op("arr", 10)).unwrap();
324        log.append(&make_op("arr", 20)).unwrap();
325
326        let ops: Vec<_> = log
327            .scan_from(Hlc::ZERO)
328            .unwrap()
329            .map(|r| r.unwrap())
330            .collect();
331        assert_eq!(ops.len(), 2);
332    }
333
334    #[tokio::test(flavor = "multi_thread")]
335    async fn scan_from_filters_below() {
336        let log = make_log().await;
337        log.append(&make_op("arr", 10)).unwrap();
338        log.append(&make_op("arr", 20)).unwrap();
339        log.append(&make_op("arr", 30)).unwrap();
340
341        let ops: Vec<_> = log
342            .scan_from(hlc(20, 0))
343            .unwrap()
344            .map(|r| r.unwrap())
345            .collect();
346        assert_eq!(ops.len(), 2);
347        assert!(ops.iter().all(|op| op.header.hlc.physical_ms >= 20));
348    }
349
350    #[tokio::test(flavor = "multi_thread")]
351    async fn scan_range_filters_array() {
352        let log = make_log().await;
353        log.append(&make_op("a", 10)).unwrap();
354        log.append(&make_op("b", 20)).unwrap();
355        log.append(&make_op("a", 30)).unwrap();
356
357        let ops: Vec<_> = log
358            .scan_range("a", Hlc::ZERO, hlc(u64::MAX >> 16, 0))
359            .unwrap()
360            .map(|r| r.unwrap())
361            .collect();
362        assert_eq!(ops.len(), 2);
363        assert!(ops.iter().all(|op| op.header.array == "a"));
364    }
365
366    #[tokio::test(flavor = "multi_thread")]
367    async fn scan_range_filters_inclusive_bounds() {
368        let log = make_log().await;
369        log.append(&make_op("arr", 10)).unwrap();
370        log.append(&make_op("arr", 20)).unwrap();
371        log.append(&make_op("arr", 30)).unwrap();
372
373        // Both bounds are inclusive.
374        let ops: Vec<_> = log
375            .scan_range("arr", hlc(10, 0), hlc(20, 0))
376            .unwrap()
377            .map(|r| r.unwrap())
378            .collect();
379        assert_eq!(ops.len(), 2);
380        let ms: Vec<u64> = ops.iter().map(|op| op.header.hlc.physical_ms).collect();
381        assert!(ms.contains(&10));
382        assert!(ms.contains(&20));
383        assert!(!ms.contains(&30));
384    }
385
386    #[tokio::test(flavor = "multi_thread")]
387    async fn drop_below_drops_correctly() {
388        let log = make_log().await;
389        log.append(&make_op("arr", 10)).unwrap();
390        log.append(&make_op("arr", 20)).unwrap();
391        log.append(&make_op("arr", 30)).unwrap();
392
393        // drop_below(20) drops only ms=10 (strict less-than).
394        let dropped = log.drop_below(hlc(20, 0)).unwrap();
395        assert_eq!(dropped, 1);
396        assert_eq!(log.len().unwrap(), 2);
397
398        let ops: Vec<_> = log
399            .scan_from(Hlc::ZERO)
400            .unwrap()
401            .map(|r| r.unwrap())
402            .collect();
403        assert!(ops.iter().all(|op| op.header.hlc.physical_ms >= 20));
404    }
405
406    #[tokio::test(flavor = "multi_thread")]
407    async fn len_counts_correctly() {
408        let log = make_log().await;
409        assert_eq!(log.len().unwrap(), 0);
410        log.append(&make_op("x", 1)).unwrap();
411        log.append(&make_op("y", 2)).unwrap();
412        assert_eq!(log.len().unwrap(), 2);
413    }
414
415    #[tokio::test(flavor = "multi_thread")]
416    async fn idempotent_append() {
417        let log = make_log().await;
418        log.append(&make_op("arr", 10)).unwrap();
419        log.append(&make_op("arr", 10)).unwrap();
420        assert_eq!(log.len().unwrap(), 1);
421    }
422
423    #[test]
424    fn array_name_too_long_errors() {
425        // make_key fails before touching storage, so no runtime needed.
426        let long_name = "a".repeat(256);
427        let name_bytes = long_name.as_bytes();
428        assert!(name_bytes.len() > 255);
429        let err = make_key(&long_name, Hlc::ZERO).unwrap_err();
430        assert!(
431            matches!(err, ArrayError::SegmentCorruption { ref detail } if detail.contains("255")),
432            "unexpected error: {err:?}"
433        );
434    }
435
436    #[tokio::test(flavor = "multi_thread")]
437    async fn decode_corruption_propagates() {
438        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
439        let log = KvOpLogStore::new(Arc::clone(&storage));
440
441        // Write a valid key with garbage value directly via storage.
442        let valid_key = make_key("arr", hlc(99, 0)).unwrap();
443        storage
444            .put(Namespace::ArrayOpLog, &valid_key, b"\xff\xfe garbage")
445            .await
446            .unwrap();
447
448        let results: Vec<_> = log.scan_from(Hlc::ZERO).unwrap().collect();
449        assert_eq!(results.len(), 1);
450        assert!(results[0].is_err(), "expected decode error, got Ok");
451    }
452
453    #[tokio::test(flavor = "multi_thread")]
454    async fn survives_storage_restart() {
455        let dir = tempfile::tempdir().unwrap();
456        let path = dir.path().join("op_log_test.pagedb");
457
458        {
459            let storage = Arc::new(
460                PagedbStorageDefault::open(
461                    &path,
462                    crate::storage::encryption::Encryption::Plaintext,
463                )
464                .await
465                .unwrap(),
466            );
467            let log = KvOpLogStore::new(Arc::clone(&storage));
468            log.append(&make_op("arr", 10)).unwrap();
469            log.append(&make_op("arr", 20)).unwrap();
470        }
471
472        // Reopen the same file.
473        {
474            let storage = Arc::new(
475                PagedbStorageDefault::open(
476                    &path,
477                    crate::storage::encryption::Encryption::Plaintext,
478                )
479                .await
480                .unwrap(),
481            );
482            let log = KvOpLogStore::new(storage);
483            assert_eq!(log.len().unwrap(), 2);
484            let ops: Vec<_> = log
485                .scan_from(Hlc::ZERO)
486                .unwrap()
487                .map(|r| r.unwrap())
488                .collect();
489            assert_eq!(ops.len(), 2);
490        }
491    }
492
493    #[test]
494    fn next_prefix_increments_last_byte() {
495        assert_eq!(next_prefix(&[0x01, 0x61]), Some(vec![0x01, 0x62]));
496        assert_eq!(next_prefix(&[0x01, 0xFF]), Some(vec![0x02, 0x00]));
497        assert_eq!(next_prefix(&[0xFF, 0xFF]), None);
498    }
499}