use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use bytes::Bytes;
use lru::LruCache;
#[derive(Debug, Clone)]
pub struct CachedVerdict {
pub status: u16,
pub body: Bytes,
pub webhook_id: String,
pub webhook_timestamp: i64,
pub webhook_signature: String,
pub processing_ms: u64,
}
pub struct ReplayCache {
inner: Mutex<LruCache<String, (Instant, CachedVerdict)>>,
ttl: Duration,
}
impl ReplayCache {
pub fn new(capacity: usize, ttl: Duration) -> Self {
let cap = NonZeroUsize::new(capacity.max(1)).expect("non-zero (clamped above)");
Self {
inner: Mutex::new(LruCache::new(cap)),
ttl,
}
}
pub fn standard() -> Self {
Self::new(1000, Duration::from_secs(300))
}
pub fn lookup(&self, webhook_id: &str) -> Option<CachedVerdict> {
let mut cache = self.inner.lock().expect("replay cache poisoned");
let entry = cache.peek(webhook_id).cloned();
match entry {
Some((ts, _)) if ts.elapsed() > self.ttl => {
cache.pop(webhook_id);
None
}
Some(_) => {
cache.get(webhook_id).map(|(_, v)| v.clone())
}
None => None,
}
}
pub fn insert(&self, webhook_id: String, verdict: CachedVerdict) {
let mut cache = self.inner.lock().expect("replay cache poisoned");
cache.put(webhook_id, (Instant::now(), verdict));
}
pub fn len(&self) -> usize {
self.inner.lock().expect("replay cache poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(id: &str) -> CachedVerdict {
CachedVerdict {
status: 200,
body: Bytes::from_static(b"{}"),
webhook_id: id.into(),
webhook_timestamp: 1_700_000_000,
webhook_signature: "v1,Zm9v".into(),
processing_ms: 5,
}
}
#[test]
fn lookup_returns_none_for_missing_id() {
let c = ReplayCache::standard();
assert!(c.lookup("msg_x").is_none());
}
#[test]
fn insert_then_lookup_round_trips() {
let c = ReplayCache::standard();
c.insert("msg_a".into(), fixture("msg_a"));
let got = c.lookup("msg_a").expect("should hit");
assert_eq!(got.webhook_id, "msg_a");
assert_eq!(got.body, Bytes::from_static(b"{}"));
}
#[test]
fn lookup_evicts_expired_entries() {
let c = ReplayCache::new(8, Duration::from_millis(10));
c.insert("msg_a".into(), fixture("msg_a"));
std::thread::sleep(Duration::from_millis(25));
assert!(c.lookup("msg_a").is_none());
assert!(c.is_empty());
}
#[test]
fn lru_evicts_oldest_when_capacity_exceeded() {
let c = ReplayCache::new(2, Duration::from_secs(60));
c.insert("a".into(), fixture("a"));
c.insert("b".into(), fixture("b"));
c.insert("c".into(), fixture("c"));
assert!(c.lookup("a").is_none(), "a should have been evicted");
assert!(c.lookup("b").is_some());
assert!(c.lookup("c").is_some());
}
#[test]
fn lookup_refreshes_recency() {
let c = ReplayCache::new(2, Duration::from_secs(60));
c.insert("a".into(), fixture("a"));
c.insert("b".into(), fixture("b"));
let _ = c.lookup("a");
c.insert("c".into(), fixture("c"));
assert!(c.lookup("a").is_some(), "a was promoted, must survive");
assert!(c.lookup("b").is_none(), "b should have been evicted");
}
#[test]
fn cache_remains_useful_at_zero_capacity_clamp() {
let c = ReplayCache::new(0, Duration::from_secs(1));
c.insert("only".into(), fixture("only"));
assert!(c.lookup("only").is_some());
}
}