#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplicaSlot {
pub id: String,
pub last_seen_ns: u64,
pub acked_offset: u64,
}
#[derive(Debug, Default)]
pub struct SlotTable {
slots: Vec<ReplicaSlot>,
}
impl SlotTable {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
pub fn get(&self, id: &str) -> Option<&ReplicaSlot> {
self.slots.iter().find(|s| s.id == id)
}
pub fn iter(&self) -> impl Iterator<Item = &ReplicaSlot> {
self.slots.iter()
}
pub fn insert_or_touch(&mut self, id: &str, acked_offset: u64, now_ns: u64) {
if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
s.last_seen_ns = now_ns;
if acked_offset > s.acked_offset {
s.acked_offset = acked_offset;
}
return;
}
self.slots.push(ReplicaSlot {
id: id.to_string(),
last_seen_ns: now_ns,
acked_offset,
});
}
pub fn remove(&mut self, id: &str) -> bool {
if let Some(pos) = self.slots.iter().position(|s| s.id == id) {
self.slots.swap_remove(pos);
true
} else {
false
}
}
pub fn expire(&mut self, now_ns: u64, window_ns: u64) -> Vec<String> {
let mut dropped = Vec::new();
let mut i = self.slots.len();
while i > 0 {
i -= 1;
let cutoff = self.slots[i].last_seen_ns.saturating_add(window_ns);
if cutoff <= now_ns {
let s = self.slots.swap_remove(i);
dropped.push(s.id);
}
}
dropped
}
pub fn min_acked_offset(&self) -> Option<u64> {
self.slots.iter().map(|s| s.acked_offset).min()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_table_is_empty() {
let t = SlotTable::new();
assert!(t.is_empty());
assert_eq!(t.len(), 0);
assert_eq!(t.min_acked_offset(), None);
}
#[test]
fn insert_then_get_returns_the_slot() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 5, 100);
let s = t.get("a").unwrap();
assert_eq!(s.id, "a");
assert_eq!(s.acked_offset, 5);
assert_eq!(s.last_seen_ns, 100);
assert_eq!(t.len(), 1);
}
#[test]
fn touch_advances_last_seen_and_acked_offset() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 5, 100);
t.insert_or_touch("a", 9, 200);
let s = t.get("a").unwrap();
assert_eq!(s.acked_offset, 9);
assert_eq!(s.last_seen_ns, 200);
assert_eq!(t.len(), 1);
}
#[test]
fn touch_with_lower_acked_offset_keeps_the_higher_one() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 10, 100);
t.insert_or_touch("a", 7, 200);
let s = t.get("a").unwrap();
assert_eq!(s.acked_offset, 10);
assert_eq!(s.last_seen_ns, 200, "last_seen still advances");
}
#[test]
fn remove_existing_returns_true_and_drops_slot() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 1, 100);
assert!(t.remove("a"));
assert!(t.is_empty());
assert_eq!(t.get("a"), None);
}
#[test]
fn remove_missing_returns_false() {
let mut t = SlotTable::new();
assert!(!t.remove("missing"));
}
#[test]
fn expire_drops_slots_past_window() {
let mut t = SlotTable::new();
t.insert_or_touch("old", 1, 100);
t.insert_or_touch("fresh", 1, 500);
let dropped = t.expire(350, 200);
assert_eq!(dropped, vec!["old".to_string()]);
assert_eq!(t.len(), 1);
assert!(t.get("fresh").is_some());
}
#[test]
fn expire_when_nothing_expires_returns_empty() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 1, 1000);
let dropped = t.expire(1100, 500); assert!(dropped.is_empty());
assert_eq!(t.len(), 1);
}
#[test]
fn expire_with_overflow_window_saturates_does_not_panic() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 1, u64::MAX - 10);
let dropped = t.expire(u64::MAX - 1, u64::MAX);
assert!(dropped.is_empty());
assert_eq!(t.len(), 1);
}
#[test]
fn min_acked_offset_returns_the_floor() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 7, 100);
t.insert_or_touch("b", 3, 100);
t.insert_or_touch("c", 12, 100);
assert_eq!(t.min_acked_offset(), Some(3));
}
#[test]
fn iter_visits_every_slot() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 1, 100);
t.insert_or_touch("b", 2, 100);
let mut ids: Vec<_> = t.iter().map(|s| s.id.clone()).collect();
ids.sort();
assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn expire_all_when_window_zero() {
let mut t = SlotTable::new();
t.insert_or_touch("a", 1, 100);
t.insert_or_touch("b", 1, 100);
let dropped = t.expire(100, 0);
assert_eq!(dropped.len(), 2);
assert!(t.is_empty());
}
}