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}