use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::{Disposition, ErrorCode, Ref};
pub const REPLAY_WINDOW: Duration = Duration::from_secs(10 * 60);
pub const RETRY_AFTER_GAP: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
struct Entry {
code: ErrorCode,
at: Instant,
at_utc: DateTime<Utc>,
fingerprint: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Proceed,
Replay {
code: ErrorCode,
at: String,
},
Wait {
code: ErrorCode,
at: String,
remaining_secs: u64,
},
}
#[derive(Debug, Default)]
pub struct RepeatIndex {
entries: Mutex<HashMap<String, Entry>>,
swept_len: std::sync::atomic::AtomicUsize,
}
const SWEEP_FLOOR: usize = 64;
impl RepeatIndex {
pub fn observe(&self, ref_input: &str, code: Option<ErrorCode>) {
self.observe_at(ref_input, code, Instant::now(), config_fingerprint());
}
pub fn observe_at(
&self,
ref_input: &str,
code: Option<ErrorCode>,
now: Instant,
fingerprint: u64,
) {
let key = key(ref_input);
let mut map = self.lock();
match code {
None => {
map.remove(&key);
}
Some(code) => {
let same = |e: &Entry| {
e.fingerprint == fingerprint && e.code.disposition() == code.disposition()
};
if let Some(e) = map.get_mut(&key).filter(|e| same(e)) {
if code.disposition() != Disposition::RetryAfter {
e.code = code;
}
} else {
use std::sync::atomic::Ordering;
let due = SWEEP_FLOOR.max(2 * self.swept_len.load(Ordering::Relaxed));
if map.len() >= due {
map.retain(|_, e| now.saturating_duration_since(e.at) < REPLAY_WINDOW);
self.swept_len.store(map.len(), Ordering::Relaxed);
}
map.insert(
key,
Entry {
code,
at: now,
at_utc: Utc::now(),
fingerprint,
},
);
}
}
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[must_use]
pub fn check(&self, ref_input: &str) -> Verdict {
self.check_at(ref_input, Instant::now(), config_fingerprint())
}
#[must_use]
pub fn check_at(&self, ref_input: &str, now: Instant, fingerprint: u64) -> Verdict {
let map = self.lock();
let Some(e) = map.get(&key(ref_input)) else {
return Verdict::Proceed;
};
if e.fingerprint != fingerprint {
return Verdict::Proceed;
}
let elapsed = now.saturating_duration_since(e.at);
let at = e.at_utc.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
match e.code.disposition() {
Disposition::Terminal | Disposition::NeedsConfig if elapsed < REPLAY_WINDOW => {
Verdict::Replay { code: e.code, at }
}
Disposition::RetryAfter if elapsed < RETRY_AFTER_GAP => Verdict::Wait {
code: e.code,
at,
remaining_secs: (RETRY_AFTER_GAP - elapsed).as_secs().max(1),
},
_ => Verdict::Proceed,
}
}
}
fn key(ref_input: &str) -> String {
Ref::parse(ref_input).map_or_else(
|_| ref_input.trim().to_string(),
|r| r.safekey().as_str().to_string(),
)
}
#[must_use]
pub fn config_fingerprint() -> u64 {
use std::hash::{Hash, Hasher};
let bytes = crate::user_extension::config_path()
.ok()
.and_then(|p| crate::store::blocking_section(|| std::fs::read(p)).ok())
.unwrap_or_default();
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
const R: &str = "10.1137/0117004";
#[test]
fn entries_past_every_window_are_swept_when_a_new_one_lands() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::NotFound), t0, 7);
for i in 1..SWEEP_FLOOR {
idx.observe_at(&format!("10.1/b{i}"), Some(ErrorCode::RateLimited), t0, 7);
}
assert_eq!(idx.lock().len(), SWEEP_FLOOR, "no sweep below the floor");
let later = t0 + REPLAY_WINDOW + Duration::from_secs(1);
idx.observe_at("10.1/c", Some(ErrorCode::NotFound), later, 7);
assert_eq!(idx.lock().len(), 1, "only the new entry is left");
assert!(matches!(
idx.check_at("10.1/c", later, 7),
Verdict::Replay { .. }
));
assert_eq!(idx.check_at(R, later, 7), Verdict::Proceed);
}
#[test]
fn a_terminal_answer_is_replayed_within_the_window_and_not_after() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::NotFound), t0, 7);
assert!(matches!(
idx.check_at(R, t0 + Duration::from_secs(5), 7),
Verdict::Replay {
code: ErrorCode::NotFound,
..
}
));
assert_eq!(idx.check_at(R, t0 + REPLAY_WINDOW, 7), Verdict::Proceed);
}
#[test]
fn the_same_work_written_differently_is_one_key() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(
"doi:10.1137/0117004",
Some(ErrorCode::CapabilityDenied),
t0,
7,
);
assert!(matches!(idx.check_at(R, t0, 7), Verdict::Replay { .. }));
}
#[test]
fn a_retry_after_answer_is_let_through_after_the_gap_and_timed_before_it() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::RateLimited), t0, 7);
match idx.check_at(R, t0 + Duration::from_secs(10), 7) {
Verdict::Wait { remaining_secs, .. } => assert_eq!(remaining_secs, 20),
v => panic!("expected Wait, got {v:?}"),
}
assert_eq!(idx.check_at(R, t0 + RETRY_AFTER_GAP, 7), Verdict::Proceed);
}
#[test]
fn a_changed_config_or_a_success_lifts_the_replay() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::CapabilityDenied), t0, 7);
assert_eq!(
idx.check_at(R, t0, 8),
Verdict::Proceed,
"config.toml changed"
);
idx.observe_at(R, None, t0, 7);
assert_eq!(
idx.check_at(R, t0, 7),
Verdict::Proceed,
"a later success clears it"
);
}
#[test]
fn a_replayed_wait_does_not_restart_the_clock_it_enforces() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::NetworkError), t0, 7);
assert!(matches!(
idx.check_at(R, t0 + Duration::from_secs(10), 7),
Verdict::Wait { .. }
));
idx.observe_at(
R,
Some(ErrorCode::RateLimited),
t0 + Duration::from_secs(10),
7,
);
assert_eq!(idx.check_at(R, t0 + RETRY_AFTER_GAP, 7), Verdict::Proceed);
}
#[test]
fn a_looping_caller_does_not_slide_the_window() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::NotFound), t0, 7);
idx.observe_at(R, Some(ErrorCode::NotFound), t0 + REPLAY_WINDOW / 2, 7);
assert_eq!(idx.check_at(R, t0 + REPLAY_WINDOW, 7), Verdict::Proceed);
}
#[test]
fn a_different_answer_replaces_the_entry_and_a_terminal_code_is_updated() {
let idx = RepeatIndex::default();
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::RateLimited), t0, 7);
idx.observe_at(R, Some(ErrorCode::NotFound), t0 + Duration::from_secs(1), 7);
assert!(matches!(
idx.check_at(R, t0 + Duration::from_secs(40), 7),
Verdict::Replay {
code: ErrorCode::NotFound,
..
}
));
let other = ErrorCode::ALL
.iter()
.copied()
.find(|c| {
*c != ErrorCode::NotFound && c.disposition() == ErrorCode::NotFound.disposition()
})
.expect("a second terminal code");
idx.observe_at(R, Some(other), t0 + Duration::from_secs(50), 7);
match idx.check_at(R, t0 + Duration::from_secs(60), 7) {
Verdict::Replay { code, .. } => assert_eq!(code, other),
v => panic!("expected Replay, got {v:?}"),
}
assert_eq!(
idx.check_at(R, t0 + Duration::from_secs(1) + REPLAY_WINDOW, 7),
Verdict::Proceed,
"the window runs from the first terminal answer"
);
}
#[test]
fn a_poisoned_lock_does_not_switch_suppression_off() {
let idx = std::sync::Arc::new(RepeatIndex::default());
let t0 = Instant::now();
idx.observe_at(R, Some(ErrorCode::NotFound), t0, 7);
let poisoner = std::sync::Arc::clone(&idx);
let _ = std::thread::spawn(move || {
let _guard = poisoner.entries.lock().unwrap();
panic!("poison the lock");
})
.join();
assert!(idx.entries.is_poisoned());
assert!(matches!(idx.check_at(R, t0, 7), Verdict::Replay { .. }));
}
}