Skip to content

Commit b08749f

Browse files
committed
feat(sync/array): add ack sender, catchup persistence, and last-applied tracking
Add ArrayAckSender — a periodic Tokio task that sends ArrayAckMsg frames to Origin so its GC frontier can advance. The task is driven by a configurable interval and stopped via a oneshot channel on disconnect. Extend CatchupTracker with a persistent catchup-needed flag: when Origin sends ArrayRejectReason::RetentionFloor the flag is written to Namespace::Meta and survives restarts, ensuring the next connect triggers a full snapshot catch-up. Expose should_request_catchup, record_reject_retention_floor, clear_catchup_needed, and arrays_needing_catchup. Extend LiteApplyEngine with a per-array last-applied HLC cache persisted under Namespace::Meta. Advances on every successful apply_write, apply_delete, and apply_erase call; restored from storage on startup. Expose last_applied_hlc for use by the ack sender. Add SchemaRegistry::list_arrays to enumerate registered arrays, required by the ack sender to iterate over known collections. Wire CatchupTracker into ArrayInbound::new (dispatcher and test fixtures) and fix initialization order in NodeDbLite::open so CatchupTracker is constructed before LiteApplyEngine.
1 parent 91d3e25 commit b08749f

9 files changed

Lines changed: 304 additions & 12 deletions

File tree

nodedb-lite/src/nodedb/core.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
313313
));
314314

