Skip to main content

oximedia_cache/
ttl_cache.rs

1//! TTL-based cache with automatic expiry.
2//!
3//! This module provides [`TtlCache`], a capacity-bounded cache where each
4//! entry carries an expiry deadline expressed as seconds since an arbitrary
5//! epoch (e.g. `SystemTime::UNIX_EPOCH`).  Expired entries are never returned
6//! and can be purged in bulk via [`TtlCache::remove_expired`].
7//!
8//! The caller is responsible for supplying monotonically-increasing
9//! `now_secs` timestamps; this keeps the implementation deterministic and
10//! easy to unit-test without `std::time` side-effects.
11//!
12//! # Example
13//!
14//! ```rust
15//! use oximedia_cache::ttl_cache::TtlCache;
16//!
17//! let mut cache: TtlCache<&str, Vec<u8>> = TtlCache::new(64, 30);
18//! cache.insert("frame-001", vec![0u8; 128], 1_000);
19//! assert!(cache.get(&"frame-001", 1_020).is_some()); // within TTL
20//! assert!(cache.get(&"frame-001", 1_031).is_none()); // expired
21//! ```
22
23use std::collections::HashMap;
24
25// ── TtlEntry ─────────────────────────────────────────────────────────────────
26
27/// A single cache entry with an embedded TTL deadline.
28#[derive(Debug, Clone)]
29pub struct TtlEntry<V> {
30    /// The cached value.
31    pub value: V,
32    /// Epoch-seconds at which this entry was inserted.
33    pub inserted_at_secs: u64,
34    /// Lifetime in seconds from insertion.
35    pub ttl_secs: u64,
36}
37
38impl<V> TtlEntry<V> {
39    /// Create a new entry with the given parameters.
40    #[inline]
41    pub fn new(value: V, inserted_at_secs: u64, ttl_secs: u64) -> Self {
42        Self {
43            value,
44            inserted_at_secs,
45            ttl_secs,
46        }
47    }
48
49    /// Returns `true` when `now_secs` has passed the entry's deadline.
50    ///
51    /// A TTL of `0` means the entry never expires.
52    #[inline]
53    pub fn is_expired(&self, now_secs: u64) -> bool {
54        if self.ttl_secs == 0 {
55            return false;
56        }
57        now_secs >= self.inserted_at_secs.saturating_add(self.ttl_secs)
58    }
59
60    /// Seconds remaining before expiry, saturating at zero once expired.
61    ///
62    /// Returns `u64::MAX` for entries with TTL == 0 (no expiry).
63    #[inline]
64    pub fn remaining_secs(&self, now_secs: u64) -> u64 {
65        if self.ttl_secs == 0 {
66            return u64::MAX;
67        }
68        let deadline = self.inserted_at_secs.saturating_add(self.ttl_secs);
69        deadline.saturating_sub(now_secs)
70    }
71}
72
73// ── TtlCacheStats ─────────────────────────────────────────────────────────────
74
75/// Snapshot of [`TtlCache`] statistics.
76#[derive(Debug, Clone, Default)]
77pub struct TtlCacheStats {
78    /// Total number of entries in the underlying map (including expired ones
79    /// that have not yet been purged).
80    pub total_entries: usize,
81    /// Number of entries that are currently expired (lazy-not-yet-purged).
82    pub expired_entries: usize,
83    /// Cumulative successful lookups (non-expired key found).
84    pub hit_count: u64,
85    /// Cumulative failed lookups (key absent or expired).
86    pub miss_count: u64,
87}
88
89// ── TtlCache ──────────────────────────────────────────────────────────────────
90
91/// Capacity-bounded TTL cache.
92///
93/// Entries are evicted in FIFO insertion order when the cache reaches
94/// `capacity`.  Expired entries are returned as absent on [`get`] and can be
95/// bulk-purged via [`remove_expired`].
96///
97/// # Type parameters
98/// * `K` – key type; must implement `Eq + Hash + Clone`.
99/// * `V` – value type.
100///
101/// [`get`]: TtlCache::get
102/// [`remove_expired`]: TtlCache::remove_expired
103pub struct TtlCache<K, V>
104where
105    K: Eq + std::hash::Hash + Clone,
106{
107    capacity: usize,
108    default_ttl_secs: u64,
109    /// Primary storage.
110    entries: HashMap<K, TtlEntry<V>>,
111    /// Insertion-order record for FIFO eviction.
112    insertion_order: Vec<K>,
113    hit_count: u64,
114    miss_count: u64,
115}
116
117impl<K, V> TtlCache<K, V>
118where
119    K: Eq + std::hash::Hash + Clone,
120{
121    /// Create a new `TtlCache` with the given `capacity` and `default_ttl_secs`.
122    ///
123    /// A `default_ttl_secs` of `0` means entries inserted via [`insert`] never
124    /// expire; per-entry TTL overrides via [`insert_with_ttl`] still apply.
125    ///
126    /// # Panics
127    ///
128    /// Panics if `capacity == 0`.
129    ///
130    /// [`insert`]: TtlCache::insert
131    /// [`insert_with_ttl`]: TtlCache::insert_with_ttl
132    pub fn new(capacity: usize, default_ttl_secs: u64) -> Self {
133        assert!(capacity > 0, "TtlCache capacity must be non-zero");
134        Self {
135            capacity,
136            default_ttl_secs,
137            entries: HashMap::with_capacity(capacity.min(1024)),
138            insertion_order: Vec::with_capacity(capacity.min(1024)),
139            hit_count: 0,
140            miss_count: 0,
141        }
142    }
143
144    /// Insert a key-value pair using the cache's default TTL.
145    ///
146    /// If the key already exists it is overwritten.  When the cache is at
147    /// `capacity`, the oldest entry (by insertion order) is evicted first.
148    pub fn insert(&mut self, key: K, value: V, now_secs: u64) {
149        self.insert_with_ttl(key, value, self.default_ttl_secs, now_secs);
150    }
151
152    /// Insert a key-value pair with an explicit `ttl_secs` override.
153    ///
154    /// A `ttl_secs` of `0` means this specific entry never expires.
155    pub fn insert_with_ttl(&mut self, key: K, value: V, ttl_secs: u64, now_secs: u64) {
156        // If we're replacing an existing key, remove the old insertion-order record.
157        if self.entries.contains_key(&key) {
158            self.insertion_order.retain(|k| k != &key);
159        } else if self.entries.len() >= self.capacity {
160            // Evict the oldest non-replaced entry.
161            self.evict_oldest();
162        }
163
164        let entry = TtlEntry::new(value, now_secs, ttl_secs);
165        self.entries.insert(key.clone(), entry);
166        self.insertion_order.push(key);
167    }
168
169    /// Look up `key`.
170    ///
171    /// Returns `None` if the key is absent **or** if the entry has expired.
172    /// Expired entries are lazy: they remain in memory until either
173    /// [`remove_expired`] or a new insertion triggers eviction.
174    ///
175    /// [`remove_expired`]: TtlCache::remove_expired
176    pub fn get(&mut self, key: &K, now_secs: u64) -> Option<&V> {
177        match self.entries.get(key) {
178            None => {
179                self.miss_count += 1;
180                None
181            }
182            Some(entry) if entry.is_expired(now_secs) => {
183                self.miss_count += 1;
184                None
185            }
186            Some(entry) => {
187                self.hit_count += 1;
188                Some(&entry.value)
189            }
190        }
191    }
192
193    /// Explicitly remove an entry from the cache regardless of expiry.
194    ///
195    /// Returns `true` if the key was present (even if expired).
196    pub fn remove(&mut self, key: &K) -> bool {
197        if self.entries.remove(key).is_some() {
198            self.insertion_order.retain(|k| k != key);
199            true
200        } else {
201            false
202        }
203    }
204
205    /// Purge all expired entries and return the number removed.
206    pub fn remove_expired(&mut self, now_secs: u64) -> usize {
207        let expired_keys: Vec<K> = self
208            .entries
209            .iter()
210            .filter(|(_, e)| e.is_expired(now_secs))
211            .map(|(k, _)| k.clone())
212            .collect();
213
214        let count = expired_keys.len();
215        for k in &expired_keys {
216            self.entries.remove(k);
217        }
218        self.insertion_order.retain(|k| !expired_keys.contains(k));
219        count
220    }
221
222    /// Count of non-expired entries at `now_secs`.
223    pub fn len(&self, now_secs: u64) -> usize {
224        self.entries
225            .values()
226            .filter(|e| !e.is_expired(now_secs))
227            .count()
228    }
229
230    /// Returns `true` when there are no non-expired entries.
231    pub fn is_empty(&self, now_secs: u64) -> bool {
232        self.len(now_secs) == 0
233    }
234
235    /// Return a statistics snapshot for the given `now_secs`.
236    pub fn stats(&self, now_secs: u64) -> TtlCacheStats {
237        let total = self.entries.len();
238        let expired = self
239            .entries
240            .values()
241            .filter(|e| e.is_expired(now_secs))
242            .count();
243        TtlCacheStats {
244            total_entries: total,
245            expired_entries: expired,
246            hit_count: self.hit_count,
247            miss_count: self.miss_count,
248        }
249    }
250
251    /// Configured maximum number of entries.
252    #[inline]
253    pub fn capacity(&self) -> usize {
254        self.capacity
255    }
256
257    // ── private helpers ───────────────────────────────────────────────────────
258
259    /// Remove the first entry in insertion order (oldest).
260    fn evict_oldest(&mut self) {
261        if self.insertion_order.is_empty() {
262            return;
263        }
264        let oldest = self.insertion_order.remove(0);
265        self.entries.remove(&oldest);
266    }
267}
268
269// ── Tests ─────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_insert_and_get_within_ttl() {
277        let mut cache: TtlCache<&str, u32> = TtlCache::new(10, 60);
278        cache.insert("a", 42, 1000);
279        assert_eq!(cache.get(&"a", 1059), Some(&42));
280    }
281
282    #[test]
283    fn test_expired_entry_returns_none() {
284        let mut cache: TtlCache<&str, u32> = TtlCache::new(10, 30);
285        cache.insert("b", 99, 1000);
286        // exactly at deadline: expired
287        assert_eq!(cache.get(&"b", 1030), None);
288        // after deadline
289        assert_eq!(cache.get(&"b", 1100), None);
290    }
291
292    #[test]
293    fn test_entry_at_expiry_boundary() {
294        let mut cache: TtlCache<&str, u32> = TtlCache::new(10, 10);
295        cache.insert("c", 7, 500);
296        assert_eq!(cache.get(&"c", 509), Some(&7)); // 509 < 510
297        assert_eq!(cache.get(&"c", 510), None); // exactly at deadline
298    }
299
300    #[test]
301    fn test_remove_expired_count() {
302        let mut cache: TtlCache<u32, &str> = TtlCache::new(20, 5);
303        for i in 0..10 {
304            cache.insert(i, "v", 0);
305        }
306        // all expire at t=5
307        let purged = cache.remove_expired(5);
308        assert_eq!(purged, 10);
309        assert_eq!(cache.len(5), 0);
310    }
311
312    #[test]
313    fn test_remove_expired_partial() {
314        let mut cache: TtlCache<u32, u32> = TtlCache::new(20, 100);
315        // short-lived entries
316        for i in 0..5 {
317            cache.insert_with_ttl(i, i * 10, 10, 0);
318        }
319        // long-lived entries
320        for i in 5..10 {
321            cache.insert_with_ttl(i, i * 10, 1000, 0);
322        }
323        let purged = cache.remove_expired(11);
324        assert_eq!(purged, 5);
325        assert_eq!(cache.len(11), 5);
326    }
327
328    #[test]
329    fn test_per_entry_ttl_override() {
330        let mut cache: TtlCache<&str, u32> = TtlCache::new(10, 60);
331        cache.insert_with_ttl("short", 1, 5, 1000);
332        cache.insert("long", 2, 1000); // uses default 60s
333        assert_eq!(cache.get(&"short", 1006), None); // expired
334        assert_eq!(cache.get(&"long", 1059), Some(&2)); // still alive
335    }
336
337    #[test]
338    fn test_stats_hit_miss_expired() {
339        let mut cache: TtlCache<u32, u32> = TtlCache::new(10, 50);
340        cache.insert(1, 10, 0); // expires at t=50
341        cache.insert(2, 20, 0); // expires at t=50
342
343        let _ = cache.get(&1, 10); // hit (within TTL)
344        let _ = cache.get(&3, 10); // miss (absent)
345        let _ = cache.get(&2, 51); // miss (expired)
346
347        // At t=51 both entries are expired (inserted_at=0, ttl=50 → deadline=50).
348        let s = cache.stats(51);
349        assert_eq!(s.hit_count, 1);
350        assert_eq!(s.miss_count, 2);
351        // Both entries 1 and 2 are past their deadline; neither has been purged.
352        assert_eq!(s.expired_entries, 2);
353    }
354
355    #[test]
356    fn test_capacity_eviction_fifo() {
357        let mut cache: TtlCache<u32, u32> = TtlCache::new(3, 3600);
358        cache.insert(1, 1, 0);
359        cache.insert(2, 2, 0);
360        cache.insert(3, 3, 0);
361        // inserting 4th should evict key 1 (oldest)
362        cache.insert(4, 4, 0);
363        assert_eq!(cache.get(&1, 0), None);
364        assert_eq!(cache.get(&2, 0), Some(&2));
365        assert_eq!(cache.get(&3, 0), Some(&3));
366        assert_eq!(cache.get(&4, 0), Some(&4));
367    }
368
369    #[test]
370    fn test_zero_ttl_never_expires() {
371        let mut cache: TtlCache<&str, u32> = TtlCache::new(10, 0);
372        cache.insert("immortal", 55, 0);
373        // far in the future
374        assert_eq!(cache.get(&"immortal", u64::MAX / 2), Some(&55));
375    }
376
377    #[test]
378    fn test_overwrite_existing_key() {
379        let mut cache: TtlCache<&str, u32> = TtlCache::new(5, 100);
380        cache.insert("k", 1, 0);
381        cache.insert("k", 2, 0); // overwrite
382        assert_eq!(cache.get(&"k", 0), Some(&2));
383        // insertion order has only one record for "k" after overwrite
384        assert_eq!(cache.len(0), 1);
385    }
386
387    #[test]
388    fn test_remove_entry() {
389        let mut cache: TtlCache<u32, u32> = TtlCache::new(10, 100);
390        cache.insert(7, 77, 0);
391        assert!(cache.remove(&7));
392        assert!(!cache.remove(&7)); // already gone
393        assert_eq!(cache.get(&7, 0), None);
394    }
395
396    #[test]
397    fn test_is_empty() {
398        let mut cache: TtlCache<u32, u32> = TtlCache::new(4, 10);
399        assert!(cache.is_empty(0));
400        cache.insert(1, 1, 0);
401        assert!(!cache.is_empty(0));
402        assert!(cache.is_empty(11)); // all expired
403    }
404}