Skip to content

Commit cf95932

Browse files
committed
feat(sync): add array CRDT sync module with outbound and inbound paths
Introduces the sync/array sub-module for replicating the sparse N-dimensional array engine between Lite and Origin via CRDT op-log deltas. Outbound path (Lite → Origin): - RedbOpLog: redb-backed persistent op-log for array cell mutations - PendingQueue: durable queue of unacknowledged outbound ops - ArrayOutbound: coordinates emit_put / emit_delete after engine writes - ReplicaState: stable replica UUID + HLC clock generator - SchemaRegistry: persists Loro-encoded SchemaDoc snapshots per array Inbound path (Origin → Lite): - ArrayInbound: dispatches wire messages to type-specific handlers - LiteApplyEngine: applies remote deltas to the local array engine - CatchupTracker: tracks per-array last-seen HLC for catch-up requests Wire the full send/receive state into NodeDbLite::new and emit ops from array_put_cell / array_delete_cell after each successful engine write.
1 parent ecec362 commit cf95932

19 files changed

Lines changed: 3035 additions & 9 deletions

nodedb-lite/src/nodedb/array.rs

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use nodedb_array::schema::ArraySchema;
88
use nodedb_array::tile::cell_payload::CellPayload;
99
use nodedb_array::types::cell_value::value::CellValue;
1010
use nodedb_array::types::coord::value::CoordValue;
11+
use nodedb_types::OPEN_UPPER;
1112
use nodedb_types::error::{NodeDbError, NodeDbResult};
1213

1314
use crate::storage::engine::{StorageEngine, StorageEngineSync};
@@ -20,10 +21,20 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
2021
///
2122
/// Returns an error if an array named `name` already exists.
2223
pub fn create_array(&self, name: &str, schema: ArraySchema) -> NodeDbResult<()> {
24+
// Clone before the engine call to avoid a partial-move of `schema`.
25+
let schema_for_crdt = schema.clone();
26+
2327
self.array_state
2428
.lock_or_recover()
2529
.create_array(&self.storage, name, schema)
26-
.map_err(NodeDbError::storage)
30+
.map_err(NodeDbError::storage)?;
31+
32+
// Register the schema CRDT so subsequent emit_* calls can find schema_hlc.
33+
self.array_schemas
34+
.put_schema(name, &schema_for_crdt)
35+
.map_err(NodeDbError::storage)?;
36+
37+
Ok(())
2738
}
2839

2940
/// Write a cell into array `name` at `coord`.
@@ -40,6 +51,10 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
4051
valid_from_ms: i64,
4152
valid_until_ms: i64,
4253
) -> NodeDbResult<()> {
54+
// Clone before the engine call to keep copies for emit.
55+
let coord_emit = coord.clone();
56+
let attrs_emit = attrs.clone();
57+
4358
self.array_state
4459
.lock_or_recover()
4560
.put_cell(
@@ -51,7 +66,27 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
5166
valid_from_ms,
5267
valid_until_ms,
5368
)
54-
.map_err(NodeDbError::storage)
69+
.map_err(NodeDbError::storage)?;
70+
71+
// Emit op after engine succeeds. If emit fails after a successful
72+
// engine write, log the error and return it; the engine write is
73+
// NOT rolled back — ack reconciliation in later phases will catch
74+
// the gap.
75+
if let Err(e) = self.array_outbound.emit_put(
76+
name,
77+
coord_emit,
78+
attrs_emit,
79+
valid_from_ms,
80+
valid_until_ms,
81+
) {
82+
tracing::error!(
83+
array = name,
84+
"array_put_cell: emit failed after engine write — op-log gap: {e}"
85+
);
86+
return Err(NodeDbError::storage(e));
87+
}
88+
89+
Ok(())
5590
}
5691