315315
// ── Array CRDT inbound receive path ───────────────────────────────────
316+
let array_catchup = Arc::new(
317+
crate::sync::array::CatchupTracker::load(Arc::clone(&storage))
318+
.map_err(NodeDbError::storage)?,
319+
);
316320
let array_apply_engine = Arc::new(crate::sync::array::LiteApplyEngine::new(
317321
Arc::clone(&storage),
318322
Arc::clone(&array_state),
@@ -325,11 +329,8 @@ impl<S: StorageEngine + StorageEngineSync> NodeDbLite<S> {
325329
Arc::clone(&array_replica),
326330
Arc::clone(array_outbound.pending()),
327331
Arc::clone(array_outbound.op_log()),
332+
Arc::clone(&array_catchup),
328333
));
329-
let array_catchup = Arc::new(
330-
crate::sync::array::CatchupTracker::load(Arc::clone(&storage))
331-
.map_err(NodeDbError::storage)?,
332-
);
333334

334335
let db = Self {
335336
storage,
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! [`ArrayAckSender`] — periodic ack sender from Lite to Origin.
2+
//!
3+
//! A Tokio task (spawned on sync-session start, shut down on disconnect)
4+
//! that runs every `interval` seconds and sends `ArrayAckMsg` for each known
5+
//! array to Origin. Origin merges these into its `ArrayAckRegistry` to advance
6+
//! the GC frontier.
7+
//!
8+
//! # What gets acked
9+
//!
10+
//! For each registered array the ack carries `last_applied_hlc` — the highest
11+
//! HLC successfully applied by `LiteApplyEngine`. This is the correct value
12+
//! because it represents "I have all ops up to this HLC" from Origin's
13+
//! perspective.
14+
//!
15+
//! # Transport
16+
//!
17+
//! Frames are sent via a `tokio::sync::mpsc::Sender<Vec<u8>>` that the
18+
//! session's WebSocket write loop drains. This matches the pattern used by
19+
//! other Lite sync subsystems.
20+
21+
use std::sync::Arc;
22+
use std::time::Duration;
23+
24+
use nodedb_array::sync::replica_id::ReplicaId;
25+
use nodedb_types::sync::wire::SyncMessageType;
26+
use nodedb_types::sync::wire::array::ArrayAckMsg;
27+
use tokio::sync::mpsc;
28+
use tokio::task::JoinHandle;
29+
use tracing::warn;
30+
31+
use crate::storage::engine::StorageEngineSync;
32+
33+
use super::inbound::apply::LiteApplyEngine;
34+
use super::schema_registry::SchemaRegistry;
35+
36+
/// Default ack interval: 30 seconds.
37+
pub const DEFAULT_ACK_INTERVAL: Duration = Duration::from_secs(30);
38+
39+
/// Periodic array ack sender task.
40+
///
41+
/// Spawned on session connect and cancelled on disconnect. The returned
42+
/// `JoinHandle` should be stored by the session and aborted (via
43+
/// `handle.abort()`) on session teardown.
44+
pub fn spawn<S: StorageEngineSync + Send + Sync + 'static>(
45+
schemas: Arc<SchemaRegistry<S>>,
46+
engine: Arc<LiteApplyEngine<S>>,
47+
replica_id: ReplicaId,
48+
tx: mpsc::Sender<Vec<u8>>,
49+
interval: Duration,
50+
mut stop: tokio::sync::oneshot::Receiver<()>,
51+
) -> JoinHandle<()> {
52+
tokio::spawn(async move {
53+
let mut ticker = tokio::time::interval(interval);
54+
// Skip the immediate first tick so we don't ack before applying anything.
55+
ticker.tick().await;
56+
57+
loop {
58+
tokio::select! {
59+
_ = ticker.tick() => {
60+
send_acks(&schemas, &engine, replica_id, &tx).await;
61+
}
62+
_ = &mut stop => {
63+
return;
64+
}
65+
}
66+
}
67+
})
68+
}
69+
70+
/// Build and send `ArrayAckMsg` frames for all known arrays.
71+
async fn send_acks<S: StorageEngineSync>(
72+
schemas: &SchemaRegistry<S>,
73+
engine: &LiteApplyEngine<S>,
74+
replica_id: ReplicaId,
75+
tx: &mpsc::Sender<Vec<u8>>,
76+
) {
77+
let arrays = schemas.list_arrays();
78+
for array in &arrays {
79+
let Some(ack_hlc) = engine.last_applied_hlc(array) else {
80+
// No ops applied for this array yet — nothing to ack.
81+
continue;
82+
};
83+
84+
let msg = ArrayAckMsg {
85+
array: array.clone(),
86+
replica_id: replica_id.as_u64(),
87+
ack_hlc_bytes: ack_hlc.to_bytes(),
88+
};
89+
90+
let frame = match nodedb_types::sync::wire::SyncFrame::try_encode(
91+
SyncMessageType::ArrayAck,
92+
&msg,
93+
) {
94+
Some(f) => f.to_bytes(),
95+
None => {
96+
warn!(
97+
array = %array,
98+
"ack_sender: SyncFrame encode failed for ArrayAck"
99+
);
100+
continue;
101+
}
102+
};
103+
104+
if tx.send(frame).await.is_err() {
105+
// Transport closed — session is shutting down.
106+
return;
107+
}
108+
}
109+
}

nodedb-lite/src/sync/array/catchup.rs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,31 @@ use crate::storage::engine::StorageEngineSync;
2121
/// Storage key prefix for per-array last-seen HLC entries.
2222
const LAST_SEEN_PREFIX: &str = "array.last_seen_hlc:";
2323

24+
/// Storage key prefix for per-array catchup-needed flags.
25+
const CATCHUP_NEEDED_PREFIX: &str = "array.catchup_needed:";
26+
2427
/// Storage key for a named array's last-seen HLC.
2528
fn last_seen_key(array: &str) -> Vec<u8> {
2629
format!("{LAST_SEEN_PREFIX}{array}").into_bytes()
2730
}
2831

32+
/// Storage key for a named array's catchup-needed flag.
33+
fn catchup_needed_key(array: &str) -> Vec<u8> {
34+
format!("{CATCHUP_NEEDED_PREFIX}{array}").into_bytes()
35+
}
36+
2937
/// Tracks the last successfully applied inbound HLC per array.
3038
///
31-
/// Used by the transport layer (future phases) to populate catchup-request
32-
/// messages after reconnect or when Origin's log GC horizon advances past the
33-
/// local log.
39+
/// Used by the transport layer to populate catchup-request messages after
40+
/// reconnect or when Origin's log GC horizon advances past the local log.
3441
///
3542
/// Thread-safe via an internal [`Mutex`].
3643
pub struct CatchupTracker<S: StorageEngineSync> {
3744
storage: Arc<S>,
3845
state: Mutex<HashMap<String, Hlc>>,
46+
/// Arrays that need a full catchup on next connect.
47+
/// Set when Origin sends `ArrayRejectMsg::RetentionFloor`.
48+
catchup_needed: Mutex<std::collections::HashSet<String>>,
3949
}
4050

4151
impl<S: StorageEngineSync> CatchupTracker<S> {
@@ -67,9 +77,24 @@ impl<S: StorageEngineSync> CatchupTracker<S> {
6777
state.insert(name.to_owned(), Hlc::from_bytes(&hlc_arr));
6878
}
6979

80+
// Load catchup-needed flags.
81+
let needed_prefix = CATCHUP_NEEDED_PREFIX.as_bytes();
82+
let needed_pairs = storage.scan_range_sync(Namespace::Meta, needed_prefix, usize::MAX)?;
83+
let mut catchup_needed = std::collections::HashSet::new();
84+
for (key, _) in needed_pairs {
85+
if !key.starts_with(needed_prefix) {
86+
break;
87+
}
88+
let name_bytes = &key[needed_prefix.len()..];
89+
if let Ok(name) = std::str::from_utf8(name_bytes) {
90+
catchup_needed.insert(name.to_owned());
91+
}
92+
}
93+
7094
Ok(Self {
7195
storage,
7296
state: Mutex::new(state),
97+
catchup_needed: Mutex::new(catchup_needed),
7398
})
7499
}
75100

@@ -110,6 +135,60 @@ impl<S: StorageEngineSync> CatchupTracker<S> {
110135
from_hlc_bytes: self.last_seen(array).to_bytes(),
111136
}
112137
}
138+
139+
/// Returns `true` if `array` should request a full catch-up on connect.
140+
///
141+
/// True when:
142+
/// - No `last_seen_hlc` is recorded (first connect), OR
143+
/// - `record_reject_retention_floor` was called (Origin GC advanced past our log).
144+
pub fn should_request_catchup(&self, array: &str, _current_local_hlc: Hlc) -> bool {
145+
let needs_catchup = self
146+
.catchup_needed
147+
.lock()
148+
.ok()
149+
.map(|s| s.contains(array))
150+
.unwrap_or(false);
151+
if needs_catchup {
152+
return true;
153+
}
154+
// First connect: no last-seen HLC recorded.
155+
let state = self.state.lock().ok();
156+
state.map(|s| !s.contains_key(array)).unwrap_or(false)
157+
}
158+
159+
/// Mark `array` as needing a full catch-up on the next connect.
160+
///
161+
/// Called when Origin sends `ArrayRejectMsg::RetentionFloor`.
162+
/// Persisted under `Namespace::Meta` `"array.catchup_needed:{array}"`.
163+
pub fn record_reject_retention_floor(&self, array: &str) -> Result<(), LiteError> {
164+
if let Ok(mut needed) = self.catchup_needed.lock() {
165+
needed.insert(array.to_owned());
166+
}
167+
// Persist flag (value = 1 byte sentinel).
168+
self.storage
169+
.put_sync(Namespace::Meta, &catchup_needed_key(array), &[1u8])
170+
}
171+
172+
/// Clear the catchup-needed flag for `array` after a successful catch-up.
173+
///
174+
/// Called after the snapshot stream has been fully applied.
175+
pub fn clear_catchup_needed(&self, array: &str) -> Result<(), LiteError> {
176+
if let Ok(mut needed) = self.catchup_needed.lock() {
177+
needed.remove(array);
178+
}
179+
// Remove the persisted flag.
180+
self.storage
181+
.delete_sync(Namespace::Meta, &catchup_needed_key(array))
182+
}
183+
184+
/// Return all arrays that are marked as needing catch-up.
185+
pub fn arrays_needing_catchup(&self) -> Vec<String> {
186+
self.catchup_needed
187+
.lock()
188+
.ok()
189+
.map(|s| s.iter().cloned().collect())
190+
.unwrap_or_default()
191+
}
113192
}
114193

