Skip to content

Commit ee750a8

Browse files
committed
feat(sync): replace in-memory outbound queues with durable storage-backed queues
Rewrite all five outbound queues (columnar, vector, FTS, spatial, timeseries) to persist every entry in their respective storage namespace before returning from `enqueue`. Entries survive process restarts and disconnects; the sync transport re-drains them on reconnect while Origin deduplicates via its idempotent-producer gate. Key changes: - Add `DurableOutboundQueue<S>`: generic storage-backed bounded queue with configurable cap. Returns `LiteError::Backpressure` at capacity. - Add `StreamSeqTracker<S>`: per-stream monotonic sequence frontier persisted in `Namespace::Meta`. Tracks `last_assigned` and `last_acked` per stream so frame numbering resumes after restart. - Add `constants::PUSH_DRAIN_LIMIT` (256) to bound each push cycle. - Add `reconcile_outbound_enqueue` helper for uniform backpressure error logging at enqueue sites. - FTS and spatial queues gain a staging buffer that is flushed to durable storage by the periodic flush path, keeping synchronous write paths free of async storage I/O. - All outbound queue types are now generic over `S: StorageEngine`.
1 parent 5aa0448 commit ee750a8

11 files changed

Lines changed: 2300 additions & 475 deletions

File tree

nodedb-lite/src/sync/constants.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Sync-layer constants shared across push and drain paths.
2+
3+
/// Maximum number of entries drained from a durable outbound queue per sync
4+
/// push cycle. Keeps each push loop iteration bounded in time and memory.
5+
pub const PUSH_DRAIN_LIMIT: usize = 256;

nodedb-lite/src/sync/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,26 @@ pub mod array;
22
pub mod client;
33
pub mod clock;
44
pub mod compensation;
5+
pub mod constants;
56
pub mod flow_control;
67
pub mod outbound;
78
pub mod shapes;
9+
pub mod stream_seq;
810
pub mod transport;
911

12+
pub use constants::PUSH_DRAIN_LIMIT;
13+
1014
pub use array::KvOpLogStore;
1115
pub use client::{SyncClient, SyncConfig, SyncState};
1216
pub use clock::VectorClock;
1317
pub use compensation::{CompensationEvent, CompensationHandler, CompensationRegistry};
1418
pub use flow_control::{FlowControlConfig, FlowController, SyncMetrics, SyncMetricsSnapshot};
19+
pub(crate) use outbound::reconcile_outbound_enqueue;
1520
pub use outbound::{
16-
ColumnarOutbound, FtsOutbound, PendingColumnarBatch, PendingFtsDelete, PendingFtsIndex,
17-
PendingSpatialDelete, PendingSpatialInsert, PendingTimeseriesBatch, PendingVectorDelete,
18-
PendingVectorInsert, SpatialOutbound, TimeseriesOutbound, VectorOutbound,
21+
ColumnarOutbound, DurableOutboundQueue, FtsOutbound, PendingColumnarBatch, PendingFtsDelete,
22+
PendingFtsIndex, PendingSpatialDelete, PendingSpatialInsert, PendingTimeseriesBatch,
23+
PendingVectorDelete, PendingVectorInsert, SpatialOutbound, TimeseriesOutbound, VectorOutbound,
1924
};
2025
pub use shapes::ShapeManager;
26+
pub use stream_seq::StreamSeqTracker;
2127
pub use transport::{SyncDelegate, run_sync_loop};
Lines changed: 204 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,256 @@
11
//! Columnar insert outbound queue for Lite sync.
22
//!
3-
//! When `ColumnarEngine::insert` is called on Lite, it enqueues rows here.
3+
//! When a columnar row is inserted on Lite, it is durably enqueued here.
44
//! The sync transport drains this queue and sends `ColumnarInsert` wire
5-
//! frames to Origin. Each batch gets a monotonic `batch_id` for ACK
6-
//! correlation.
5+
//! frames to Origin. Each drained entry carries a monotonic durable key used
6+
//! to delete it from storage once Origin acknowledges receipt.
77
//!
8-
//! The queue is in-memory only (no durable persistence). If Lite restarts
9-
//! before a batch is ACKed, the rows are already in the local `ColumnarEngine`
10-
//! segments; full catch-up replay is future work. PREVIEW targets
11-
//! live-session replication (device never goes offline between insert and
12-
//! sync).
8+
//! # Durability
9+
//!
10+
//! The queue is backed by [`DurableOutboundQueue`], which persists every
11+
//! entry in [`Namespace::ColumnarPending`] before returning from `enqueue`.
12+
//! Entries survive process restarts; a crash between send and ack causes
13+
//! at-most-one retry (at-least-once delivery to Origin).
14+
//!
15+
//! # Backpressure
16+
//!
17+
//! When the queue reaches its cap, `enqueue` returns
18+
//! [`LiteError::Backpressure`], propagating to the caller so writes pause
19+
//! until the sync transport drains the backlog.
20+
21+
use std::collections::HashMap;
22+
use std::sync::Arc;
23+
use std::sync::atomic::{AtomicU64, Ordering};
1324

