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}