115194
// ─── Tests ────────────────────────────────────────────────────────────────────

nodedb-lite/src/sync/array/inbound/apply.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! [`ApplyEngine`] trait so [`nodedb_array::sync::apply::apply_op`] can drive
33
//! local state from inbound wire messages.
44
5+
use std::collections::HashMap;
56
use std::sync::{Arc, Mutex};
67

78
use nodedb_array::error::ArrayError;
@@ -10,12 +11,16 @@ use nodedb_array::sync::hlc::Hlc;
1011
use nodedb_array::sync::op::ArrayOp;
1112
use nodedb_array::sync::op_log::OpLog;
1213
use nodedb_array::types::coord::value::CoordValue;
14+
use nodedb_types::Namespace;
1315

1416
use crate::engine::array::engine::ArrayEngineState;
1517
use crate::storage::engine::StorageEngineSync;
1618
use crate::sync::array::op_log_redb::RedbOpLog;
1719
use crate::sync::array::schema_registry::SchemaRegistry;
1820

21+
/// Key prefix for `last_applied_hlc` entries persisted under `Namespace::Meta`.
22+
const LAST_APPLIED_PREFIX: &str = "array.last_applied:";
23+
1924
/// Adapts NodeDB-Lite's array engine state to the [`ApplyEngine`] trait.
2025
///
2126
/// All fields are `Arc`-wrapped so their interior mutability can satisfy the
@@ -35,6 +40,9 @@ pub struct LiteApplyEngine<S: StorageEngineSync> {
3540
pub(super) array_state: Arc<Mutex<ArrayEngineState>>,
3641
pub(super) schemas: Arc<SchemaRegistry<S>>,
3742
pub(super) op_log: Arc<RedbOpLog<S>>,
43+
/// In-memory cache of the last applied HLC per array.
44+
/// Persisted under `Namespace::Meta` `"array.last_applied:{name}"`.
45+
last_applied: Mutex<HashMap<String, Hlc>>,
3846
}
3947