5792
/// Slice query: return all live cells whose coordinates fall within
@@ -94,10 +129,29 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
94129
coord: Vec<CoordValue>,
95130
system_from_ms: i64,
96131
) -> NodeDbResult<()> {
132+
// Clone before the engine call for emit.
133+
let coord_emit = coord.clone();
134+
97135
self.array_state
98136
.lock_or_recover()
99137
.delete_cell(name, coord, system_from_ms)
100-
.map_err(NodeDbError::storage)
138+
.map_err(NodeDbError::storage)?;
139+
140+
// valid_from_ms / valid_until_ms: the current API does not expose
141+
// valid-time arguments on delete. Defaults of 0 / OPEN_UPPER are used
142+
// here. Phase F will widen the API to carry the full bitemporal envelope.
143+
if let Err(e) = self
144+
.array_outbound
145+
.emit_delete(name, coord_emit, 0, OPEN_UPPER)
146+
{
147+
tracing::error!(
148+
array = name,
149+
"array_delete_cell: emit failed after engine write — op-log gap: {e}"
150+
);
151+
return Err(NodeDbError::storage(e));
152+
}
153+
154+
Ok(())
101155
}
102156

103157
/// GDPR erasure: write the `0xFE` sentinel and flush to disk.
@@ -111,10 +165,28 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
111165
coord: Vec<CoordValue>,
112166
system_from_ms: i64,
113167
) -> NodeDbResult<()> {
168+
// Clone before the engine call for emit.
169+
let coord_emit = coord.clone();
170+
114171
self.array_state
115172
.lock_or_recover()
116173
.gdpr_erase_cell(&self.storage, name, coord, system_from_ms)
117-
.map_err(NodeDbError::storage)
174+
.map_err(NodeDbError::storage)?;
175+
176+
// valid_from_ms / valid_until_ms: same defaulting as array_delete_cell.
177+
// Phase F will widen the API.
178+
if let Err(e) = self
179+
.array_outbound
180+
.emit_erase(name, coord_emit, 0, OPEN_UPPER)
181+
{
182+
tracing::error!(
183+
array = name,
184+
"array_gdpr_erase_cell: emit failed after engine write — op-log gap: {e}"
185+
);
186+
return Err(NodeDbError::storage(e));
187+
}
188+
189+
Ok(())
118190
}
119191

120192
/// Flush any pending memtable data for `name` to a durable segment.

nodedb-lite/src/nodedb/core.rs

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::engine::htap::HtapBridge;
1414
use crate::engine::strict::StrictEngine;
1515
use crate::engine::vector::graph::{HnswIndex, HnswParams};
1616
use crate::memory::{EngineId, MemoryGovernor};
17-
use crate::storage::engine::{StorageEngine, WriteOp};
17+
use crate::storage::engine::{StorageEngine, StorageEngineSync, WriteOp};
1818

