oximedia_proxy/
proxy_cache.rs1#![allow(dead_code)]
8#![allow(missing_docs)]
9#![allow(clippy::cast_precision_loss)]
10
11#[derive(Debug, Clone, PartialEq)]
17pub struct CacheEntry {
18 pub path: String,
20 pub size_bytes: u64,
22 pub last_access_ms: u64,
24 pub hit_count: u32,
26}
27
28impl CacheEntry {
29 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CachePolicy {
58 Lru,
60 Ttl,
62 Lfu,
64}
65
66impl CachePolicy {
67 #[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#[derive(Debug)]
84pub struct ProxyCache {
85 pub entries: Vec<CacheEntry>,
87 pub max_size_bytes: u64,
89 pub used_bytes: u64,
91}
92
93impl ProxyCache {
94 #[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 pub fn add(&mut self, path: &str, size: u64, now_ms: u64) {
109 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 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 pub fn evict_lru(&mut self) -> Option<String> {
136 if self.entries.is_empty() {
137 return None;
138 }
139 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 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 self.used_bytes = self.entries.iter().map(|e| e.size_bytes).sum();
168 evicted
169 }
170
171 #[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#[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 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 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 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}