25+
use nodedb_types::Namespace;
1426
use nodedb_types::value::Value;
27+
use tokio::sync::Mutex;
1528

16-
use super::queue::{BatchIdGen, PendingQueue};
29+
use super::durable_queue::DurableOutboundQueue;
30+
use crate::error::LiteError;
31+
use crate::storage::engine::StorageEngine;
1732

1833
/// A single pending batch of columnar rows awaiting sync to Origin.
19-
#[derive(Debug, Clone)]
34+
#[derive(
35+
Debug,
36+
Clone,
37+
serde::Serialize,
38+
serde::Deserialize,
39+
zerompk::ToMessagePack,
40+
zerompk::FromMessagePack,
41+
)]
2042
pub struct PendingColumnarBatch {
21-
/// Monotonic batch ID (per-collection, Lite-assigned).
43+
/// Monotonic batch ID (per-collection, Lite-assigned) for ACK correlation.
2244
pub batch_id: u64,
2345
/// Collection name.
2446
pub collection: String,
2547
/// Rows in schema column order, one `Vec<Value>` per row.
2648
pub rows: Vec<Vec<Value>>,
2749
/// MessagePack-serialized `ColumnarSchema` hint. May be empty.
2850
pub schema_bytes: Vec<u8>,
51+
/// Stable idempotent-producer seq for this entry. 0 = not yet assigned;
52+
/// assigned at first drain and persisted so re-sends after reconnect reuse
53+
/// the same seq (Origin dedups instead of double-applying).
54+
#[serde(default)]
55+
pub seq: u64,
2956
}
3057

