use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use nodedb_array::sync::hlc::Hlc;
use nodedb_types::Namespace;
use nodedb_types::sync::wire::array::ArrayCatchupRequestMsg;
use crate::error::LiteError;
use crate::storage::engine::StorageEngine;
const LAST_SEEN_PREFIX: &str = "array.last_seen_hlc:";
const CATCHUP_NEEDED_PREFIX: &str = "array.catchup_needed:";
fn last_seen_key(array: &str) -> Vec<u8> {
format!("{LAST_SEEN_PREFIX}{array}").into_bytes()
}
fn catchup_needed_key(array: &str) -> Vec<u8> {
format!("{CATCHUP_NEEDED_PREFIX}{array}").into_bytes()
}
pub struct CatchupTracker<S: StorageEngine> {
storage: Arc<S>,
state: Mutex<HashMap<String, Hlc>>,
catchup_needed: Mutex<std::collections::HashSet<String>>,
write_lock: tokio::sync::Mutex<()>,
}
impl<S: StorageEngine> CatchupTracker<S> {
pub async fn load(storage: Arc<S>) -> Result<Self, LiteError> {
let prefix = LAST_SEEN_PREFIX.as_bytes();
let pairs = storage.scan_prefix(Namespace::Meta, prefix).await?;
let mut state = HashMap::new();
for (key, value) in pairs {
let name_bytes = &key[prefix.len()..];
let name = std::str::from_utf8(name_bytes).map_err(|e| LiteError::Storage {
detail: format!("catchup_tracker: non-UTF8 array name in storage: {e}"),
})?;
let hlc_arr: [u8; 18] = value.try_into().map_err(|v: Vec<u8>| LiteError::Storage {
detail: format!(
"catchup_tracker: last_seen_hlc wrong length ({}) for '{name}'",
v.len()
),
})?;
state.insert(name.to_owned(), Hlc::from_bytes(&hlc_arr));
}
let needed_prefix = CATCHUP_NEEDED_PREFIX.as_bytes();
let needed_pairs = storage.scan_prefix(Namespace::Meta, needed_prefix).await?;
let mut catchup_needed = std::collections::HashSet::new();
for (key, _) in needed_pairs {
let name_bytes = &key[needed_prefix.len()..];
if let Ok(name) = std::str::from_utf8(name_bytes) {
catchup_needed.insert(name.to_owned());
}
}
Ok(Self {
storage,
state: Mutex::new(state),
catchup_needed: Mutex::new(catchup_needed),
write_lock: tokio::sync::Mutex::new(()),
})
}
pub async fn record(&self, array: &str, hlc: Hlc) -> Result<(), LiteError> {
let _write = self.write_lock.lock().await;
let advanced = {
let mut state = self.state.lock().map_err(|_| LiteError::LockPoisoned)?;
let current = state.get(array).copied().unwrap_or(Hlc::ZERO);
if hlc > current {
state.insert(array.to_owned(), hlc);
}
hlc > current
};
if !advanced {
return Ok(());
}
self.storage
.put(Namespace::Meta, &last_seen_key(array), &hlc.to_bytes())
.await
}
pub fn last_seen(&self, array: &str) -> Hlc {
self.state
.lock()
.ok()
.and_then(|s| s.get(array).copied())
.unwrap_or(Hlc::ZERO)
}
pub fn build_request(&self, array: &str) -> ArrayCatchupRequestMsg {
let from = if self.is_catchup_needed(array) {
Hlc::ZERO
} else {
self.last_seen(array)
};
ArrayCatchupRequestMsg {
array: array.to_owned(),
from_hlc_bytes: from.to_bytes(),
}
}
pub fn is_catchup_needed(&self, array: &str) -> bool {
self.catchup_needed
.lock()
.ok()
.is_some_and(|s| s.contains(array))
}
pub fn should_request_catchup(&self, array: &str, _current_local_hlc: Hlc) -> bool {
if self.is_catchup_needed(array) {
return true;
}
let state = self.state.lock().ok();
state.map(|s| !s.contains_key(array)).unwrap_or(false)
}
pub async fn record_reject_retention_floor(&self, array: &str) -> Result<(), LiteError> {
let _write = self.write_lock.lock().await;
self.catchup_needed
.lock()
.map_err(|_| LiteError::LockPoisoned)?
.insert(array.to_owned());
self.storage
.put(Namespace::Meta, &catchup_needed_key(array), &[1u8])
.await
}
pub async fn clear_catchup_needed(&self, array: &str) -> Result<(), LiteError> {
let _write = self.write_lock.lock().await;
self.catchup_needed
.lock()
.map_err(|_| LiteError::LockPoisoned)?
.remove(array);
self.storage
.delete(Namespace::Meta, &catchup_needed_key(array))
.await
}
pub fn arrays_needing_catchup(&self) -> Vec<String> {
self.catchup_needed
.lock()
.ok()
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::pagedb_storage::{PagedbStorageDefault, PagedbStorageMem};
use nodedb_array::sync::replica_id::ReplicaId;
fn rep() -> ReplicaId {
ReplicaId::new(1)
}
fn hlc(ms: u64) -> Hlc {
Hlc::new(ms, 0, rep()).unwrap()
}
async fn make_tracker() -> CatchupTracker<PagedbStorageMem> {
let storage = Arc::new(PagedbStorageMem::open_in_memory().await.unwrap());
CatchupTracker::load(storage).await.unwrap()
}
#[tokio::test]
async fn load_returns_zero_when_empty() {
let tracker = make_tracker().await;
assert_eq!(tracker.last_seen("any_array"), Hlc::ZERO);
}
#[tokio::test]
async fn record_persists_across_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catchup_test.pagedb");
let target_hlc = hlc(42_000);
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = CatchupTracker::load(Arc::clone(&storage)).await.unwrap();
tracker.record("arr", target_hlc).await.unwrap();
assert_eq!(tracker.last_seen("arr"), target_hlc);
}
{
let storage = Arc::new(
PagedbStorageDefault::open(
&path,
crate::storage::encryption::Encryption::Plaintext,
)
.await
.unwrap(),
);
let tracker = CatchupTracker::load(storage).await.unwrap();
assert_eq!(
tracker.last_seen("arr"),
target_hlc,
"last_seen must survive storage restart"
);
}
}
#[tokio::test]
async fn record_is_monotonic_only() {
let tracker = make_tracker().await;
let h1 = hlc(100);
let h2 = hlc(200);
tracker.record("x", h2).await.unwrap();
tracker.record("x", h1).await.unwrap();
assert_eq!(tracker.last_seen("x"), h2);
}
#[tokio::test]
async fn build_request_carries_last_seen() {
let tracker = make_tracker().await;
let h = hlc(9_999);
tracker.record("feed", h).await.unwrap();
let req = tracker.build_request("feed");
assert_eq!(req.array, "feed");
assert_eq!(req.from_hlc_bytes, h.to_bytes());
}
#[tokio::test]
async fn build_request_starts_at_zero_while_retention_floor_flag_is_set() {
let tracker = make_tracker().await;
let h = hlc(5_000);
tracker.record("feed", h).await.unwrap();
tracker.record_reject_retention_floor("feed").await.unwrap();
assert!(tracker.is_catchup_needed("feed"));
assert_eq!(
tracker.build_request("feed").from_hlc_bytes,
Hlc::ZERO.to_bytes(),
"a flagged array must ask for history below the GC boundary"
);
tracker.clear_catchup_needed("feed").await.unwrap();
assert!(!tracker.is_catchup_needed("feed"));
assert_eq!(tracker.build_request("feed").from_hlc_bytes, h.to_bytes());
}
#[tokio::test]
async fn build_request_zero_when_unknown() {
let tracker = make_tracker().await;
let req = tracker.build_request("unknown");
assert_eq!(req.from_hlc_bytes, Hlc::ZERO.to_bytes());
}
}