4048
impl<S: StorageEngineSync> LiteApplyEngine<S> {
@@ -45,11 +53,69 @@ impl<S: StorageEngineSync> LiteApplyEngine<S> {
4553
schemas: Arc<SchemaRegistry<S>>,
4654
op_log: Arc<RedbOpLog<S>>,
4755
) -> Self {
56+
// Restore any persisted last-applied HLCs from storage on startup.
57+
let last_applied = Self::load_last_applied(&storage);
4858
Self {
4959
storage,
5060
array_state,
5161
schemas,
5262
op_log,
63+
last_applied: Mutex::new(last_applied),
64+
}
65+
}
66+
67+
fn load_last_applied(storage: &Arc<S>) -> HashMap<String, Hlc> {
68+
let prefix = LAST_APPLIED_PREFIX.as_bytes();
69+
let Ok(pairs) = storage.scan_range_sync(Namespace::Meta, prefix, usize::MAX) else {
70+
return HashMap::new();
71+
};
72+
let mut map = HashMap::new();
73+
for (key, value) in pairs {
74+
if !key.starts_with(prefix) {
75+
break;
76+
}
77+
let name_bytes = &key[prefix.len()..];
78+
let Ok(name) = std::str::from_utf8(name_bytes) else {
79+
continue;
80+
};
81+
if value.len() == 18 {
82+
let bytes: [u8; 18] = match value.try_into() {
83+
Ok(b) => b,
84+
Err(_) => continue,
85+
};
86+
map.insert(name.to_owned(), Hlc::from_bytes(&bytes));
87+
}
88+
}
89+
map
90+
}
91+
92+
/// Return the highest HLC applied for `array`, or `None` if no ops applied.
93+
pub fn last_applied_hlc(&self, array: &str) -> Option<Hlc> {
94+
self.last_applied.lock().ok()?.get(array).copied()
95+
}
96+
97+
/// Record that `hlc` has been successfully applied for `array`.
98+
///
99+
/// Advances the in-memory record and persists under `Namespace::Meta`.
100+
fn record_applied_hlc(&self, array: &str, hlc: Hlc) {
101+
let should_persist = {
102+
let mut map = match self.last_applied.lock() {
103+
Ok(m) => m,
104+
Err(_) => return,
105+
};
106+
let current = map.get(array).copied().unwrap_or(Hlc::ZERO);
107+
if hlc > current {
108+
map.insert(array.to_owned(), hlc);
109+
true
110+
} else {
111+
false
112+
}
113+
};
114+
if should_persist {
115+
let key = format!("{LAST_APPLIED_PREFIX}{array}").into_bytes();
116+
let _ = self
117+
.storage
118+
.put_sync(Namespace::Meta, &key, &hlc.to_bytes());
53119
}
54120
}
55121
}
@@ -92,7 +158,9 @@ impl<S: StorageEngineSync> ApplyEngine for &LiteApplyEngine<S> {
92158
})?;
93159
// Record in op-log so that subsequent `already_seen` returns true.
94160
// `append` is idempotent on duplicate (array, hlc) pairs.
95-
self.op_log.append(op)
161+
self.op_log.append(op)?;
162+
self.record_applied_hlc(&op.header.array, op.header.hlc);
163+
Ok(())
96164
}
97165

98166
fn apply_delete(&mut self, op: &ArrayOp) -> nodedb_array::error::ArrayResult<()> {
@@ -105,7 +173,9 @@ impl<S: StorageEngineSync> ApplyEngine for &LiteApplyEngine<S> {
105173
.map_err(|e| ArrayError::SegmentCorruption {
106174
detail: format!("apply_delete: {e}"),
107175
})?;
108-
self.op_log.append(op)
176+
self.op_log.append(op)?;
177+
self.record_applied_hlc(&op.header.array, op.header.hlc);
178+
Ok(())
109179
}
110180

111181
fn apply_erase(&mut self, op: &ArrayOp) -> nodedb_array::error::ArrayResult<()> {
@@ -123,7 +193,9 @@ impl<S: StorageEngineSync> ApplyEngine for &LiteApplyEngine<S> {
123193
.map_err(|e| ArrayError::SegmentCorruption {
124194
detail: format!("apply_erase: {e}"),
125195
})?;
126-
self.op_log.append(op)
196+
self.op_log.append(op)?;
197+
self.record_applied_hlc(&op.header.array, op.header.hlc);
198+
Ok(())
127199
}
128200

129201
/// No-op: the Lite array engine reads from the durable memtable + segments

0 commit comments

Comments
 (0)