Skip to main content

oximedia_cache/
negative.rs

1//! Negative (miss) caching.
2//!
3//! [`NegativeCache`] records keys for which a lookup has previously returned
4//! no result (a *miss*).  Subsequent lookups can check `is_known_miss` to
5//! avoid hitting the origin for keys that are known to be absent.
6//!
7//! Each miss entry is stamped with the time it was recorded and expires after
8//! a configurable TTL (in milliseconds).  Expired entries are considered
9//! "unknown" again.
10//!
11//! # Example
12//!
13//! ```
14//! use oximedia_cache::negative::NegativeCache;
15//!
16//! let mut nc = NegativeCache::new(5_000); // 5-second TTL
17//! nc.insert_miss("missing-asset", 1_000_000);
18//!
19//! assert!(nc.is_known_miss("missing-asset", 1_002_000)); // within TTL
20//! assert!(!nc.is_known_miss("missing-asset", 1_006_000)); // expired
21//! ```
22
23use std::collections::HashMap;
24
25// ---------------------------------------------------------------------------
26// NegativeCache
27// ---------------------------------------------------------------------------
28
29/// A TTL-keyed negative result cache.
30#[derive(Debug, Clone)]
31pub struct NegativeCache {
32    /// How long (in milliseconds) a miss entry is considered valid.
33    ttl_ms: u64,
34    /// Map from key to the timestamp (ms) at which the miss was recorded.
35    entries: HashMap<String, u64>,
36}
37
38impl NegativeCache {
39    /// Create a new `NegativeCache` with the given TTL in milliseconds.
40    #[must_use]
41    pub fn new(ttl_ms: u64) -> Self {
42        Self {
43            ttl_ms,
44            entries: HashMap::new(),
45        }
46    }
47
48    /// Record `key` as a known miss at timestamp `now_ms`.
49    ///
50    /// If an entry for `key` already exists it is overwritten with the new
51    /// timestamp (refreshing the TTL).
52    pub fn insert_miss(&mut self, key: &str, now_ms: u64) {
53        self.entries.insert(key.to_string(), now_ms);
54    }
55
56    /// Returns `true` when `key` is a known miss that has not yet expired.
57    ///
58    /// A miss entry at `recorded_ms` expires when `now_ms >= recorded_ms + ttl_ms`.
59    #[must_use]
60    pub fn is_known_miss(&self, key: &str, now_ms: u64) -> bool {
61        match self.entries.get(key) {
62            Some(&recorded_ms) => {
63                let expiry = recorded_ms.saturating_add(self.ttl_ms);
64                now_ms < expiry
65            }
66            None => false,
67        }
68    }
69
70    /// Remove the entry for `key` (e.g. after it has been successfully
71    /// populated in the origin cache).
72    pub fn remove(&mut self, key: &str) {
73        self.entries.remove(key);
74    }
75
76    /// Prune all entries that have expired by `now_ms`.
77    pub fn evict_expired(&mut self, now_ms: u64) {
78        self.entries.retain(|_, &mut recorded_ms| {
79            let expiry = recorded_ms.saturating_add(self.ttl_ms);
80            now_ms < expiry
81        });
82    }
83
84    /// Number of currently tracked negative entries (including expired ones
85    /// that have not been pruned yet).
86    #[must_use]
87    pub fn len(&self) -> usize {
88        self.entries.len()
89    }
90
91    /// Returns `true` when no entries are tracked.
92    #[must_use]
93    pub fn is_empty(&self) -> bool {
94        self.entries.is_empty()
95    }
96
97    /// The configured TTL in milliseconds.
98    #[must_use]
99    pub fn ttl_ms(&self) -> u64 {
100        self.ttl_ms
101    }
102
103    /// Clear all entries.
104    pub fn clear(&mut self) {
105        self.entries.clear();
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Tests
111// ---------------------------------------------------------------------------
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    // ── new ──────────────────────────────────────────────────────────────────
118
119    #[test]
120    fn test_new_is_empty() {
121        let nc = NegativeCache::new(5_000);
122        assert!(nc.is_empty());
123        assert_eq!(nc.len(), 0);
124    }
125
126    #[test]
127    fn test_ttl_getter() {
128        let nc = NegativeCache::new(10_000);
129        assert_eq!(nc.ttl_ms(), 10_000);
130    }
131
132    // ── insert_miss / is_known_miss ──────────────────────────────────────────
133
134    #[test]
135    fn test_is_known_miss_before_expiry() {
136        let mut nc = NegativeCache::new(5_000);
137        nc.insert_miss("key1", 1_000_000);
138        assert!(nc.is_known_miss("key1", 1_002_000));
139    }
140
141    #[test]
142    fn test_is_known_miss_at_expiry_boundary() {
143        let mut nc = NegativeCache::new(5_000);
144        nc.insert_miss("k", 1_000_000);
145        // Exactly at expiry (recorded + ttl = 1_005_000) → expired
146        assert!(!nc.is_known_miss("k", 1_005_000));
147    }
148
149    #[test]
150    fn test_is_known_miss_after_expiry() {
151        let mut nc = NegativeCache::new(5_000);
152        nc.insert_miss("k", 1_000_000);
153        assert!(!nc.is_known_miss("k", 1_100_000));
154    }
155
156    #[test]
157    fn test_is_known_miss_unknown_key_returns_false() {
158        let nc = NegativeCache::new(5_000);
159        assert!(!nc.is_known_miss("absent", 0));
160    }
161
162    #[test]
163    fn test_insert_miss_overwrites_existing() {
164        let mut nc = NegativeCache::new(5_000);
165        nc.insert_miss("k", 1_000_000);
166        // Override with a later timestamp
167        nc.insert_miss("k", 2_000_000);
168        assert!(nc.is_known_miss("k", 2_001_000));
169        // Old entry would have expired, but new one is valid
170        assert!(!nc.is_known_miss("k", 2_006_000));
171    }
172
173    // ── remove ───────────────────────────────────────────────────────────────
174
175    #[test]
176    fn test_remove_makes_key_unknown() {
177        let mut nc = NegativeCache::new(5_000);
178        nc.insert_miss("k", 0);
179        nc.remove("k");
180        assert!(!nc.is_known_miss("k", 1_000));
181    }
182
183    #[test]
184    fn test_remove_nonexistent_is_noop() {
185        let mut nc = NegativeCache::new(5_000);
186        nc.remove("ghost"); // should not panic
187        assert!(nc.is_empty());
188    }
189
190    // ── evict_expired ────────────────────────────────────────────────────────
191
192    #[test]
193    fn test_evict_expired_removes_old_entries() {
194        let mut nc = NegativeCache::new(1_000);
195        nc.insert_miss("old", 0); // expires at 1_000
196        nc.insert_miss("young", 5_000); // expires at 6_000
197        nc.evict_expired(2_000); // prune at t=2_000
198        assert_eq!(nc.len(), 1, "Only 'young' should remain");
199        assert!(!nc.is_known_miss("old", 500));
200    }
201
202    #[test]
203    fn test_evict_expired_keeps_valid_entries() {
204        let mut nc = NegativeCache::new(10_000);
205        nc.insert_miss("a", 1_000);
206        nc.evict_expired(5_000); // 5_000 < 1_000 + 10_000 = 11_000 → still valid
207        assert_eq!(nc.len(), 1);
208    }
209
210    // ── clear ────────────────────────────────────────────────────────────────
211
212    #[test]
213    fn test_clear_removes_all_entries() {
214        let mut nc = NegativeCache::new(5_000);
215        nc.insert_miss("a", 1);
216        nc.insert_miss("b", 2);
217        nc.clear();
218        assert!(nc.is_empty());
219    }
220
221    // ── zero ttl ─────────────────────────────────────────────────────────────
222
223    #[test]
224    fn test_zero_ttl_expires_immediately() {
225        let mut nc = NegativeCache::new(0);
226        nc.insert_miss("k", 1_000);
227        // Any now_ms >= 1_000 → expired
228        assert!(!nc.is_known_miss("k", 1_000));
229    }
230}