Skip to main content

oximedia_proxy/
proxy_cache.rs

1//! Proxy cache management: LRU eviction, TTL-based staleness, and utilisation tracking.
2//!
3//! Provides a simple in-memory cache that tracks proxy files by path, access
4//! time, and hit count.  Eviction strategies include LRU (least-recently used),
5//! TTL (time-to-live), and LFU (least-frequently used).
6
7#![allow(dead_code)]
8#![allow(missing_docs)]
9#![allow(clippy::cast_precision_loss)]
10
11// ---------------------------------------------------------------------------
12// CacheEntry
13// ---------------------------------------------------------------------------
14
15/// A single entry in the proxy cache.
16#[derive(Debug, Clone, PartialEq)]
17pub struct CacheEntry {
18    /// File-system path of the cached proxy.
19    pub path: String,
20    /// Size of the proxy file in bytes.
21    pub size_bytes: u64,
22    /// Unix timestamp (milliseconds) of the most recent access.
23    pub last_access_ms: u64,
24    /// Number of times this entry has been accessed.
25    pub hit_count: u32,
26}
27
28impl CacheEntry {
29    /// Create a new cache entry.
30    #[must_use]
31    pub fn new(path: impl Into<String>, size_bytes: u64, now_ms: u64) -> Self {
32        Self {
33            path: path.into(),
34            size_bytes,
35            last_access_ms: now_ms,
36            hit_count: 1,
37        }
38    }
39
40    /// Returns `true` when the entry has not been accessed within `ttl_ms` milliseconds.
41    ///
42    /// # Arguments
43    /// * `now_ms` – current time in Unix milliseconds.
44    /// * `ttl_ms` – time-to-live threshold in milliseconds.
45    #[must_use]
46    pub fn is_stale(&self, now_ms: u64, ttl_ms: u64) -> bool {
47        now_ms.saturating_sub(self.last_access_ms) > ttl_ms
48    }
49}
50
51// ---------------------------------------------------------------------------
52// CachePolicy
53// ---------------------------------------------------------------------------
54
55/// Eviction policy for the proxy cache.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CachePolicy {
58    /// Evict the least-recently-used entry.
59    Lru,
60    /// Evict entries that have exceeded their time-to-live.
61    Ttl,
62    /// Evict the least-frequently-used entry.
63    Lfu,
64}
65
66impl CachePolicy {
67    /// Human-readable description of the policy.
68    #[must_use]
69    pub fn description(&self) -> &str {
70        match self {
71            Self::Lru => "Least Recently Used (LRU): evict the entry not accessed longest",
72            Self::Ttl => "Time To Live (TTL): evict entries older than a configured threshold",
73            Self::Lfu => "Least Frequently Used (LFU): evict the entry with the fewest accesses",
74        }
75    }
76}
77
78// ---------------------------------------------------------------------------
79// ProxyCache
80// ---------------------------------------------------------------------------
81
82/// In-memory proxy cache with configurable capacity.
83#[derive(Debug)]
84pub struct ProxyCache {
85    /// All cached entries.
86    pub entries: Vec<CacheEntry>,
87    /// Maximum allowed total size of the cache in bytes.
88    pub max_size_bytes: u64,
89    /// Current total size of all entries in bytes.
90    pub used_bytes: u64,
91}
92
93impl ProxyCache {
94    /// Create a new, empty cache with the given maximum size.
95    #[must_use]
96    pub fn new(max_size_bytes: u64) -> Self {
97        Self {
98            entries: Vec::new(),
99            max_size_bytes,
100            used_bytes: 0,
101        }
102    }
103
104    /// Add a new entry to the cache.
105    ///
106    /// If an entry with the same path already exists it is replaced and the
107    /// used-byte count is updated accordingly.
108    pub fn add(&mut self, path: &str, size: u64, now_ms: u64) {
109        // Remove existing entry with the same path to avoid duplicates.
110        if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
111            let old_size = self.entries[pos].size_bytes;
112            self.entries.remove(pos);
113            self.used_bytes = self.used_bytes.saturating_sub(old_size);
114        }
115        self.entries.push(CacheEntry::new(path, size, now_ms));
116        self.used_bytes += size;
117    }
118
119    /// Update the access timestamp and hit count for an entry.
120    ///
121    /// Returns `true` if the entry was found and updated.
122    pub fn touch(&mut self, path: &str, now_ms: u64) -> bool {
123        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
124            entry.last_access_ms = now_ms;
125            entry.hit_count = entry.hit_count.saturating_add(1);
126            true
127        } else {
128            false
129        }
130    }
131
132    /// Remove the least-recently-used entry and return its path.
133    ///
134    /// Returns `None` if the cache is empty.
135    pub fn evict_lru(&mut self) -> Option<String> {
136        if self.entries.is_empty() {
137            return None;
138        }
139        // Find the index of the entry with the smallest `last_access_ms`.
140        let idx = self
141            .entries
142            .iter()
143            .enumerate()
144            .min_by_key(|(_, e)| e.last_access_ms)
145            .map(|(i, _)| i)?;
146
147        let removed = self.entries.remove(idx);
148        self.used_bytes = self.used_bytes.saturating_sub(removed.size_bytes);
149        Some(removed.path)
150    }
151
152    /// Remove all entries that are stale (last access older than `ttl_ms`).
153    ///
154    /// Returns the paths of all evicted entries.
155    pub fn evict_stale(&mut self, now_ms: u64, ttl_ms: u64) -> Vec<String> {
156        let mut evicted = Vec::new();
157        self.entries.retain(|e| {
158            if e.is_stale(now_ms, ttl_ms) {
159                evicted.push(e.path.clone());
160                false
161            } else {
162                true
163            }
164        });
165        // Update used_bytes: subtract sizes of evicted entries.
166        // We already removed them, so recalculate from remaining entries.
167        self.used_bytes = self.entries.iter().map(|e| e.size_bytes).sum();
168        evicted
169    }
170
171    /// Cache utilisation as a fraction in `[0.0, 1.0]`.
172    ///
173    /// Returns `0.0` when `max_size_bytes` is 0.
174    #[must_use]
175    pub fn utilization(&self) -> f64 {
176        if self.max_size_bytes == 0 {
177            return 0.0;
178        }
179        (self.used_bytes as f64 / self.max_size_bytes as f64).min(1.0)
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Unit tests
185// ---------------------------------------------------------------------------
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_cache_entry_is_stale_fresh() {
193        let entry = CacheEntry::new("p.mp4", 1024, 1_000);
194        // TTL 5 000 ms, now 2 000 ms → only 1 000 ms old → not stale
195        assert!(!entry.is_stale(2_000, 5_000));
196    }
197
198    #[test]
199    fn test_cache_entry_is_stale_expired() {
200        let entry = CacheEntry::new("p.mp4", 1024, 0);
201        // TTL 500 ms, now 1 000 ms → 1 000 ms old → stale
202        assert!(entry.is_stale(1_000, 500));
203    }
204
205    #[test]
206    fn test_cache_entry_is_stale_exactly_at_ttl() {
207        let entry = CacheEntry::new("p.mp4", 1024, 0);
208        // Exactly at TTL boundary → not stale (> rather than >=)
209        assert!(!entry.is_stale(500, 500));
210    }
211
212    #[test]
213    fn test_cache_policy_descriptions_non_empty() {
214        assert!(!CachePolicy::Lru.description().is_empty());
215        assert!(!CachePolicy::Ttl.description().is_empty());
216        assert!(!CachePolicy::Lfu.description().is_empty());
217    }
218
219    #[test]
220    fn test_proxy_cache_add_single() {
221        let mut cache = ProxyCache::new(1_000_000);
222        cache.add("a.mp4", 100, 1_000);
223        assert_eq!(cache.entries.len(), 1);
224        assert_eq!(cache.used_bytes, 100);
225    }
226
227    #[test]
228    fn test_proxy_cache_add_replaces_existing() {
229        let mut cache = ProxyCache::new(1_000_000);
230        cache.add("a.mp4", 100, 1_000);
231        cache.add("a.mp4", 200, 2_000);
232        assert_eq!(cache.entries.len(), 1);
233        assert_eq!(cache.used_bytes, 200);
234    }
235
236    #[test]
237    fn test_proxy_cache_touch_updates_access() {
238        let mut cache = ProxyCache::new(1_000_000);
239        cache.add("a.mp4", 100, 1_000);
240        let updated = cache.touch("a.mp4", 5_000);
241        assert!(updated);
242        assert_eq!(cache.entries[0].last_access_ms, 5_000);
243        assert_eq!(cache.entries[0].hit_count, 2);
244    }
245
246    #[test]
247    fn test_proxy_cache_touch_missing_returns_false() {
248        let mut cache = ProxyCache::new(1_000_000);
249        assert!(!cache.touch("missing.mp4", 1_000));
250    }
251
252    #[test]
253    fn test_proxy_cache_evict_lru_empty() {
254        let mut cache = ProxyCache::new(1_000_000);
255        assert!(cache.evict_lru().is_none());
256    }
257
258    #[test]
259    fn test_proxy_cache_evict_lru_removes_oldest() {
260        let mut cache = ProxyCache::new(1_000_000);
261        cache.add("old.mp4", 100, 1_000);
262        cache.add("new.mp4", 100, 9_000);
263        let evicted = cache.evict_lru();
264        assert_eq!(evicted, Some("old.mp4".to_string()));
265        assert_eq!(cache.entries.len(), 1);
266    }
267
268    #[test]
269    fn test_proxy_cache_evict_stale() {
270        let mut cache = ProxyCache::new(1_000_000);
271        cache.add("stale.mp4", 100, 0);
272        cache.add("fresh.mp4", 200, 9_000);
273        let evicted = cache.evict_stale(10_000, 5_000);
274        assert_eq!(evicted.len(), 1);
275        assert_eq!(evicted[0], "stale.mp4");
276        assert_eq!(cache.used_bytes, 200);
277    }
278
279    #[test]
280    fn test_proxy_cache_utilization() {
281        let mut cache = ProxyCache::new(1_000);
282        cache.add("a.mp4", 500, 0);
283        let u = cache.utilization();
284        assert!((u - 0.5).abs() < 1e-9);
285    }
286
287    #[test]
288    fn test_proxy_cache_utilization_zero_max() {
289        let cache = ProxyCache::new(0);
290        assert_eq!(cache.utilization(), 0.0);
291    }
292}
293
294// ============================================================================
295// Disk-Bounded LRU Cache Manager
296// ============================================================================
297
298/// Statistics snapshot for a `DiskBoundedCache`.
299#[derive(Debug, Clone)]
300pub struct DiskCacheStats {
301    /// Number of entries currently in the cache.
302    pub entry_count: usize,
303    /// Total bytes occupied by cached proxies.
304    pub used_bytes: u64,
305    /// Maximum allowed bytes.
306    pub max_bytes: u64,
307    /// Cache utilisation fraction in `[0.0, 1.0]`.
308    pub utilization: f64,
309    /// Total number of LRU evictions performed since construction.
310    pub eviction_count: u64,
311    /// Total number of successful insertions.
312    pub insertion_count: u64,
313    /// Total number of cache hits (access to an existing entry).
314    pub hit_count: u64,
315    /// Total number of cache misses (lookup that found nothing).
316    pub miss_count: u64,
317}
318
319/// A disk-space-bounded LRU proxy cache that automatically evicts entries
320/// when the configured space limit would be exceeded.
321///
322/// Unlike the simpler `ProxyCache`, `DiskBoundedCache`:
323/// * Enforces a hard disk-space ceiling on insertions.
324/// * Uses LRU eviction automatically upon every insertion that would violate the limit.
325/// * Maintains hit/miss/eviction counters for telemetry.
326/// * Supports lookup with automatic LRU touch.
327#[derive(Debug)]
328pub struct DiskBoundedCache {
329    /// Inner ordered list of entries; oldest-accessed first (front = LRU end).
330    entries: std::collections::VecDeque<CacheEntry>,
331    /// Maximum allowed total size in bytes.
332    max_bytes: u64,
333    /// Current total size in bytes.
334    used_bytes: u64,
335    /// Cumulative eviction count.
336    eviction_count: u64,
337    /// Cumulative insertion count.
338    insertion_count: u64,
339    /// Cumulative hit count.
340    hit_count: u64,
341    /// Cumulative miss count.
342    miss_count: u64,
343}
344
345impl DiskBoundedCache {
346    /// Create a new cache with a disk-space limit of `max_bytes`.
347    ///
348    /// # Errors
349    ///
350    /// Returns a descriptive string if `max_bytes` is zero.
351    pub fn new(max_bytes: u64) -> Result<Self, String> {
352        if max_bytes == 0 {
353            return Err("DiskBoundedCache: max_bytes must be > 0".to_string());
354        }
355        Ok(Self {
356            entries: std::collections::VecDeque::new(),
357            max_bytes,
358            used_bytes: 0,
359            eviction_count: 0,
360            insertion_count: 0,
361            hit_count: 0,
362            miss_count: 0,
363        })
364    }
365
366    /// Insert a proxy entry identified by `path` with `size_bytes` and access time `now_ms`.
367    ///
368    /// If an entry with the same path already exists it is updated in-place (moved to the
369    /// MRU end).  Before inserting, LRU entries are evicted until the new entry fits within
370    /// the space limit.  If the single entry itself is larger than the limit, the insert
371    /// is rejected and `false` is returned.
372    ///
373    /// Returns `true` when the entry was inserted/updated, `false` when it was rejected.
374    pub fn insert(&mut self, path: &str, size_bytes: u64, now_ms: u64) -> bool {
375        if size_bytes > self.max_bytes {
376            return false;
377        }
378
379        // If the path already exists, remove the old entry first
380        if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
381            let old_size = self.entries[pos].size_bytes;
382            self.entries.remove(pos);
383            self.used_bytes = self.used_bytes.saturating_sub(old_size);
384        }
385
386        // Evict LRU entries until there is room for the new entry
387        while !self.entries.is_empty() && self.used_bytes + size_bytes > self.max_bytes {
388            // Front of VecDeque is the LRU end
389            if let Some(evicted) = self.entries.pop_front() {
390                self.used_bytes = self.used_bytes.saturating_sub(evicted.size_bytes);
391                self.eviction_count += 1;
392            }
393        }
394
395        // Push the new entry to the MRU end (back)
396        self.entries
397            .push_back(CacheEntry::new(path, size_bytes, now_ms));
398        self.used_bytes += size_bytes;
399        self.insertion_count += 1;
400        true
401    }
402
403    /// Look up a cache entry by path.
404    ///
405    /// On hit: moves the entry to the MRU position and updates access time.
406    /// On miss: increments the miss counter.
407    ///
408    /// Returns a clone of the entry on hit, or `None` on miss.
409    pub fn access(&mut self, path: &str, now_ms: u64) -> Option<CacheEntry> {
410        if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
411            // Move to MRU end
412            if let Some(mut entry) = self.entries.remove(pos) {
413                entry.last_access_ms = now_ms;
414                entry.hit_count = entry.hit_count.saturating_add(1);
415                let clone = entry.clone();
416                self.entries.push_back(entry);
417                self.hit_count += 1;
418                Some(clone)
419            } else {
420                // Position was found but remove returned None — should not happen
421                self.miss_count += 1;
422                None
423            }
424        } else {
425            self.miss_count += 1;
426            None
427        }
428    }
429
430    /// Remove and return the path of the least-recently-used entry.
431    ///
432    /// Returns `None` if the cache is empty.
433    pub fn evict_lru(&mut self) -> Option<String> {
434        self.entries.pop_front().map(|e| {
435            self.used_bytes = self.used_bytes.saturating_sub(e.size_bytes);
436            self.eviction_count += 1;
437            e.path
438        })
439    }
440
441    /// Remove all entries, returning their paths.
442    pub fn clear(&mut self) -> Vec<String> {
443        let paths: Vec<String> = self.entries.iter().map(|e| e.path.clone()).collect();
444        self.entries.clear();
445        self.used_bytes = 0;
446        self.eviction_count += paths.len() as u64;
447        paths
448    }
449
450    /// Whether the cache contains an entry for `path`.
451    pub fn contains(&self, path: &str) -> bool {
452        self.entries.iter().any(|e| e.path == path)
453    }
454
455    /// Number of entries in the cache.
456    pub fn len(&self) -> usize {
457        self.entries.len()
458    }
459
460    /// Whether the cache is empty.
461    pub fn is_empty(&self) -> bool {
462        self.entries.is_empty()
463    }
464
465    /// Current total used bytes.
466    pub fn used_bytes(&self) -> u64 {
467        self.used_bytes
468    }
469
470    /// Maximum allowed bytes.
471    pub fn max_bytes(&self) -> u64 {
472        self.max_bytes
473    }
474
475    /// Cache utilisation fraction in `[0.0, 1.0]`.
476    pub fn utilization(&self) -> f64 {
477        (self.used_bytes as f64 / self.max_bytes as f64).min(1.0)
478    }
479
480    /// Collect and return a statistics snapshot.
481    pub fn stats(&self) -> DiskCacheStats {
482        DiskCacheStats {
483            entry_count: self.entries.len(),
484            used_bytes: self.used_bytes,
485            max_bytes: self.max_bytes,
486            utilization: self.utilization(),
487            eviction_count: self.eviction_count,
488            insertion_count: self.insertion_count,
489            hit_count: self.hit_count,
490            miss_count: self.miss_count,
491        }
492    }
493}
494
495#[cfg(test)]
496mod disk_bounded_tests {
497    use super::*;
498
499    fn make_cache(max_bytes: u64) -> DiskBoundedCache {
500        DiskBoundedCache::new(max_bytes).expect("valid max_bytes")
501    }
502
503    #[test]
504    fn test_new_rejects_zero_max() {
505        assert!(DiskBoundedCache::new(0).is_err());
506    }
507
508    #[test]
509    fn test_insert_single_entry() {
510        let mut cache = make_cache(1_000);
511        assert!(cache.insert("a.mp4", 100, 1_000));
512        assert_eq!(cache.len(), 1);
513        assert_eq!(cache.used_bytes(), 100);
514    }
515
516    #[test]
517    fn test_insert_over_limit_rejected() {
518        let mut cache = make_cache(500);
519        assert!(!cache.insert("huge.mp4", 1_000, 1_000));
520        assert!(cache.is_empty());
521    }
522
523    #[test]
524    fn test_lru_eviction_on_insert() {
525        let mut cache = make_cache(200);
526        cache.insert("a.mp4", 100, 1_000);
527        cache.insert("b.mp4", 100, 2_000);
528        // Both fit; total = 200
529        assert_eq!(cache.len(), 2);
530        // Insert a third entry that requires eviction of LRU ("a.mp4")
531        cache.insert("c.mp4", 100, 3_000);
532        assert_eq!(cache.len(), 2);
533        assert!(!cache.contains("a.mp4"), "a.mp4 should have been evicted");
534        assert!(cache.contains("b.mp4"));
535        assert!(cache.contains("c.mp4"));
536        assert_eq!(cache.stats().eviction_count, 1);
537    }
538
539    #[test]
540    fn test_access_hit_moves_to_mru() {
541        // max_bytes=200 so two 100-byte entries fill the cache completely.
542        // Inserting a third entry must evict the LRU one.
543        let mut cache = make_cache(200);
544        cache.insert("a.mp4", 100, 1_000);
545        cache.insert("b.mp4", 100, 2_000);
546        // Access "a.mp4" → it becomes MRU; "b.mp4" becomes LRU
547        let entry = cache.access("a.mp4", 5_000).expect("hit expected");
548        assert_eq!(entry.path, "a.mp4");
549
550        // Insert "c.mp4" → should evict "b.mp4" (now LRU), not "a.mp4"
551        cache.insert("c.mp4", 100, 6_000);
552        assert!(!cache.contains("b.mp4"), "b.mp4 should be evicted");
553        assert!(cache.contains("a.mp4"), "a.mp4 is MRU, should survive");
554    }
555
556    #[test]
557    fn test_access_miss_increments_counter() {
558        let mut cache = make_cache(1_000);
559        let result = cache.access("nonexistent.mp4", 1_000);
560        assert!(result.is_none());
561        assert_eq!(cache.stats().miss_count, 1);
562    }
563
564    #[test]
565    fn test_hit_count_increments() {
566        let mut cache = make_cache(1_000);
567        cache.insert("a.mp4", 100, 1_000);
568        cache.access("a.mp4", 2_000);
569        cache.access("a.mp4", 3_000);
570        assert_eq!(cache.stats().hit_count, 2);
571    }
572
573    #[test]
574    fn test_update_existing_entry() {
575        let mut cache = make_cache(500);
576        cache.insert("a.mp4", 200, 1_000);
577        // Update with new size
578        cache.insert("a.mp4", 300, 2_000);
579        assert_eq!(cache.len(), 1);
580        assert_eq!(cache.used_bytes(), 300);
581    }
582
583    #[test]
584    fn test_evict_lru_explicit() {
585        let mut cache = make_cache(500);
586        cache.insert("old.mp4", 100, 1_000);
587        cache.insert("new.mp4", 100, 9_000);
588        let evicted = cache.evict_lru();
589        assert_eq!(evicted, Some("old.mp4".to_string()));
590        assert_eq!(cache.len(), 1);
591        assert_eq!(cache.used_bytes(), 100);
592    }
593
594    #[test]
595    fn test_evict_lru_empty_returns_none() {
596        let mut cache = make_cache(500);
597        assert!(cache.evict_lru().is_none());
598    }
599
600    #[test]
601    fn test_clear() {
602        let mut cache = make_cache(1_000);
603        cache.insert("a.mp4", 100, 1_000);
604        cache.insert("b.mp4", 200, 2_000);
605        let cleared = cache.clear();
606        assert_eq!(cleared.len(), 2);
607        assert!(cache.is_empty());
608        assert_eq!(cache.used_bytes(), 0);
609    }
610
611    #[test]
612    fn test_utilization() {
613        let mut cache = make_cache(1_000);
614        cache.insert("a.mp4", 500, 1_000);
615        assert!((cache.utilization() - 0.5).abs() < 1e-9);
616    }
617
618    #[test]
619    fn test_stats_fields() {
620        let mut cache = make_cache(1_000);
621        cache.insert("a.mp4", 100, 1_000);
622        cache.insert("b.mp4", 100, 2_000);
623        cache.access("a.mp4", 3_000);
624        cache.access("missing.mp4", 4_000);
625        let s = cache.stats();
626        assert_eq!(s.entry_count, 2);
627        assert_eq!(s.insertion_count, 2);
628        assert_eq!(s.hit_count, 1);
629        assert_eq!(s.miss_count, 1);
630    }
631
632    #[test]
633    fn test_rapid_create_evict_cycles() {
634        // Stress: 1000 inserts into a small cache (holds ≤5 entries)
635        let mut cache = make_cache(500);
636        let mut total_evictions = 0u64;
637        for i in 0..1_000u64 {
638            let path = format!("proxy_{i}.mp4");
639            cache.insert(&path, 100, i * 10);
640            total_evictions = cache.stats().eviction_count;
641        }
642        // Should have evicted many entries but remain within limits
643        assert!(cache.used_bytes() <= 500);
644        assert!(
645            total_evictions > 900,
646            "expected many evictions, got {total_evictions}"
647        );
648    }
649
650    #[test]
651    fn test_multiple_evictions_per_insert() {
652        // Insert 10 small entries, then one large one that forces many evictions
653        let mut cache = make_cache(1_000);
654        for i in 0..10u64 {
655            cache.insert(&format!("{i}.mp4"), 100, i);
656        }
657        assert_eq!(cache.len(), 10);
658        // Insert one 600-byte entry: must evict 6 old ones to make room
659        cache.insert("big.mp4", 600, 100);
660        assert!(cache.used_bytes() <= 1_000);
661        assert!(cache.contains("big.mp4"));
662    }
663}