use std::sync::Arc;
use nodedb_array::sync::hlc::Hlc;
use nodedb_array::sync::op::ArrayOp;
use nodedb_array::sync::op_codec;
use nodedb_types::Namespace;
use crate::error::LiteError;
use crate::storage::engine::{StorageEngine, WriteOp};
#[derive(zerompk::ToMessagePack, zerompk::FromMessagePack)]
struct StoredOp {
seq: u64,
op: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PendingArrayOp {
pub array: String,
pub hlc: Hlc,
pub op_payload: Vec<u8>,
pub seq: u64,
}
fn decode_stored(value: &[u8]) -> Result<StoredOp, LiteError> {
zerompk::from_msgpack(value).map_err(|e| LiteError::Storage {
detail: format!("pending queue entry decode: {e}"),
})
}
fn encode_stored(stored: &StoredOp) -> Result<Vec<u8>, LiteError> {
zerompk::to_msgpack_vec(stored).map_err(|e| LiteError::Serialization {
detail: format!("pending queue entry encode: {e}"),
})
}
fn decode_payload(payload: &[u8]) -> Result<ArrayOp, LiteError> {
op_codec::decode_op(payload).map_err(|e| LiteError::Storage {
detail: format!("pending queue decode: {e}"),
})
}
fn key_after(hlc: Hlc) -> Vec<u8> {
let mut key = hlc.to_bytes().to_vec();
key.push(0);
key
}
pub struct PendingQueue<S: StorageEngine> {
storage: Arc<S>,
cap: usize,
}
impl<S: StorageEngine> PendingQueue<S> {
pub const DEFAULT_CAP: usize = 100_000;
pub fn new(storage: Arc<S>) -> Self {
Self {
storage,
cap: Self::DEFAULT_CAP,
}
}
pub fn with_cap(storage: Arc<S>, cap: usize) -> Self {
Self { storage, cap }
}
pub async fn enqueue(&self, op: &ArrayOp) -> Result<(), LiteError> {
let current = self.len().await?;
if current >= self.cap as u64 {
return Err(LiteError::Backpressure {
detail: format!(
"array pending queue full ({current} >= {}); local writes paused until Origin sync drains the queue",
self.cap
),
});
}
let key = op.header.hlc.to_bytes().to_vec();
let payload = op_codec::encode_op(op).map_err(|e| LiteError::Storage {
detail: format!("pending queue encode: {e}"),
})?;
let value = encode_stored(&StoredOp {
seq: 0,
op: payload,
})?;
self.storage.put(Namespace::ArrayDelta, &key, &value).await
}
pub async fn scan_after(
&self,
after: Option<Hlc>,
limit: usize,
) -> Result<Vec<PendingArrayOp>, LiteError> {
let start = after.map(key_after).unwrap_or_default();
let pairs = self
.storage
.scan_range(Namespace::ArrayDelta, &start, limit)
.await?;
let mut ops = Vec::with_capacity(pairs.len());
for (_key, value) in pairs {
let stored = decode_stored(&value)?;
let op = decode_payload(&stored.op)?;
ops.push(PendingArrayOp {
array: op.header.array,
hlc: op.header.hlc,
op_payload: stored.op,
seq: stored.seq,
});
}
Ok(ops)
}
pub async fn set_seq(&self, hlc: Hlc, seq: u64) -> Result<bool, LiteError> {
let key = hlc.to_bytes();
let Some(value) = self.storage.get(Namespace::ArrayDelta, &key).await? else {
return Ok(false);
};
let mut stored = decode_stored(&value)?;
stored.seq = seq;
self.storage
.put(Namespace::ArrayDelta, &key, &encode_stored(&stored)?)
.await?;
Ok(true)
}
pub async fn retire(&self, hlcs: &[Hlc]) -> Result<u64, LiteError> {
let mut deletes = Vec::with_capacity(hlcs.len());
for hlc in hlcs {
let key = hlc.to_bytes().to_vec();
if self
.storage
.get(Namespace::ArrayDelta, &key)
.await?
.is_some()
{
deletes.push(WriteOp::Delete {
ns: Namespace::ArrayDelta,
key,
});
}
}
let count = deletes.len() as u64;
if !deletes.is_empty() {
self.storage.batch_write(&deletes).await?;
}
Ok(count)
}
pub async fn drain_batch(&self, limit: usize) -> Result<Vec<ArrayOp>, LiteError> {
let pairs = self
.storage
.scan_range(Namespace::ArrayDelta, &[], limit)
.await?;
let mut ops = Vec::with_capacity(pairs.len());
for (_key, value) in pairs {
let stored = decode_stored(&value)?;
ops.push(decode_payload(&stored.op)?);
}
Ok(ops)
}
pub async fn ack_through(&self, ack_hlc: Hlc) -> Result<u64, LiteError> {
let ack_key = ack_hlc.to_bytes();
let pairs = self
.storage
.scan_range(Namespace::ArrayDelta, &[], usize::MAX)
.await?;
let to_delete: Vec<WriteOp> = pairs
.into_iter()
.take_while(|(key, _)| {
if key.len() != 18 {
return false;
}
key.as_slice() <= ack_key.as_slice()
})
.map(|(key, _)| WriteOp::Delete {
ns: Namespace::ArrayDelta,
key,
})
.collect();
let count = to_delete.len() as u64;
if !to_delete.is_empty() {
self.storage.batch_write(&to_delete).await?;
}
Ok(count)
}
pub async fn len(&self) -> Result<u64, LiteError> {
self.storage.count(Namespace::ArrayDelta).await
}
pub async fn is_empty(&self) -> Result<bool, LiteError> {
self.len().await.map(|n| n == 0)
}
pub async fn remove(&self, hlc: Hlc) -> Result<bool, LiteError> {
let key = hlc.to_bytes().to_vec();
let exists = self
.storage
.get(Namespace::ArrayDelta, &key)
.await?
.is_some();
if exists {
self.storage.delete(Namespace::ArrayDelta, &key).await?;
}
Ok(exists)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::pagedb_storage::{PagedbStorageDefault, PagedbStorageMem};
use nodedb_array::sync::op::{ArrayOpHeader, ArrayOpKind};
use nodedb_array::sync::replica_id::ReplicaId;
use nodedb_array::types::cell_value::value::CellValue;
use nodedb_array::types::coord::value::CoordValue;
fn replica() -> ReplicaId {
ReplicaId::new(1)
}
fn hlc(ms: u64) -> Hlc {
Hlc::new(ms, 0, replica()).unwrap()
}
fn make_op(ms: u64) -> ArrayOp {
ArrayOp {
header: ArrayOpHeader {
array: "arr".into(),
hlc: hlc(ms),
schema_hlc: hlc(1),
valid_from_ms: 0,
valid_until_ms: -1,
system_from_ms: ms as i64,
},
kind: ArrayOpKind::Put,
coord: vec![CoordValue::Int64(ms as i64)],
attrs: Some(vec![CellValue::Null]),
}
}
async fn make_queue() -> PendingQueue<PagedbStorageMem> {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
PendingQueue::new(storage)
}
#[tokio::test]
async fn enqueue_drain_fifo_order() {
let q = make_queue().await;
q.enqueue(&make_op(10)).await.unwrap();
q.enqueue(&make_op(20)).await.unwrap();
q.enqueue(&make_op(30)).await.unwrap();
let ops = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(ops.len(), 3);
let ms: Vec<u64> = ops.iter().map(|o| o.header.hlc.physical_ms).collect();
assert_eq!(ms, vec![10, 20, 30], "must be FIFO (ascending HLC) order");
}
#[tokio::test]
async fn enqueue_full_returns_backpressure() {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
let q = PendingQueue::with_cap(Arc::clone(&storage), 2);
q.enqueue(&make_op(1)).await.unwrap();
q.enqueue(&make_op(2)).await.unwrap();
let err = q.enqueue(&make_op(3)).await.unwrap_err();
assert!(
matches!(err, LiteError::Backpressure { .. }),
"expected Backpressure, got: {err:?}"
);
}
#[tokio::test]
async fn ack_through_removes_lower() {
let q = make_queue().await;
q.enqueue(&make_op(10)).await.unwrap();
q.enqueue(&make_op(20)).await.unwrap();
q.enqueue(&make_op(30)).await.unwrap();
let removed = q.ack_through(hlc(20)).await.unwrap();
assert_eq!(removed, 2);
assert_eq!(q.len().await.unwrap(), 1);
let remaining = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].header.hlc.physical_ms, 30);
}
#[tokio::test]
async fn remove_existing_returns_true() {
let q = make_queue().await;
q.enqueue(&make_op(10)).await.unwrap();
q.enqueue(&make_op(20)).await.unwrap();
let removed = q.remove(hlc(10)).await.unwrap();
assert!(removed, "remove of existing op must return true");
assert_eq!(q.len().await.unwrap(), 1);
let remaining = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(remaining[0].header.hlc.physical_ms, 20);
}
#[tokio::test]
async fn remove_missing_returns_false() {
let q = make_queue().await;
q.enqueue(&make_op(10)).await.unwrap();
let removed = q.remove(hlc(99)).await.unwrap();
assert!(!removed, "remove of absent op must return false");
assert_eq!(q.len().await.unwrap(), 1, "queue length must be unchanged");
}
#[tokio::test]
async fn survives_storage_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pending_test.pagedb");
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let q = PendingQueue::new(storage);
q.enqueue(&make_op(5)).await.unwrap();
q.enqueue(&make_op(15)).await.unwrap();
}
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let q = PendingQueue::new(storage);
assert_eq!(q.len().await.unwrap(), 2);
let ops = q.drain_batch(usize::MAX).await.unwrap();
let ms: Vec<u64> = ops.iter().map(|o| o.header.hlc.physical_ms).collect();
assert_eq!(ms, vec![5, 15]);
}
}
fn physical(ops: &[PendingArrayOp]) -> Vec<u64> {
ops.iter().map(|o| o.hlc.physical_ms).collect()
}
#[tokio::test]
async fn scan_after_starts_strictly_after_the_cursor() {
let q = make_queue().await;
for ms in [10, 20, 30] {
q.enqueue(&make_op(ms)).await.unwrap();
}
let all = q.scan_after(None, usize::MAX).await.unwrap();
assert_eq!(physical(&all), vec![10, 20, 30]);
assert!(all.iter().all(|o| o.seq == 0 && o.array == "arr"));
let decoded = op_codec::decode_op(&all[0].op_payload).unwrap();
assert_eq!(decoded.header.hlc, hlc(10), "payload is the encoded op");
let tail = q.scan_after(Some(hlc(10)), usize::MAX).await.unwrap();
assert_eq!(physical(&tail), vec![20, 30]);
let bounded = q.scan_after(None, 2).await.unwrap();
assert_eq!(physical(&bounded), vec![10, 20]);
}
#[tokio::test]
async fn set_seq_persists_the_seq_and_reports_absent_ops() {
let q = make_queue().await;
q.enqueue(&make_op(10)).await.unwrap();
assert!(q.set_seq(hlc(10), 7).await.unwrap());
assert!(!q.set_seq(hlc(99), 7).await.unwrap());
let ops = q.scan_after(None, usize::MAX).await.unwrap();
assert_eq!(ops[0].seq, 7);
let drained = q.drain_batch(usize::MAX).await.unwrap();
assert_eq!(
drained[0].header.hlc,
hlc(10),
"the op survives the seq write"
);
}
#[tokio::test]
async fn retire_removes_exactly_the_named_ops() {
let q = make_queue().await;
for ms in [10, 20, 30] {
q.enqueue(&make_op(ms)).await.unwrap();
}
let removed = q.retire(&[hlc(10), hlc(30), hlc(99)]).await.unwrap();
assert_eq!(removed, 2, "an absent op is skipped");
let remaining = q.scan_after(None, usize::MAX).await.unwrap();
assert_eq!(physical(&remaining), vec![20]);
}
}