31-
/// Thread-safe outbound queue for columnar inserts.
58+
/// Durable outbound queue for columnar inserts.
3259
///
33-
/// Held by `NodeDbLite` and shared with `ColumnarEngine` via `Arc`.
34-
#[derive(Debug, Default)]
35-
pub struct ColumnarOutbound {
36-
queue: PendingQueue<PendingColumnarBatch>,
37-
ids: BatchIdGen,
60+
/// Held as `Arc<ColumnarOutbound<S>>` by `NodeDbLite` and shared with
61+
/// `ColumnarEngine` via `Arc`. The inner storage is accessed only for
62+
/// `enqueue` (from the sync insert path) and `drain_batch`/`ack_keys`
63+
/// (from the async sync transport path).
64+
pub struct ColumnarOutbound<S: StorageEngine> {
65+
queue: DurableOutboundQueue<S>,
66+
ids: AtomicU64,
67+
/// batch_id → durable_key for entries that have been sent but not yet
68+
/// acked by Origin. Cleared on reconnect so entries are re-drained.
69+
in_flight: Mutex<HashMap<u64, Vec<u8>>>,
3870
}
3971

40-
impl ColumnarOutbound {
41-
pub const fn new() -> Self {
42-
Self {
43-
queue: PendingQueue::new(),
44-
ids: BatchIdGen::new(),
45-
}
72+
impl<S: StorageEngine> ColumnarOutbound<S> {
73+
/// Open the durable queue backed by [`Namespace::ColumnarPending`].
74+
pub async fn open(storage: Arc<S>) -> Result<Self, LiteError> {
75+
Self::open_with_cap(storage, DurableOutboundQueue::<S>::DEFAULT_CAP).await
76+
}
77+
78+
/// Open with a custom cap.
79+
pub async fn open_with_cap(storage: Arc<S>, cap: usize) -> Result<Self, LiteError> {
80+
let queue =
81+
DurableOutboundQueue::open_with_cap(storage, Namespace::ColumnarPending, cap).await?;
82+
Ok(Self {
83+
queue,
84+
ids: AtomicU64::new(1),
85+
in_flight: Mutex::new(HashMap::new()),
86+
})
4687
}
4788

48-
/// Enqueue a single row for a collection.
89+
/// Durably enqueue a single row for a collection.
4990
///
50-
/// Rows for the same collection are coalesced into a single batch if a
51-
/// pending batch for that collection already exists; otherwise a new
52-
/// batch is created with a fresh `batch_id`.
53-
pub fn enqueue_row(&self, collection: &str, row: Vec<Value>, schema_bytes: Vec<u8>) {
54-
// `with_first_mut` consumes `row` only on the matched path, so we use
55-
// an `Option` shuttle to recover ownership when no open batch exists.
56-
let mut row_slot = Some(row);
57-
let appended = self.queue.with_first_mut(
58-
|b| b.collection == collection,
59-
|b| {
60-
if let Some(r) = row_slot.take() {
61-
b.rows.push(r);
62-
}
63-
},
64-
);
65-
if appended.is_some() {
66-
return;
67-
}
68-
let row = row_slot.expect("row preserved when no open batch matched");
69-
self.queue.push(PendingColumnarBatch {
70-
batch_id: self.ids.next(),
91+
/// Rows for the same collection are **not** coalesced here — each call
92+
/// produces one durable entry. The sync transport batches by collection
93+
/// when building wire frames.
94+
///
95+
/// Returns [`LiteError::Backpressure`] when the queue is at cap.
96+
pub async fn enqueue_row(
97+
&self,
98+
collection: &str,
99+
row: Vec<Value>,
100+
schema_bytes: Vec<u8>,
101+
) -> Result<(), LiteError> {
102+
let batch_id = self.ids.fetch_add(1, Ordering::Relaxed);
103+
let batch = PendingColumnarBatch {
104+
batch_id,
71105
collection: collection.to_string(),
72106
rows: vec![row],
73107
schema_bytes,
74-
});
108+
seq: 0,
109+
};
110+
let payload = zerompk::to_msgpack_vec(&batch).map_err(|e| LiteError::Serialization {
111+
detail: format!("columnar outbound encode: {e}"),
112+
})?;
113+
self.queue.enqueue(&payload).await
114+
}
115+
116+
/// Drain up to `limit` pending batches in FIFO order, skipping any entries
117+
/// currently in-flight (sent but not yet acked by Origin).
118+
///
119+
/// Returns `(durable_key, batch)` pairs. On send success, call
120+
/// [`mark_in_flight`] with the batch_id and key. The durable entry is
121+
/// deleted only when Origin's ack arrives via [`ack_in_flight`].
122+
///
123+
/// Does **not** remove entries from storage.
124+
pub async fn drain_batch(
125+
&self,
126+
limit: usize,
127+
) -> Result<Vec<(Vec<u8>, PendingColumnarBatch)>, LiteError> {
128+
let in_flight = self.in_flight.lock().await;
129+
let pairs = self.queue.drain_batch(limit).await?;
130+
let mut out = Vec::with_capacity(pairs.len());
131+
for (key, payload) in pairs {
132+
if in_flight.values().any(|k| k == &key) {
133+
continue;
134+
}
135+
let batch: PendingColumnarBatch =
136+
zerompk::from_msgpack(&payload).map_err(|e| LiteError::Serialization {
137+
detail: format!("columnar outbound decode: {e}"),
138+
})?;
139+
out.push((key, batch));
140+
}
141+
Ok(out)
75142
}
76143

77-
/// Drain all pending batches for sending.
78-
pub fn drain_pending(&self) -> Vec<PendingColumnarBatch> {
79-
self.queue.drain()
144+
/// Record that a batch has been sent to Origin and is awaiting its ack.
145+
///
146+
/// The durable entry is kept in storage until [`ack_in_flight`] is called.
147+
pub async fn mark_in_flight(&self, batch_id: u64, durable_key: Vec<u8>) {
148+
self.in_flight.lock().await.insert(batch_id, durable_key);
80149
}
81150

82-
/// Remove the batch with the given `batch_id` (ACK path; no-op if absent).
83-
pub fn acknowledge_batch(&self, batch_id: u64) {
84-
self.queue.retain(|b| b.batch_id != batch_id);
151+
/// Remove the in-flight record for `batch_id` and return its durable key.
152+
///
153+
/// Returns `Some(key)` if the entry was in-flight; `None` if already acked
154+
/// or not tracked (e.g. the entry was un-encodable and dropped at send time).
155+
pub async fn ack_in_flight(&self, batch_id: u64) -> Option<Vec<u8>> {
156+
self.in_flight.lock().await.remove(&batch_id)
85157
}
86158

87-
/// Re-queue a rejected batch at the head for retry on the next drain.
88-
pub fn requeue_batch(&self, batch: PendingColumnarBatch) {
89-
self.queue.requeue(batch);
159+
/// Clear all in-flight records on reconnect.
160+
///
161+
/// The durable entries are still in storage and will be re-drained on the
162+
/// next push tick. Origin's idempotent gate deduplicates re-sent batches.
163+
pub async fn clear_in_flight(&self) {
164+
self.in_flight.lock().await.clear();
90165
}
91166

92-
/// Number of pending batches (diagnostics).
93-
pub fn pending_count(&self) -> usize {
94-
self.queue.len()
167+
/// Delete the durable entries identified by `keys` (Origin ack path).
168+
pub async fn ack_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
169+
self.queue.ack_keys(keys).await
170+
}
171+
172+
/// Update the durable payload for `key` with the new seq stamped into `batch`.
173+
pub async fn update_entry(
174+
&self,
175+
key: &[u8],
176+
batch: &PendingColumnarBatch,
177+
) -> Result<(), LiteError> {
178+
let payload = zerompk::to_msgpack_vec(batch).map_err(|e| LiteError::Serialization {
179+
detail: format!("columnar outbound update encode: {e}"),
180+
})?;
181+
self.queue.update_entry(key, &payload).await
182+
}
183+
184+
/// Number of pending entries in durable storage.
185+
pub async fn len(&self) -> Result<u64, LiteError> {
186+
self.queue.len().await
187+
}
188+
189+
/// Returns `true` if no pending entries remain.
190+
pub async fn is_empty(&self) -> Result<bool, LiteError> {
191+
self.queue.is_empty().await
95192
}
96193
}
97194

98195
#[cfg(test)]
99196
mod tests {
100197
use super::*;
198+
use crate::storage::pagedb_storage::PagedbStorageMem;
101199

102-
#[test]
103-
fn enqueue_and_drain() {
104-
let q = ColumnarOutbound::new();
105-
q.enqueue_row("metrics", vec![Value::Integer(1)], Vec::new());
106-
q.enqueue_row("metrics", vec![Value::Integer(2)], Vec::new());
107-
108-
let batches = q.drain_pending();
109-
assert_eq!(batches.len(), 1);
110-
assert_eq!(batches[0].collection, "metrics");
111-
assert_eq!(batches[0].rows.len(), 2);
112-
assert!(q.drain_pending().is_empty());
200+
async fn make_queue() -> ColumnarOutbound<PagedbStorageMem> {
201+
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
202+
ColumnarOutbound::open(storage).await.unwrap()
113203
}
114204

115-
#[test]
116-
fn separate_collections_separate_batches() {
117-
let q = ColumnarOutbound::new();
118-
q.enqueue_row("a", vec![Value::Integer(1)], Vec::new());
119-
q.enqueue_row("b", vec![Value::Integer(2)], Vec::new());
205+
#[tokio::test]
206+
async fn enqueue_and_drain() {
207+
let q = make_queue().await;
208+
q.enqueue_row("metrics", vec![Value::Integer(1)], Vec::new())
209+
.await
210+
.unwrap();
211+
q.enqueue_row("metrics", vec![Value::Integer(2)], Vec::new())
212+
.await
213+
.unwrap();
120214

121-
let batches = q.drain_pending();
122-
assert_eq!(batches.len(), 2);
215+
let pairs = q.drain_batch(usize::MAX).await.unwrap();
216+
assert_eq!(pairs.len(), 2);
217+
assert_eq!(pairs[0].1.collection, "metrics");
218+
assert_eq!(pairs[0].1.rows[0][0], Value::Integer(1));
219+
assert_eq!(pairs[1].1.rows[0][0], Value::Integer(2));
123220
}
124221

125-
#[test]
126-
fn acknowledge_removes_batch() {
127-
let q = ColumnarOutbound::new();
128-
q.enqueue_row("m", vec![Value::Integer(1)], Vec::new());
129-
let batches = q.drain_pending();
130-
let id = batches[0].batch_id;
131-
q.acknowledge_batch(id);
132-
assert!(q.drain_pending().is_empty());
133-
}
222+
#[tokio::test]
223+
async fn ack_keys_removes_entries() {
224+
let q = make_queue().await;
225+
q.enqueue_row("m", vec![Value::Integer(1)], Vec::new())
226+
.await
227+
.unwrap();
228+
q.enqueue_row("m", vec![Value::Integer(2)], Vec::new())
229+
.await
230+
.unwrap();
231+
232+
let pairs = q.drain_batch(1).await.unwrap();
233+
assert_eq!(pairs.len(), 1);
234+
let keys: Vec<Vec<u8>> = pairs.into_iter().map(|(k, _)| k).collect();
235+
q.ack_keys(&keys).await.unwrap();
134236

135-
#[test]
136-
fn requeue_retries_on_next_drain() {
137-
let q = ColumnarOutbound::new();
138-
q.enqueue_row("m", vec![Value::Integer(1)], Vec::new());
139-
let batches = q.drain_pending();
140-
q.requeue_batch(batches.into_iter().next().unwrap());
237+
assert_eq!(q.len().await.unwrap(), 1);
238+
}
141239

142-
let retried = q.drain_pending();
143-
assert_eq!(retried.len(), 1);
144-
assert_eq!(retried[0].rows.len(), 1);
240+
#[tokio::test]
241+
async fn cap_returns_backpressure() {
242+
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
243+
let q = ColumnarOutbound::open_with_cap(storage, 2).await.unwrap();
244+
q.enqueue_row("m", vec![Value::Integer(1)], Vec::new())
245+
.await
246+
.unwrap();
247+
q.enqueue_row("m", vec![Value::Integer(2)], Vec::new())
248+
.await
249+
.unwrap();
250+
let err = q
251+
.enqueue_row("m", vec![Value::Integer(3)], Vec::new())
252+
.await
253+
.unwrap_err();
254+
assert!(matches!(err, LiteError::Backpressure { .. }));
145255
}
146256
}

0 commit comments

Comments
 (0)