use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use nodedb_types::Namespace;
use crate::error::LiteError;
use crate::storage::engine::{StorageEngine, WriteOp};
pub struct DurableOutboundQueue<S: StorageEngine> {
storage: Arc<S>,
namespace: Namespace,
cap: usize,
next_id: AtomicU64,
}
impl<S: StorageEngine> DurableOutboundQueue<S> {
pub const DEFAULT_CAP: usize = 100_000;
pub async fn open(storage: Arc<S>, namespace: Namespace) -> Result<Self, LiteError> {
Self::open_with_cap(storage, namespace, Self::DEFAULT_CAP).await
}
pub async fn open_with_cap(
storage: Arc<S>,
namespace: Namespace,
cap: usize,
) -> Result<Self, LiteError> {
let next_id = Self::recover_next_id(&storage, namespace).await?;
Ok(Self {
storage,
namespace,
cap,
next_id: AtomicU64::new(next_id),
})
}
async fn recover_next_id(storage: &Arc<S>, namespace: Namespace) -> Result<u64, LiteError> {
let pairs = storage.scan_range(namespace, &[], usize::MAX).await?;
let max = pairs.last().and_then(|(key, _)| {
if key.len() == 8 {
Some(u64::from_be_bytes(key[..8].try_into().ok()?))
} else {
None
}
});
max.map_or(Ok(1), |id| id.checked_add(1).ok_or_else(Self::exhausted))
}
pub async fn enqueue(&self, payload: &[u8]) -> Result<(), LiteError> {
self.ensure_room(1).await?;
let key = self.reserve_key()?;
self.storage.put(self.namespace, &key, payload).await
}
pub(crate) fn reserve_key(&self) -> Result<[u8; 8], LiteError> {
super::queue::take_next(&self.next_id)
.map(u64::to_be_bytes)
.ok_or_else(Self::exhausted)
}
fn exhausted() -> LiteError {
LiteError::Backpressure { detail: "outbound key counter exhausted: drain and acknowledge pending entries before reopening the queue".into() }
}
pub(crate) async fn persist_reserved(
&self,
key: &[u8; 8],
payload: &[u8],
) -> Result<(), LiteError> {
if self.storage.get(self.namespace, key).await?.is_some() {
return Ok(());
}
self.ensure_room(1).await?;
self.storage.put(self.namespace, key, payload).await
}
pub async fn ensure_room(&self, entries: usize) -> Result<(), LiteError> {
let current = self.len().await?;
if current.saturating_add(entries as u64) > self.cap as u64 {
return Err(LiteError::Backpressure {
detail: format!(
"outbound pending queue full ({current} pending, {entries} more, cap {}); \
writes paused until Origin sync drains the queue",
self.cap
),
});
}
Ok(())
}
pub async fn drain_batch(&self, limit: usize) -> Result<Vec<(Vec<u8>, Vec<u8>)>, LiteError> {
self.storage.scan_range(self.namespace, &[], limit).await
}
pub async fn ack_keys(&self, keys: &[Vec<u8>]) -> Result<(), LiteError> {
if keys.is_empty() {
return Ok(());
}
let ops: Vec<WriteOp> = keys
.iter()
.map(|key| WriteOp::Delete {
ns: self.namespace,
key: key.clone(),
})
.collect();
self.storage.batch_write(&ops).await
}
pub async fn update_entry(&self, key: &[u8], payload: &[u8]) -> Result<(), LiteError> {
self.storage.put(self.namespace, key, payload).await
}
pub async fn len(&self) -> Result<u64, LiteError> {
self.storage.count(self.namespace).await
}
pub async fn is_empty(&self) -> Result<bool, LiteError> {
self.len().await.map(|n| n == 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::pagedb_storage::PagedbStorageMem;
use std::sync::atomic::Ordering;
async fn make_queue() -> DurableOutboundQueue<PagedbStorageMem> {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
DurableOutboundQueue::open(storage, Namespace::ColumnarPending)
.await
.unwrap()
}
#[tokio::test]
async fn enqueue_persists_and_drain_fifo() {
let q = make_queue().await;
q.enqueue(b"payload-a").await.unwrap();
q.enqueue(b"payload-b").await.unwrap();
q.enqueue(b"payload-c").await.unwrap();
let pairs = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(pairs.len(), 3);
let payloads: Vec<&[u8]> = pairs.iter().map(|(_, v)| v.as_slice()).collect();
assert_eq!(payloads, vec![b"payload-a", b"payload-b", b"payload-c"]);
}
#[tokio::test]
async fn drain_batch_respects_limit() {
let q = make_queue().await;
q.enqueue(b"a").await.unwrap();
q.enqueue(b"b").await.unwrap();
q.enqueue(b"c").await.unwrap();
let pairs = q.drain_batch(2).await.unwrap();
assert_eq!(pairs.len(), 2);
}
#[tokio::test]
async fn ack_keys_deletes_entries() {
let q = make_queue().await;
q.enqueue(b"x").await.unwrap();
q.enqueue(b"y").await.unwrap();
q.enqueue(b"z").await.unwrap();
let pairs = q.drain_batch(2).await.unwrap();
assert_eq!(pairs.len(), 2);
let keys: Vec<Vec<u8>> = pairs.into_iter().map(|(k, _)| k).collect();
q.ack_keys(&keys).await.unwrap();
assert_eq!(q.len().await.unwrap(), 1);
let remaining = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(remaining[0].1, b"z");
}
#[tokio::test]
async fn len_and_is_empty() {
let q = make_queue().await;
assert!(q.is_empty().await.unwrap());
q.enqueue(b"data").await.unwrap();
assert_eq!(q.len().await.unwrap(), 1);
assert!(!q.is_empty().await.unwrap());
}
#[tokio::test]
async fn cap_returns_backpressure() {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
let q = DurableOutboundQueue::open_with_cap(storage, Namespace::ColumnarPending, 2)
.await
.unwrap();
q.enqueue(b"a").await.unwrap();
q.enqueue(b"b").await.unwrap();
let err = q.enqueue(b"c").await.unwrap_err();
assert!(
matches!(err, LiteError::Backpressure { .. }),
"expected Backpressure, got: {err:?}"
);
}
#[tokio::test]
async fn update_entry_persists_and_re_drain_reuses_payload() {
let q = make_queue().await;
q.enqueue(b"seq=0").await.unwrap();
let pairs = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(pairs.len(), 1);
let key = pairs[0].0.clone();
assert_eq!(pairs[0].1, b"seq=0");
q.update_entry(&key, b"seq=7").await.unwrap();
let re = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(re.len(), 1, "update_entry must not create a new entry");
assert_eq!(re[0].0, key, "key is stable across update");
assert_eq!(
re[0].1, b"seq=7",
"re-drain returns the persisted seq payload"
);
assert_eq!(q.len().await.unwrap(), 1);
}
#[tokio::test]
async fn survives_reload() {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
{
let q = DurableOutboundQueue::open(Arc::clone(&storage), Namespace::ColumnarPending)
.await
.unwrap();
q.enqueue(b"first").await.unwrap();
q.enqueue(b"second").await.unwrap();
}
let q = DurableOutboundQueue::open(Arc::clone(&storage), Namespace::ColumnarPending)
.await
.unwrap();
assert_eq!(q.len().await.unwrap(), 2);
q.enqueue(b"third").await.unwrap();
assert_eq!(q.len().await.unwrap(), 3);
let pairs = q.drain_batch(usize::MAX).await.unwrap();
let payloads: Vec<&[u8]> = pairs.iter().map(|(_, v)| v.as_slice()).collect();
assert_eq!(
payloads,
vec![
b"first".as_slice(),
b"second".as_slice(),
b"third".as_slice()
]
);
}
#[tokio::test]
async fn reserved_retry_preserves_payload_at_capacity() {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
let queue = DurableOutboundQueue::open_with_cap(storage, Namespace::FtsIndexPending, 1)
.await
.unwrap();
let key = queue.reserve_key().unwrap();
queue.persist_reserved(&key, b"seq=7").await.unwrap();
queue.persist_reserved(&key, b"seq=0").await.unwrap();
assert_eq!(
queue.drain_batch(10).await.unwrap(),
vec![(key.to_vec(), b"seq=7".to_vec())]
);
}
#[tokio::test]
async fn exhausted_counter_never_wraps_or_overwrites_existing_keys() {
let queue = make_queue().await;
queue.next_id.store(u64::MAX - 1, Ordering::Relaxed);
assert_eq!(queue.reserve_key().unwrap(), (u64::MAX - 1).to_be_bytes());
assert!(matches!(
queue.reserve_key(),
Err(LiteError::Backpressure { .. })
));
assert!(matches!(
queue.reserve_key(),
Err(LiteError::Backpressure { .. })
));
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
storage
.put(Namespace::ColumnarPending, &u64::MAX.to_be_bytes(), b"last")
.await
.unwrap();
assert!(matches!(
DurableOutboundQueue::open(storage, Namespace::ColumnarPending).await,
Err(LiteError::Backpressure { .. })
));
}
}