use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const MAX_THROTTLE_ENTRIES: usize = 10_000;
pub struct WarnThrottle {
cooldown: Duration,
last: Mutex<HashMap<String, Instant>>,
}
impl WarnThrottle {
#[must_use]
pub fn new(cooldown_secs: u64) -> Self {
Self {
cooldown: Duration::from_secs(cooldown_secs),
last: Mutex::new(HashMap::new()),
}
}
pub fn should_warn(&self, key: &str) -> bool {
let mut map = match self.last.lock() {
Ok(g) => g,
Err(e) => e.into_inner(),
};
let now = Instant::now();
if let Some(last) = map.get(key)
&& now.duration_since(*last) < self.cooldown
{
return false;
}
if map.len() >= MAX_THROTTLE_ENTRIES {
map.retain(|_, last| now.duration_since(*last) < self.cooldown);
if map.len() >= MAX_THROTTLE_ENTRIES
&& let Some(oldest_key) =
map.iter().min_by_key(|(_, t)| **t).map(|(k, _)| k.clone())
{
map.remove(&oldest_key);
}
}
map.insert(key.to_string(), now);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
impl WarnThrottle {
fn max_entries() -> usize {
MAX_THROTTLE_ENTRIES
}
}
use std::thread::sleep;
#[test]
fn first_call_with_a_key_always_warns() {
let t = WarnThrottle::new(60);
assert!(t.should_warn("category:host-a"));
}
#[test]
fn second_call_within_cooldown_returns_false() {
let t = WarnThrottle::new(60);
assert!(t.should_warn("k"));
assert!(!t.should_warn("k"));
}
#[test]
fn distinct_keys_have_independent_cooldowns() {
let t = WarnThrottle::new(60);
assert!(t.should_warn("a"));
assert!(t.should_warn("b"));
assert!(!t.should_warn("a"));
assert!(!t.should_warn("b"));
}
#[test]
fn after_cooldown_elapses_warning_fires_again() {
let t = WarnThrottle::new(0); for _ in 0..5 {
assert!(t.should_warn("k"));
}
}
#[test]
fn cooldown_one_millisecond_works_with_short_sleep() {
let t = WarnThrottle::new(1);
assert!(t.should_warn("k"));
assert!(!t.should_warn("k"));
sleep(Duration::from_millis(1100));
assert!(t.should_warn("k"), "cooldown elapsed; should warn again");
}
#[test]
fn map_stays_bounded_at_max_entries() {
let t = WarnThrottle::new(60); let cap = WarnThrottle::max_entries();
for i in 0..=cap {
t.should_warn(&format!("k{i}"));
}
let map = t.last.lock().unwrap();
assert!(
map.len() <= cap,
"throttle map grew past cap: {} > {cap}",
map.len()
);
}
#[test]
fn expired_entries_evicted_when_at_cap() {
let t = WarnThrottle::new(0);
let cap = WarnThrottle::max_entries();
for i in 0..=cap {
t.should_warn(&format!("k{i}"));
}
let map = t.last.lock().unwrap();
assert!(
map.len() <= cap,
"map size {} should never exceed cap {cap}",
map.len()
);
}
}