Skip to main content

nodedb_lite/sync/outbound/
durable_queue.rs

1//! Durable FIFO queue backed by a [`StorageEngine`] namespace.
2//!
3//! Used by the columnar and timeseries outbound sync paths to persist pending
4//! row batches across restarts. Keys are big-endian monotonic `u64` IDs
5//! (`[u8; 8]`), giving natural FIFO order because byte-comparable big-endian
6//! integers sort in ascending insertion order.
7//!
8//! # Backpressure
9//!
10//! When [`DurableOutboundQueue::len`] reaches the configured cap,
11//! [`DurableOutboundQueue::enqueue`] returns [`LiteError::Backpressure`]
12//! instead of writing to storage. RAM is bounded regardless of cap; the items
13//! themselves always live on disk.
14//!
15//! # Drain and acknowledge
16//!
17//! [`drain_batch`] reads up to `limit` entries in FIFO order **without
18//! removing them**. The caller sends the payloads to Origin, then calls
19//! [`ack_keys`] with the confirmed keys to delete them. Un-acked entries
20//! survive a crash and are re-drained on the next connect.
21
22use std::sync::Arc;
23use std::sync::atomic::AtomicU64;
24
25use nodedb_types::Namespace;
26
27use crate::error::LiteError;
28use crate::storage::engine::{StorageEngine, WriteOp};
29
30/// Durable FIFO outbound queue backed by a [`StorageEngine`] namespace.
31pub struct DurableOutboundQueue<S: StorageEngine> {
32    storage: Arc<S>,
33    namespace: Namespace,
34    cap: usize,
35    /// Monotonic counter for the next key to assign.
36    ///
37    /// Initialised at `open()` time from the maximum existing key so the
38    /// counter survives restarts and never regresses.
39    next_id: AtomicU64,
40}
41
42impl<S: StorageEngine> DurableOutboundQueue<S> {
43    /// Default maximum number of pending batches before backpressure kicks in.
44    pub const DEFAULT_CAP: usize = 100_000;
45
46    /// Open a queue over the given namespace, deriving the next-ID from the
47    /// highest key currently in storage (zero on an empty namespace).
48    pub async fn open(storage: Arc<S>, namespace: Namespace) -> Result<Self, LiteError> {
49        Self::open_with_cap(storage, namespace, Self::DEFAULT_CAP).await
50    }
51
52    /// Open with a custom cap (useful for tests with small limits).
53    pub async fn open_with_cap(
54        storage: Arc<S>,
55        namespace: Namespace,
56        cap: usize,
57    ) -> Result<Self, LiteError> {
58        // Derive next ID from the highest existing key so restarts are seamless.
59        let next_id = Self::recover_next_id(&storage, namespace).await?;
60        Ok(Self {
61            storage,
62            namespace,
63            cap,
64            next_id: AtomicU64::new(next_id),
65        })
66    }
67
68    /// Read the max key in the namespace and return `max + 1` (or 1 if empty).
69    async fn recover_next_id(storage: &Arc<S>, namespace: Namespace) -> Result<u64, LiteError> {
70        // scan_range with empty start and limit = usize::MAX reads all keys.
71        // We only need the last key so we use a single scan and take the tail.
72        // This is called once at startup, not on the hot path.
73        let pairs = storage.scan_range(namespace, &[], usize::MAX).await?;
74        let max = pairs.last().and_then(|(key, _)| {
75            if key.len() == 8 {
76                Some(u64::from_be_bytes(key[..8].try_into().ok()?))
77            } else {
78                None
79            }
80        });
81        max.map_or(Ok(1), |id| id.checked_add(1).ok_or_else(Self::exhausted))
82    }
83
84    /// Enqueue a pre-encoded payload.
85    ///
86    /// Returns [`LiteError::Backpressure`] when `len() >= cap`.
87    pub async fn enqueue(&self, payload: &[u8]) -> Result<(), LiteError> {
88        self.ensure_room(1).await?;
89        let key = self.reserve_key()?;
90        self.storage.put(self.namespace, &key, payload).await
91    }
92
93    /// Reserve a stable key without awaiting persistence.
94    pub(crate) fn reserve_key(&self) -> Result<[u8; 8], LiteError> {
95        super::queue::take_next(&self.next_id)
96            .map(u64::to_be_bytes)
97            .ok_or_else(Self::exhausted)
98    }
99
100    fn exhausted() -> LiteError {
101        LiteError::Backpressure { detail: "outbound key counter exhausted: drain and acknowledge pending entries before reopening the queue".into() }
102    }
103
104    /// Persist a reserved key, preserving any existing payload and assigned seq.
105    /// The caller exclusively owns this namespace and serializes reserved persistence.
106    /// Retry after ambiguous storage completion uses the same key.
107    pub(crate) async fn persist_reserved(
108        &self,
109        key: &[u8; 8],
110        payload: &[u8],
111    ) -> Result<(), LiteError> {
112        if self.storage.get(self.namespace, key).await?.is_some() {
113            return Ok(());
114        }
115        self.ensure_room(1).await?;
116        self.storage.put(self.namespace, key, payload).await
117    }
118
119    /// Check that `entries` more entries fit under the cap.
120    ///
121    /// Returns [`LiteError::Backpressure`] when `len() + entries > cap`. A
122    /// writer that enqueues after its local write calls this first, so a
123    /// full queue refuses the write before it changes local state.
124    pub async fn ensure_room(&self, entries: usize) -> Result<(), LiteError> {
125        let current = self.len().await?;
126        if current.saturating_add(entries as u64) > self.cap as u64 {
127            return Err(LiteError::Backpressure {
128                detail: format!(
129                    "outbound pending queue full ({current} pending, {entries} more, cap {}); \
130                     writes paused until Origin sync drains the queue",
131                    self.cap
132                ),
133            });
134        }
135        Ok(())
136    }
137
138    /// Return up to `limit` entries in FIFO order (lowest key first).
139    ///
140    /// Does **not** remove the returned entries. Call [`ack_keys`] after
141    /// Origin confirms delivery to remove them.
142    pub async fn drain_batch(&self, limit: usize) -> Result<Vec<(Vec<u8>, Vec<u8>)>, LiteError> {
143        self.storage.scan_range(self.namespace, &[], limit).await
144    }
145
146    /// Delete the entries identified by `keys` from storage.
147    ///
148    /// Called after Origin acknowledges the corresponding batches. Deleting
149    /// only confirmed keys means un-acked entries survive a crash and will be
150    /// re-sent on reconnect (at-least-once delivery).
151    pub async fn ack_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
152        if keys.is_empty() {
153            return Ok(());
154        }
155        let ops: Vec<WriteOp> = keys
156            .iter()
157            .map(|key| WriteOp::Delete {
158                ns: self.namespace,
159                key: key.clone(),
160            })
161            .collect();
162        self.storage.batch_write(&ops).await
163    }
164
165    /// Update the payload stored under an existing `key` in-place.
166    ///
167    /// Used by the push paths to persist an assigned stream seq back into a
168    /// durable entry before sending, so reconnects reuse the same seq.
169    pub async fn update_entry(&self, key: &[u8], payload: &[u8]) -> Result<(), LiteError> {
170        self.storage.put(self.namespace, key, payload).await
171    }
172
173    /// Total number of pending entries in storage.
174    pub async fn len(&self) -> Result<u64, LiteError> {
175        self.storage.count(self.namespace).await
176    }
177
178    /// Returns `true` if the queue contains no pending entries.
179    pub async fn is_empty(&self) -> Result<bool, LiteError> {
180        self.len().await.map(|n| n == 0)
181    }
182}
183
184// ─── Tests ────────────────────────────────────────────────────────────────────
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::storage::pagedb_storage::PagedbStorageMem;
190    use std::sync::atomic::Ordering;
191
192    async fn make_queue() -> DurableOutboundQueue<PagedbStorageMem> {
193        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
194        DurableOutboundQueue::open(storage, Namespace::ColumnarPending)
195            .await
196            .unwrap()
197    }
198
199    #[tokio::test]
200    async fn enqueue_persists_and_drain_fifo() {
201        let q = make_queue().await;
202        q.enqueue(b"payload-a").await.unwrap();
203        q.enqueue(b"payload-b").await.unwrap();
204        q.enqueue(b"payload-c").await.unwrap();
205
206        let pairs = q.drain_batch(usize::MAX).await.unwrap();
207        assert_eq!(pairs.len(), 3);
208        let payloads: Vec<&[u8]> = pairs.iter().map(|(_, v)| v.as_slice()).collect();
209        assert_eq!(payloads, vec![b"payload-a", b"payload-b", b"payload-c"]);
210    }
211
212    #[tokio::test]
213    async fn drain_batch_respects_limit() {
214        let q = make_queue().await;
215        q.enqueue(b"a").await.unwrap();
216        q.enqueue(b"b").await.unwrap();
217        q.enqueue(b"c").await.unwrap();
218
219        let pairs = q.drain_batch(2).await.unwrap();
220        assert_eq!(pairs.len(), 2);
221    }
222
223    #[tokio::test]
224    async fn ack_keys_deletes_entries() {
225        let q = make_queue().await;
226        q.enqueue(b"x").await.unwrap();
227        q.enqueue(b"y").await.unwrap();
228        q.enqueue(b"z").await.unwrap();
229
230        let pairs = q.drain_batch(2).await.unwrap();
231        assert_eq!(pairs.len(), 2);
232        let keys: Vec<Vec<u8>> = pairs.into_iter().map(|(k, _)| k).collect();
233        q.ack_keys(&keys).await.unwrap();
234
235        assert_eq!(q.len().await.unwrap(), 1);
236        let remaining = q.drain_batch(usize::MAX).await.unwrap();
237        assert_eq!(remaining[0].1, b"z");
238    }
239
240    #[tokio::test]
241    async fn len_and_is_empty() {
242        let q = make_queue().await;
243        assert!(q.is_empty().await.unwrap());
244        q.enqueue(b"data").await.unwrap();
245        assert_eq!(q.len().await.unwrap(), 1);
246        assert!(!q.is_empty().await.unwrap());
247    }
248
249    #[tokio::test]
250    async fn cap_returns_backpressure() {
251        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
252        let q = DurableOutboundQueue::open_with_cap(storage, Namespace::ColumnarPending, 2)
253            .await
254            .unwrap();
255
256        q.enqueue(b"a").await.unwrap();
257        q.enqueue(b"b").await.unwrap();
258        let err = q.enqueue(b"c").await.unwrap_err();
259        assert!(
260            matches!(err, LiteError::Backpressure { .. }),
261            "expected Backpressure, got: {err:?}"
262        );
263    }
264
265    #[tokio::test]
266    async fn update_entry_persists_and_re_drain_reuses_payload() {
267        // Assigned stream seq persists across repeated drains under the same durable key.
268        let q = make_queue().await;
269        q.enqueue(b"seq=0").await.unwrap();
270
271        let pairs = q.drain_batch(usize::MAX).await.unwrap();
272        assert_eq!(pairs.len(), 1);
273        let key = pairs[0].0.clone();
274        assert_eq!(pairs[0].1, b"seq=0");
275
276        // First drain assigns + persists the seq back into the entry.
277        q.update_entry(&key, b"seq=7").await.unwrap();
278
279        // Re-drain (e.g. after reconnect clears in-flight) reuses the persisted
280        // payload — same key, same (now-assigned) seq — and does NOT duplicate.
281        let re = q.drain_batch(usize::MAX).await.unwrap();
282        assert_eq!(re.len(), 1, "update_entry must not create a new entry");
283        assert_eq!(re[0].0, key, "key is stable across update");
284        assert_eq!(
285            re[0].1, b"seq=7",
286            "re-drain returns the persisted seq payload"
287        );
288        assert_eq!(q.len().await.unwrap(), 1);
289    }
290
291    #[tokio::test]
292    async fn survives_reload() {
293        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
294        {
295            let q = DurableOutboundQueue::open(Arc::clone(&storage), Namespace::ColumnarPending)
296                .await
297                .unwrap();
298            q.enqueue(b"first").await.unwrap();
299            q.enqueue(b"second").await.unwrap();
300        }
301        // Re-open over the same storage — counter resumes after max existing key.
302        let q = DurableOutboundQueue::open(Arc::clone(&storage), Namespace::ColumnarPending)
303            .await
304            .unwrap();
305        assert_eq!(q.len().await.unwrap(), 2);
306        // New entry must not overwrite existing keys.
307        q.enqueue(b"third").await.unwrap();
308        assert_eq!(q.len().await.unwrap(), 3);
309
310        let pairs = q.drain_batch(usize::MAX).await.unwrap();
311        let payloads: Vec<&[u8]> = pairs.iter().map(|(_, v)| v.as_slice()).collect();
312        assert_eq!(
313            payloads,
314            vec![
315                b"first".as_slice(),
316                b"second".as_slice(),
317                b"third".as_slice()
318            ]
319        );
320    }
321    #[tokio::test]
322    async fn reserved_retry_preserves_payload_at_capacity() {
323        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
324        let queue = DurableOutboundQueue::open_with_cap(storage, Namespace::FtsIndexPending, 1)
325            .await
326            .unwrap();
327        let key = queue.reserve_key().unwrap();
328        queue.persist_reserved(&key, b"seq=7").await.unwrap();
329        queue.persist_reserved(&key, b"seq=0").await.unwrap();
330        assert_eq!(
331            queue.drain_batch(10).await.unwrap(),
332            vec![(key.to_vec(), b"seq=7".to_vec())]
333        );
334    }
335
336    #[tokio::test]
337    async fn exhausted_counter_never_wraps_or_overwrites_existing_keys() {
338        let queue = make_queue().await;
339        queue.next_id.store(u64::MAX - 1, Ordering::Relaxed);
340        assert_eq!(queue.reserve_key().unwrap(), (u64::MAX - 1).to_be_bytes());
341        assert!(matches!(
342            queue.reserve_key(),
343            Err(LiteError::Backpressure { .. })
344        ));
345        assert!(matches!(
346            queue.reserve_key(),
347            Err(LiteError::Backpressure { .. })
348        ));
349        let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
350        storage
351            .put(Namespace::ColumnarPending, &u64::MAX.to_be_bytes(), b"last")
352            .await
353            .unwrap();
354        assert!(matches!(
355            DurableOutboundQueue::open(storage, Namespace::ColumnarPending).await,
356            Err(LiteError::Backpressure { .. })
357        ));
358    }
359}