use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use nodedb_types::Namespace;
use crate::error::LiteError;
use crate::storage::engine::StorageEngine;
const STREAM_SEQ_PREFIX: &str = "sync.stream_seq:";
fn stream_seq_key(stream_id: u64) -> Vec<u8> {
format!("{STREAM_SEQ_PREFIX}{stream_id:016x}").into_bytes()
}
#[derive(Debug, Clone, Copy, Default)]
struct Frontier {
last_assigned: u64,
last_acked: u64,
}
pub struct StreamSeqTracker<S: StorageEngine> {
storage: Arc<S>,
state: Mutex<HashMap<u64, Frontier>>,
}
impl<S: StorageEngine> StreamSeqTracker<S> {
pub async fn load(storage: Arc<S>) -> Result<Self, LiteError> {
let prefix = STREAM_SEQ_PREFIX.as_bytes();
let pairs = storage
.scan_range(Namespace::Meta, prefix, usize::MAX)
.await?;
let mut state = HashMap::new();
for (key, value) in pairs {
if !key.starts_with(prefix) {
break;
}
let id_bytes = &key[prefix.len()..];
let id_str = std::str::from_utf8(id_bytes).map_err(|e| LiteError::Storage {
detail: format!("stream_seq_tracker: non-UTF8 stream_id in storage: {e}"),
})?;
let stream_id = u64::from_str_radix(id_str, 16).map_err(|e| LiteError::Storage {
detail: format!("stream_seq_tracker: invalid hex stream_id '{id_str}': {e}"),
})?;
let arr: [u8; 16] = value.try_into().map_err(|v: Vec<u8>| LiteError::Storage {
detail: format!(
"stream_seq_tracker: frontier wrong length ({}) for stream {stream_id:016x}",
v.len()
),
})?;
let last_assigned = u64::from_be_bytes(arr[..8].try_into().unwrap_or([0; 8]));
let last_acked = u64::from_be_bytes(arr[8..].try_into().unwrap_or([0; 8]));
state.insert(
stream_id,
Frontier {
last_assigned,
last_acked,
},
);
}
Ok(Self {
storage,
state: Mutex::new(state),
})
}
pub async fn next_seq(&self, stream_id: u64) -> Result<u64, LiteError> {
let (next, encoded) = {
let mut state = self.state.lock().map_err(|_| LiteError::LockPoisoned)?;
let entry = state.entry(stream_id).or_default();
let next = entry.last_assigned + 1;
entry.last_assigned = next;
let encoded = encode_frontier(*entry);
(next, encoded)
};
self.storage
.put(Namespace::Meta, &stream_seq_key(stream_id), &encoded)
.await?;
Ok(next)
}
pub async fn record_ack(&self, stream_id: u64, applied_seq: u64) -> Result<(), LiteError> {
let (updated, encoded) = {
let mut state = self.state.lock().map_err(|_| LiteError::LockPoisoned)?;
let entry = state.entry(stream_id).or_default();
if applied_seq <= entry.last_acked {
return Ok(());
}
entry.last_acked = applied_seq;
entry.last_assigned = entry.last_assigned.max(applied_seq);
let encoded = encode_frontier(*entry);
(true, encoded)
};
if updated {
self.storage
.put(Namespace::Meta, &stream_seq_key(stream_id), &encoded)
.await?;
}
Ok(())
}
pub async fn last_acked(&self, stream_id: u64) -> u64 {
self.state
.lock()
.ok()
.and_then(|s| s.get(&stream_id).map(|f| f.last_acked))
.unwrap_or(0)
}
pub async fn last_assigned(&self, stream_id: u64) -> u64 {
self.state
.lock()
.ok()
.and_then(|s| s.get(&stream_id).map(|f| f.last_assigned))
.unwrap_or(0)
}
}
fn encode_frontier(f: Frontier) -> Vec<u8> {
let mut buf = Vec::with_capacity(16);
buf.extend_from_slice(&f.last_assigned.to_be_bytes());
buf.extend_from_slice(&f.last_acked.to_be_bytes());
buf
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::pagedb_storage::{PagedbStorageDefault, PagedbStorageMem};
async fn make_tracker() -> StreamSeqTracker<PagedbStorageMem> {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
StreamSeqTracker::load(storage).await.unwrap()
}
#[tokio::test]
async fn next_seq_starts_at_one() {
let tracker = make_tracker().await;
let seq = tracker.next_seq(1).await.unwrap();
assert_eq!(seq, 1);
}
#[tokio::test]
async fn next_seq_is_monotonic() {
let tracker = make_tracker().await;
let s1 = tracker.next_seq(42).await.unwrap();
let s2 = tracker.next_seq(42).await.unwrap();
let s3 = tracker.next_seq(42).await.unwrap();
assert_eq!(s1, 1);
assert_eq!(s2, 2);
assert_eq!(s3, 3);
}
#[tokio::test]
async fn next_seq_survives_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stream_seq_test.pagedb");
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = StreamSeqTracker::load(Arc::clone(&storage)).await.unwrap();
assert_eq!(tracker.next_seq(7).await.unwrap(), 1);
assert_eq!(tracker.next_seq(7).await.unwrap(), 2);
}
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = StreamSeqTracker::load(storage).await.unwrap();
assert_eq!(
tracker.next_seq(7).await.unwrap(),
3,
"seq must resume from durable last_assigned on reload"
);
}
}
#[tokio::test]
async fn record_ack_advances_and_survives_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stream_seq_ack_test.pagedb");
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = StreamSeqTracker::load(Arc::clone(&storage)).await.unwrap();
tracker.next_seq(5).await.unwrap(); tracker.next_seq(5).await.unwrap(); tracker.record_ack(5, 2).await.unwrap();
assert_eq!(tracker.last_acked(5).await, 2);
}
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = StreamSeqTracker::load(storage).await.unwrap();
assert_eq!(
tracker.last_acked(5).await,
2,
"last_acked must survive storage restart"
);
}
}
#[tokio::test]
async fn record_ack_is_noop_if_not_advancing() {
let tracker = make_tracker().await;
tracker.next_seq(3).await.unwrap();
tracker.record_ack(3, 1).await.unwrap();
assert_eq!(tracker.last_acked(3).await, 1);
tracker.record_ack(3, 1).await.unwrap();
tracker.record_ack(3, 0).await.unwrap();
assert_eq!(tracker.last_acked(3).await, 1);
}
#[tokio::test]
async fn two_stream_ids_are_independent() {
let tracker = make_tracker().await;
assert_eq!(tracker.next_seq(10).await.unwrap(), 1);
assert_eq!(tracker.next_seq(10).await.unwrap(), 2);
assert_eq!(tracker.next_seq(20).await.unwrap(), 1);
assert_eq!(tracker.next_seq(20).await.unwrap(), 2);
assert_eq!(tracker.next_seq(10).await.unwrap(), 3);
tracker.record_ack(10, 2).await.unwrap();
assert_eq!(tracker.last_acked(10).await, 2);
assert_eq!(tracker.last_acked(20).await, 0);
}
}