use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};
pub const DEFAULT_WINDOW_CAPACITY: usize = 1024;
pub const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(300);
#[derive(Debug)]
pub struct IdempotencyWindow {
capacity: usize,
ttl: Duration,
seen: HashMap<String, Instant>,
order: VecDeque<(String, Instant)>,
}
impl Default for IdempotencyWindow {
fn default() -> Self {
Self::new()
}
}
impl IdempotencyWindow {
pub fn new() -> Self {
Self::with_config(DEFAULT_WINDOW_CAPACITY, DEFAULT_WINDOW_TTL)
}
pub fn with_config(capacity: usize, ttl: Duration) -> Self {
Self {
capacity: capacity.max(1),
ttl,
seen: HashMap::new(),
order: VecDeque::new(),
}
}
pub fn check_insert(&mut self, request_id: &str) -> bool {
let now = Instant::now();
self.sweep_expired(now);
if self.seen.contains_key(request_id) {
return false;
}
while self.seen.len() >= self.capacity {
match self.order.pop_front() {
Some((evicted, _)) => {
self.seen.remove(&evicted);
}
None => break,
}
}
self.seen.insert(request_id.to_string(), now);
self.order.push_back((request_id.to_string(), now));
true
}
pub fn len(&self) -> usize {
self.seen.len()
}
pub fn is_empty(&self) -> bool {
self.seen.is_empty()
}
fn sweep_expired(&mut self, now: Instant) {
while let Some((_, inserted_at)) = self.order.front() {
if now.duration_since(*inserted_at) >= self.ttl {
let (id, _) = self.order.pop_front().expect("front existed");
self.seen.remove(&id);
} else {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_insert_true_repeat_false() {
let mut window = IdempotencyWindow::new();
assert!(window.check_insert("11111111-1111-4111-8111-111111111111"));
assert!(!window.check_insert("11111111-1111-4111-8111-111111111111"));
assert_eq!(window.len(), 1);
}
#[test]
fn distinct_ids_coexist() {
let mut window = IdempotencyWindow::new();
assert!(window.check_insert("a"));
assert!(window.check_insert("b"));
assert!(!window.check_insert("a"));
assert!(!window.check_insert("b"));
assert_eq!(window.len(), 2);
}
#[test]
fn capacity_evicts_oldest_first() {
let mut window = IdempotencyWindow::with_config(3, Duration::from_secs(300));
assert!(window.check_insert("a"));
assert!(window.check_insert("b"));
assert!(window.check_insert("c"));
assert!(window.check_insert("d"));
assert_eq!(window.len(), 3);
assert!(!window.check_insert("b"));
assert!(!window.check_insert("c"));
assert!(!window.check_insert("d"));
assert_eq!(window.len(), 3);
assert!(
window.check_insert("a"),
"evicted id must be accepted again"
);
}
#[test]
fn ttl_expiry_frees_the_key() {
let mut window = IdempotencyWindow::with_config(16, Duration::from_millis(50));
assert!(window.check_insert("tick"));
assert!(!window.check_insert("tick"));
std::thread::sleep(Duration::from_millis(90));
assert!(
window.check_insert("tick"),
"expired id must be accepted again"
);
assert_eq!(window.len(), 1);
}
#[test]
fn capacity_is_clamped_to_at_least_one() {
let mut window = IdempotencyWindow::with_config(0, Duration::from_secs(300));
assert!(window.check_insert("only"));
assert_eq!(window.len(), 1);
assert!(!window.check_insert("only"));
}
#[test]
fn default_window_uses_documented_bounds() {
let window = IdempotencyWindow::new();
assert_eq!(DEFAULT_WINDOW_CAPACITY, 1024);
assert_eq!(DEFAULT_WINDOW_TTL, Duration::from_secs(300));
assert!(window.is_empty());
}
}