1919
/// Storage key constants.
2020
pub(crate) const META_HNSW_COLLECTIONS: &[u8] = b"meta:hnsw_collections";
@@ -31,7 +31,7 @@ pub(crate) const META_LAST_FLUSHED_MID: &[u8] = b"meta:last_flushed_mid";
3131
///
3232
/// Fully capable of vector search, graph traversal, and document CRUD
3333
/// entirely offline. Optional sync to Origin via WebSocket.
34-
pub struct NodeDbLite<S: StorageEngine> {
34+
pub struct NodeDbLite<S: StorageEngine + StorageEngineSync> {
3535
pub(crate) storage: Arc<S>,
3636
/// Per-collection HNSW indices.
3737
pub(crate) hnsw_indices: Mutex<HashMap<String, HnswIndex>>,
@@ -70,7 +70,25 @@ pub struct NodeDbLite<S: StorageEngine> {
7070
/// Arc-wrapped for sharing with the query engine's DDL handlers.
7171
pub(crate) timeseries: Arc<Mutex<crate::engine::timeseries::engine::TimeseriesEngine>>,
7272
/// Array engine in-memory state (storage-agnostic; calls via NodeDbLite methods).
73-
pub(crate) array_state: std::sync::Mutex<crate::engine::array::engine::ArrayEngineState>,
73+
///
74+
/// `Arc`-wrapped so it can be shared with [`crate::sync::array::LiteApplyEngine`]
75+
/// for the inbound receive path without borrowing `NodeDbLite`.
76+
pub(crate) array_state: Arc<std::sync::Mutex<crate::engine::array::engine::ArrayEngineState>>,
77+
/// Stable per-replica identity + HLC generator for array CRDT sync.
78+
/// Used by the transport-layer wiring (Phase F+); held here so that
79+
/// the `NodeDbLite` constructor owns the lifetime.
80+
#[allow(dead_code)]
81+
pub(crate) array_replica: Arc<crate::sync::array::ReplicaState>,
82+
/// Per-array [`SchemaDoc`] registry (persisted Loro snapshots).
83+
pub(crate) array_schemas: Arc<crate::sync::array::SchemaRegistry<S>>,
84+
/// Array CRDT send path: op-log + pending queue emitters.
85+
pub(crate) array_outbound: Arc<crate::sync::array::ArrayOutbound<S>>,
86+
/// Array CRDT receive path: applies inbound wire messages from Origin.
87+
#[allow(dead_code)]
88+
pub(crate) array_inbound: Arc<crate::sync::array::ArrayInbound<S>>,
89+
/// Per-array last-seen HLC tracker for catch-up requests.
90+
#[allow(dead_code)]
91+
pub(crate) array_catchup: Arc<crate::sync::array::CatchupTracker<S>>,
7492
/// When `false`, KV operations go directly to redb, bypassing Loro.
7593
/// Other engines (vector, graph, document) are unaffected.
7694
pub(crate) sync_enabled: bool,
@@ -97,7 +115,7 @@ pub(crate) struct KvWriteBuffer {
97115
pub overlay: HashMap<Vec<u8>, Option<Vec<u8>>>,
98116
}
99117

100-
impl<S: StorageEngine> NodeDbLite<S> {
118+
impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
101119
/// Open or create a Lite database backed by the given storage engine.
102120
///
103121
/// Memory budget and per-engine percentages are resolved from environment
@@ -271,6 +289,47 @@ impl<S: StorageEngine> NodeDbLite<S> {
271289

272290
let array_engine =
273291
crate::engine::array::ArrayEngineState::open(&storage).map_err(NodeDbError::storage)?;
292+
let array_state = Arc::new(Mutex::new(array_engine));
293+
294+
// ── Array CRDT sync state ──────────────────────────────────────────────
295+
let array_replica = Arc::new(
296+
crate::sync::array::ReplicaState::load_or_init(&*storage)
297+
.map_err(NodeDbError::storage)?,
298+
);
299+
let array_schemas = Arc::new(
300+
crate::sync::array::SchemaRegistry::load(
301+
Arc::clone(&storage),
302+
Arc::clone(&array_replica),
303+
)
304+
.map_err(NodeDbError::storage)?,
305+
);
306+
let array_op_log = Arc::new(crate::sync::array::RedbOpLog::new(Arc::clone(&storage)));
307+
let array_pending = Arc::new(crate::sync::array::PendingQueue::new(Arc::clone(&storage)));
308+
let array_outbound = Arc::new(crate::sync::array::ArrayOutbound::new(
309+
Arc::clone(&array_op_log),
310+
Arc::clone(&array_pending),
311+
Arc::clone(&array_schemas),
312+
Arc::clone(&array_replica),
313+
));
314+
315+
// ── Array CRDT inbound receive path ───────────────────────────────────
316+
let array_apply_engine = Arc::new(crate::sync::array::LiteApplyEngine::new(
317+
Arc::clone(&storage),
318+
Arc::clone(&array_state),
319+
Arc::clone(&array_schemas),
320+
Arc::clone(array_outbound.op_log()),
321+
));
322+
let array_inbound = Arc::new(crate::sync::array::ArrayInbound::new(
323+
array_apply_engine,
324+
Arc::clone(&array_schemas),
325+
Arc::clone(&array_replica),
326+
Arc::clone(array_outbound.pending()),
327+
Arc::clone(array_outbound.op_log()),
328+
));
329+
let array_catchup = Arc::new(
330+
crate::sync::array::CatchupTracker::load(Arc::clone(&storage))
331+
.map_err(NodeDbError::storage)?,
332+
);
274333

275334
let db = Self {
276335
storage,
@@ -288,7 +347,12 @@ impl<S: StorageEngine> NodeDbLite<S> {
288347
columnar,
289348
htap,
290349
timeseries,
291-
array_state: Mutex::new(array_engine),
350+
array_state,
351+
array_replica,
352+
array_schemas,
353+
array_outbound,
354+
array_inbound,
355+
array_catchup,
292356
sync_enabled,
293357
kv_write_buf: Mutex::new(KvWriteBuffer {
294358
ops: Vec::with_capacity(1024),

0 commit comments

Comments
 (0)