use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
pub const DEFAULT_RELAY_SEEN_CAPACITY: usize = 8192;
pub const DEFAULT_RELAY_SEEN_RETENTION_SECS: u64 = 600;
#[derive(Debug, Clone)]
pub struct RelaySeenConfig {
pub capacity: usize,
pub retention: Duration,
}
impl Default for RelaySeenConfig {
fn default() -> Self {
Self {
capacity: DEFAULT_RELAY_SEEN_CAPACITY,
retention: Duration::from_secs(DEFAULT_RELAY_SEEN_RETENTION_SECS),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeenOutcome {
Fresh,
Duplicate,
}
#[derive(Debug)]
pub struct RelaySeenCache {
config: RelaySeenConfig,
entries: HashMap<String, Instant>,
order: VecDeque<String>,
duplicates_suppressed: u64,
capacity_evictions: u64,
}
impl RelaySeenCache {
pub fn new() -> Self {
Self::with_config(RelaySeenConfig::default())
}
pub fn with_config(config: RelaySeenConfig) -> Self {
let capacity = config.capacity.max(1);
Self {
config: RelaySeenConfig { capacity, ..config },
entries: HashMap::with_capacity(capacity.min(1024)),
order: VecDeque::with_capacity(capacity.min(1024)),
duplicates_suppressed: 0,
capacity_evictions: 0,
}
}
pub fn observe(&mut self, message_id: &str) -> SeenOutcome {
self.expire(Instant::now());
if self.entries.contains_key(message_id) {
self.duplicates_suppressed = self.duplicates_suppressed.saturating_add(1);
return SeenOutcome::Duplicate;
}
while self.entries.len() >= self.config.capacity {
if self.pop_oldest().is_some() {
self.capacity_evictions = self.capacity_evictions.saturating_add(1);
} else {
break;
}
}
self.entries.insert(message_id.to_string(), Instant::now());
self.order.push_back(message_id.to_string());
SeenOutcome::Fresh
}
pub fn forget(&mut self, message_id: &str) {
if self.entries.remove(message_id).is_some() {
self.order.retain(|id| id != message_id);
}
}
pub fn contains(&self, message_id: &str) -> bool {
match self.entries.get(message_id) {
Some(seen_at) => seen_at.elapsed() < self.config.retention,
None => false,
}
}
pub fn expire(&mut self, now: Instant) {
while let Some(oldest_id) = self.order.front() {
let expired = match self.entries.get(oldest_id) {
Some(seen_at) => now.duration_since(*seen_at) >= self.config.retention,
None => true,
};
if !expired {
break;
}
self.pop_oldest();
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn duplicates_suppressed(&self) -> u64 {
self.duplicates_suppressed
}
pub fn capacity_evictions(&self) -> u64 {
self.capacity_evictions
}
pub fn clear(&mut self) {
self.entries.clear();
self.order.clear();
}
fn pop_oldest(&mut self) -> Option<String> {
let oldest = self.order.pop_front()?;
self.entries.remove(&oldest);
Some(oldest)
}
}
impl Default for RelaySeenCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_sighting_is_fresh_and_repeats_are_duplicates() {
let mut cache = RelaySeenCache::new();
assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
assert_eq!(cache.observe("m1"), SeenOutcome::Duplicate);
assert_eq!(cache.observe("m1"), SeenOutcome::Duplicate);
assert_eq!(cache.duplicates_suppressed(), 2);
assert_eq!(cache.len(), 1);
}
#[test]
fn distinct_ids_are_tracked_independently() {
let mut cache = RelaySeenCache::new();
assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
assert_eq!(cache.observe("m2"), SeenOutcome::Fresh);
assert!(cache.contains("m1"));
assert!(cache.contains("m2"));
}
#[test]
fn capacity_evicts_oldest_first_and_is_counted() {
let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
capacity: 3,
retention: Duration::from_secs(600),
});
cache.observe("m1");
cache.observe("m2");
cache.observe("m3");
assert_eq!(cache.capacity_evictions(), 0);
cache.observe("m4");
assert_eq!(cache.len(), 3);
assert_eq!(cache.capacity_evictions(), 1);
assert!(!cache.contains("m1"));
assert!(cache.contains("m2"));
assert!(cache.contains("m4"));
assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
}
#[test]
fn entries_expire_by_age() {
let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
capacity: 128,
retention: Duration::from_millis(0),
});
cache.observe("m1");
assert_eq!(cache.observe("m2"), SeenOutcome::Fresh);
assert!(!cache.contains("m1"));
assert_eq!(cache.capacity_evictions(), 0);
}
#[test]
fn expiry_never_leaves_orphaned_order_entries() {
let mut cache = RelaySeenCache::with_config(RelaySeenConfig {
capacity: 4,
retention: Duration::from_millis(0),
});
for i in 0..16 {
cache.observe(&format!("m{i}"));
}
assert_eq!(cache.order.len(), cache.entries.len());
assert!(cache.len() <= 4);
}
#[test]
fn a_forgotten_id_is_new_again_and_leaves_no_orphan() {
let mut cache = RelaySeenCache::new();
cache.observe("m1");
cache.observe("m2");
cache.forget("m1");
assert!(
!cache.contains("m1"),
"a forgotten id must not be suppressed"
);
assert!(cache.contains("m2"), "and its neighbors are untouched");
assert_eq!(cache.observe("m1"), SeenOutcome::Fresh);
assert_eq!(cache.order.len(), cache.entries.len());
assert_eq!(cache.capacity_evictions(), 0);
}
#[test]
fn forgetting_an_unknown_id_is_a_no_op() {
let mut cache = RelaySeenCache::new();
cache.observe("m1");
cache.forget("never-seen");
assert!(cache.contains("m1"));
assert_eq!(cache.order.len(), 1);
assert_eq!(cache.entries.len(), 1);
}
#[test]
fn clear_drops_everything() {
let mut cache = RelaySeenCache::new();
cache.observe("m1");
cache.observe("m2");
cache.clear();
assert!(cache.is_empty());
assert!(!cache.contains("m1"));
}
}