oximedia_cache/content_aware_cache.rs
1//! Media-content-aware caching.
2//!
3//! Standard eviction policies treat all entries equally. For multimedia
4//! workloads, different content types have very different access patterns
5//! and cost/benefit trade-offs:
6//!
7//! * Manifests are tiny but highly re-fetched → very high priority, short TTL.
8//! * Video segments are large and often sequential → medium priority, long TTL.
9//! * Thumbnails are small and rarely re-fetched but cheap → very long TTL.
10//!
11//! [`ContentAwareCache`] layers this domain knowledge on top of
12//! [`LruCache`] so that eviction candidates are
13//! scored by a combined recency × priority × size-efficiency metric rather
14//! than pure LRU order.
15
16use std::collections::HashMap;
17use std::time::{Duration, Instant};
18
19use crate::lru_cache::LruCache;
20
21// ── MediaContentType ──────────────────────────────────────────────────────────
22
23/// The media type of a cached entry.
24///
25/// The variant determines the default priority, TTL, and eviction score.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum MediaContentType {
28 /// A video segment (e.g. MPEG-DASH chunk or HLS `.ts` file).
29 VideoSegment {
30 /// Encoded bitrate in bits per second.
31 bitrate: u32,
32 /// Codec name (e.g. `"av1"`, `"vp9"`).
33 codec: String,
34 },
35 /// An audio-only segment.
36 AudioSegment {
37 /// Encoded bitrate in bits per second.
38 bitrate: u32,
39 },
40 /// A still image (e.g. JPEG or PNG frame).
41 Image {
42 /// Width in pixels.
43 width: u32,
44 /// Height in pixels.
45 height: u32,
46 },
47 /// A streaming manifest / playlist (e.g. HLS `.m3u8` or DASH `.mpd`).
48 Manifest,
49 /// A thumbnail preview image.
50 Thumbnail,
51 /// Lightweight metadata / sidecar (e.g. `.json` or `.xml` descriptor).
52 Metadata,
53}
54
55// ── ContentCachePriority ──────────────────────────────────────────────────────
56
57/// Numeric priority (higher = more important to keep in cache).
58///
59/// Derived from [`MediaContentType`] via [`ContentCachePriority::for_type`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub struct ContentCachePriority(pub u8);
62
63impl ContentCachePriority {
64 /// Compute the priority for the given content type.
65 ///
66 /// | Content type | Priority |
67 /// |-------------------------------------|----------|
68 /// | Manifest | 10 |
69 /// | Thumbnail | 8 |
70 /// | VideoSegment (bitrate ≥ 4 Mbps) | 7 |
71 /// | VideoSegment (bitrate < 4 Mbps) | 6 |
72 /// | AudioSegment | 5 |
73 /// | Image | 4 |
74 /// | Metadata | 3 |
75 pub fn for_type(content_type: &MediaContentType) -> Self {
76 let p = match content_type {
77 MediaContentType::Manifest => 10,
78 MediaContentType::Thumbnail => 8,
79 MediaContentType::VideoSegment { bitrate, .. } => {
80 if *bitrate >= 4_000_000 {
81 7
82 } else {
83 6
84 }
85 }
86 MediaContentType::AudioSegment { .. } => 5,
87 MediaContentType::Image { .. } => 4,
88 MediaContentType::Metadata => 3,
89 };
90 Self(p)
91 }
92}
93
94// ── CacheEntry ────────────────────────────────────────────────────────────────
95
96/// A single entry held in [`ContentAwareCache`].
97#[derive(Debug, Clone)]
98pub struct CacheEntry {
99 /// The cache key.
100 pub key: String,
101 /// Raw payload bytes.
102 pub data: Vec<u8>,
103 /// Media content type (determines priority and TTL).
104 pub content_type: MediaContentType,
105 /// Wall-clock time at which this entry was first inserted.
106 pub inserted_at: Instant,
107 /// Wall-clock time of the most recent successful lookup.
108 pub last_accessed: Instant,
109 /// Total number of successful lookups since insertion.
110 pub access_count: u32,
111 /// `data.len()` cached to avoid recomputation.
112 pub size_bytes: usize,
113}
114
115impl CacheEntry {
116 /// Create a new entry.
117 fn new(key: String, data: Vec<u8>, content_type: MediaContentType) -> Self {
118 let size = data.len();
119 let now = Instant::now();
120 Self {
121 key,
122 data,
123 content_type,
124 inserted_at: now,
125 last_accessed: now,
126 access_count: 0,
127 size_bytes: size,
128 }
129 }
130
131 /// Compute the eviction score for this entry using default weights.
132 ///
133 /// A higher score means the entry is a *better* eviction candidate (i.e.
134 /// it should be evicted first).
135 ///
136 /// ```text
137 /// score = (1.0 - recency) × (1.0 / priority) × size_factor
138 /// ```
139 ///
140 /// Where:
141 /// * `recency = e^(-age_secs / 60.0)` — exponential decay over 1 minute.
142 /// * `priority = ContentCachePriority::for_type(content_type).0` cast to f32.
143 /// * `size_factor = size_bytes / 1_048_576.0 + 1.0` — larger entries score
144 /// higher (give bigger bang for the eviction buck).
145 pub fn score_for_eviction(&self) -> f32 {
146 self.score_for_eviction_weighted(&ScoringWeights::default())
147 }
148
149 /// Compute the eviction score using configurable [`ScoringWeights`].
150 ///
151 /// ```text
152 /// score = (1 - recency).powf(w.recency_exp)
153 /// × (1 / priority).powf(w.priority_exp)
154 /// × size_factor.powf(w.size_exp)
155 /// ```
156 ///
157 /// Default weights (`recency_exp = priority_exp = size_exp = 1.0`)
158 /// reproduce the original behaviour exactly.
159 pub fn score_for_eviction_weighted(&self, w: &ScoringWeights) -> f32 {
160 let age_secs = self.last_accessed.elapsed().as_secs_f64();
161 let recency = (-age_secs / 60.0_f64).exp(); // 1.0 when just accessed, → 0 with age
162 let base_priority = ContentCachePriority::for_type(&self.content_type).0 as f64;
163 let multiplier = w.priority_multiplier(&self.content_type);
164 let effective_priority = (base_priority * multiplier).max(0.001);
165 let size_factor = self.size_bytes as f64 / 1_048_576.0 + 1.0;
166 let score = (1.0 - recency).powf(w.recency_exp)
167 * (1.0 / effective_priority).powf(w.priority_exp)
168 * size_factor.powf(w.size_exp);
169 score as f32
170 }
171}
172
173// ── TTL helpers ───────────────────────────────────────────────────────────────
174
175/// Return the recommended TTL for the given content type.
176///
177/// | Content type | TTL |
178/// |-----------------------|------------|
179/// | Manifest | 30 s |
180/// | VideoSegment | 300 s (5 min) |
181/// | AudioSegment | 300 s |
182/// | Image | 3 600 s (1 h) |
183/// | Thumbnail | 86 400 s (24 h) |
184/// | Metadata | 600 s (10 min) |
185pub fn ttl_for_type(content_type: &MediaContentType) -> Duration {
186 match content_type {
187 MediaContentType::Manifest => Duration::from_secs(30),
188 MediaContentType::VideoSegment { .. } => Duration::from_secs(300),
189 MediaContentType::AudioSegment { .. } => Duration::from_secs(300),
190 MediaContentType::Image { .. } => Duration::from_secs(3_600),
191 MediaContentType::Thumbnail => Duration::from_secs(86_400),
192 MediaContentType::Metadata => Duration::from_secs(600),
193 }
194}
195
196// ── ScoringWeights ────────────────────────────────────────────────────────────
197
198/// Configurable exponent weights used by `score_for_eviction`.
199///
200/// The eviction score formula is:
201/// ```text
202/// score = (1 - recency).powf(recency_exp)
203/// × (1/priority).powf(priority_exp)
204/// × size_factor.powf(size_exp)
205/// ```
206///
207/// Defaults (`recency_exp = 1.0`, `priority_exp = 1.0`, `size_exp = 1.0`)
208/// reproduce the original behaviour exactly.
209///
210/// `per_type_priority` overrides the `priority` weight multiplier per
211/// [`MediaContentType`] variant; keys are matched by discriminant tag, so
212/// only the variant kind matters (e.g. all `VideoSegment` variants share one
213/// entry).
214#[derive(Debug, Clone)]
215pub struct ScoringWeights {
216 /// Exponent applied to the recency factor `(1 - recency)`. Higher = aged
217 /// entries are penalised more aggressively.
218 pub recency_exp: f64,
219 /// Exponent applied to `(1 / priority)`. Higher = low-priority items are
220 /// more strongly preferred for eviction.
221 pub priority_exp: f64,
222 /// Exponent applied to `size_factor`. Higher = large entries are more
223 /// strongly preferred for eviction.
224 pub size_exp: f64,
225 /// Per-type priority weight override. When a content type has an entry
226 /// here, its `priority` value is multiplied by this factor before the
227 /// scoring formula is applied. Values < 1.0 make the type *harder* to
228 /// evict; values > 1.0 make it *easier* to evict.
229 pub per_type_priority: HashMap<String, f64>,
230}
231
232impl Default for ScoringWeights {
233 fn default() -> Self {
234 Self {
235 recency_exp: 1.0,
236 priority_exp: 1.0,
237 size_exp: 1.0,
238 per_type_priority: HashMap::new(),
239 }
240 }
241}
242
243impl ScoringWeights {
244 /// Create default weights that reproduce the original eviction behaviour.
245 pub fn new() -> Self {
246 Self::default()
247 }
248
249 /// Return the per-type priority multiplier for the given content type.
250 fn type_key(content_type: &MediaContentType) -> &'static str {
251 match content_type {
252 MediaContentType::VideoSegment { .. } => "VideoSegment",
253 MediaContentType::AudioSegment { .. } => "AudioSegment",
254 MediaContentType::Image { .. } => "Image",
255 MediaContentType::Manifest => "Manifest",
256 MediaContentType::Thumbnail => "Thumbnail",
257 MediaContentType::Metadata => "Metadata",
258 }
259 }
260
261 /// Look up the priority multiplier for a content type.
262 pub fn priority_multiplier(&self, content_type: &MediaContentType) -> f64 {
263 let key = Self::type_key(content_type);
264 self.per_type_priority.get(key).copied().unwrap_or(1.0)
265 }
266}
267
268// ── ContentAwareCache ─────────────────────────────────────────────────────────
269
270/// A media-content-aware cache that scores eviction candidates by a
271/// recency × priority × size metric rather than pure LRU order.
272///
273/// Internally, it delegates storage to an [`LruCache<String, CacheEntry>`] for
274/// O(1) operations and maintains a live count of bytes.
275pub struct ContentAwareCache {
276 inner: LruCache<String, CacheEntry>,
277 /// Capacity in number of entries.
278 capacity: usize,
279 /// Current total size of all resident entries in bytes.
280 total_bytes: usize,
281 /// Optional byte-level capacity; entries whose cumulative size exceeds
282 /// this trigger an additional content-aware eviction pass.
283 max_bytes: Option<usize>,
284 /// Configurable scoring weights used by `score_for_eviction`.
285 scoring_weights: ScoringWeights,
286}
287
288impl ContentAwareCache {
289 /// Create a new `ContentAwareCache` with an entry-count `capacity`.
290 pub fn new(capacity: usize) -> Self {
291 Self {
292 inner: LruCache::new(capacity),
293 capacity,
294 total_bytes: 0,
295 max_bytes: None,
296 scoring_weights: ScoringWeights::default(),
297 }
298 }
299
300 /// Set an optional byte-level capacity in addition to the entry count cap.
301 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
302 self.max_bytes = Some(max_bytes);
303 self
304 }
305
306 /// Set custom scoring weights used for eviction candidate selection.
307 pub fn with_scoring_weights(mut self, weights: ScoringWeights) -> Self {
308 self.scoring_weights = weights;
309 self
310 }
311
312 /// Update scoring weights at runtime.
313 pub fn set_scoring_weights(&mut self, weights: ScoringWeights) {
314 self.scoring_weights = weights;
315 }
316
317 /// Return a reference to the current scoring weights.
318 pub fn scoring_weights(&self) -> &ScoringWeights {
319 &self.scoring_weights
320 }
321
322 // ── Insertion ─────────────────────────────────────────────────────────────
323
324 /// Insert a media entry.
325 ///
326 /// If the cache is at capacity, the entry with the highest eviction score
327 /// is removed first. If the new entry has a higher priority than the
328 /// current worst entry, it displaces it; otherwise the LRU entry is used
329 /// as the fallback.
330 pub fn insert_media(&mut self, key: String, data: Vec<u8>, content_type: MediaContentType) {
331 let size = data.len();
332
333 // If the key already exists, remove the old size first.
334 if let Some(old) = self.inner.remove(&key) {
335 self.total_bytes = self.total_bytes.saturating_sub(old.size_bytes);
336 }
337
338 // Enforce byte-level capacity if configured.
339 if let Some(max_bytes) = self.max_bytes {
340 while self.total_bytes + size > max_bytes && !self.inner.is_empty() {
341 self.evict_worst();
342 }
343 }
344
345 // Enforce entry-count capacity.
346 if self.inner.len() >= self.capacity {
347 self.evict_worst();
348 }
349
350 let entry = CacheEntry::new(key.clone(), data, content_type);
351 self.total_bytes += size;
352 self.inner.insert(key, entry, size);
353 }
354
355 // ── Retrieval ─────────────────────────────────────────────────────────────
356
357 /// Look up `key` and return an immutable reference to its [`CacheEntry`]
358 /// if present (updating last-accessed time).
359 pub fn get(&mut self, key: &str) -> Option<&CacheEntry> {
360 // We need to update last_accessed but LruCache::get only returns &V.
361 // We do a two-step: get (to update LRU order) then update the entry.
362 let key_owned = key.to_string();
363 if self.inner.contains(&key_owned) {
364 // Touch the entry to update `last_accessed` and `access_count`.
365 // We use `peek` to get a reference without a second LRU move, then
366 // perform a targeted update via `insert` (which handles duplicates).
367 let updated_entry = {
368 let entry = self.inner.peek(&key_owned)?;
369 let mut e = entry.clone();
370 e.last_accessed = Instant::now();
371 e.access_count = e.access_count.saturating_add(1);
372 e
373 };
374 let size = updated_entry.size_bytes;
375 // Re-insert to move to MRU head and persist the updated timestamps.
376 // Adjust total_bytes: remove old, add back same size.
377 self.total_bytes = self.total_bytes.saturating_sub(size);
378 self.inner.insert(key_owned.clone(), updated_entry, size);
379 self.total_bytes += size;
380 self.inner.peek(&key_owned)
381 } else {
382 None
383 }
384 }
385
386 /// Peek at `key` without updating access metadata or LRU order.
387 pub fn peek(&self, key: &str) -> Option<&CacheEntry> {
388 self.inner.peek(&key.to_string())
389 }
390
391 // ── Removal ───────────────────────────────────────────────────────────────
392
393 /// Explicitly remove an entry by `key`.
394 ///
395 /// Returns `true` if the entry was present.
396 pub fn remove(&mut self, key: &str) -> bool {
397 if let Some(entry) = self.inner.remove(&key.to_string()) {
398 self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
399 true
400 } else {
401 false
402 }
403 }
404
405 // ── TTL expiry ────────────────────────────────────────────────────────────
406
407 /// Scan all entries and remove those whose TTL has elapsed.
408 ///
409 /// Returns the number of entries evicted.
410 ///
411 /// This is an O(n) operation; callers should invoke it periodically rather
412 /// than on every access.
413 pub fn evict_expired(&mut self) -> usize {
414 // Collect expired keys — we cannot mutate inner while iterating.
415 // We use a drain-based approach: repeatedly pop the LRU until we find a
416 // non-expired entry or exhaust the cache. This is O(n) worst case but
417 // is acceptable for a maintenance sweep.
418 //
419 // A more efficient approach would require direct iteration over the
420 // backing HashMap; that's not exposed by LruCache, so we collect keys
421 // via `peek` patterns. Instead we collect entries into a scratch vec.
422 let mut expired_keys: Vec<String> = Vec::new();
423 let mut remaining: Vec<(String, CacheEntry)> = Vec::new();
424
425 // Drain by repeated LRU eviction, noting which entries are expired.
426 while let Some((k, entry)) = self.inner.evict_lru() {
427 let ttl = ttl_for_type(&entry.content_type);
428 if entry.inserted_at.elapsed() > ttl {
429 expired_keys.push(k);
430 } else {
431 remaining.push((k, entry));
432 }
433 }
434
435 // Re-insert non-expired entries.
436 for (k, entry) in remaining {
437 let size = entry.size_bytes;
438 self.inner.insert(k, entry, size);
439 }
440
441 // Recompute total_bytes.
442 self.total_bytes = 0;
443 // We cannot iterate LruCache directly; use a fresh total.
444 // Instead, drain and reinsert again is wasteful; track via expired_keys count.
445 // Correct approach: adjust total_bytes by subtracting expired sizes.
446 // Since we already re-inserted all non-expired entries, recalculate.
447 // Use the stats for a best-effort figure.
448 self.total_bytes = self.inner.stats().total_size_bytes;
449
450 expired_keys.len()
451 }
452
453 // ── Statistics ────────────────────────────────────────────────────────────
454
455 /// Return the number of entries currently in the cache.
456 pub fn len(&self) -> usize {
457 self.inner.len()
458 }
459
460 /// Return `true` when the cache has no entries.
461 pub fn is_empty(&self) -> bool {
462 self.inner.is_empty()
463 }
464
465 /// Return the total number of bytes currently stored.
466 pub fn total_bytes(&self) -> usize {
467 self.total_bytes
468 }
469
470 /// Return the entry-count capacity.
471 pub fn capacity(&self) -> usize {
472 self.capacity
473 }
474
475 // ── Internal eviction ─────────────────────────────────────────────────────
476
477 /// Evict the entry with the highest eviction score (content-aware).
478 ///
479 /// Because [`LruCache`] does not expose iteration, we must drain and
480 /// re-insert to find the worst entry. This is O(n) per eviction;
481 /// acceptable for moderate cache sizes and infrequent evictions.
482 ///
483 /// If finding the worst entry is too expensive (e.g. very large cache),
484 /// callers can fall back to plain LRU via `inner.evict_lru()` directly.
485 fn evict_worst(&mut self) {
486 if self.inner.is_empty() {
487 return;
488 }
489
490 // Capture the current weights — we cannot borrow self.scoring_weights
491 // and self.inner mutably at the same time.
492 let weights = self.scoring_weights.clone();
493
494 // Drain everything, find the worst, re-insert the rest.
495 let mut entries: Vec<(String, CacheEntry)> = Vec::with_capacity(self.inner.len());
496 while let Some((k, entry)) = self.inner.evict_lru() {
497 entries.push((k, entry));
498 }
499
500 // Find the index of the highest eviction score.
501 let worst_idx = entries
502 .iter()
503 .enumerate()
504 .max_by(|(_, (_, a)), (_, (_, b))| {
505 a.score_for_eviction_weighted(&weights)
506 .partial_cmp(&b.score_for_eviction_weighted(&weights))
507 .unwrap_or(std::cmp::Ordering::Equal)
508 })
509 .map(|(i, _)| i)
510 .unwrap_or(0);
511
512 // Remove the worst entry.
513 let (_, evicted) = entries.remove(worst_idx);
514 self.total_bytes = self.total_bytes.saturating_sub(evicted.size_bytes);
515
516 // Re-insert the remaining entries.
517 for (k, entry) in entries {
518 let size = entry.size_bytes;
519 self.inner.insert(k, entry, size);
520 }
521 }
522}
523
524// ── Tests ─────────────────────────────────────────────────────────────────────
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 // ── ContentCachePriority ──────────────────────────────────────────────────
531
532 #[test]
533 fn test_priority_manifest_is_highest() {
534 let p = ContentCachePriority::for_type(&MediaContentType::Manifest);
535 assert_eq!(p.0, 10);
536 }
537
538 #[test]
539 fn test_priority_thumbnail() {
540 let p = ContentCachePriority::for_type(&MediaContentType::Thumbnail);
541 assert_eq!(p.0, 8);
542 }
543
544 #[test]
545 fn test_priority_high_bitrate_video() {
546 let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
547 bitrate: 5_000_000,
548 codec: "av1".into(),
549 });
550 assert_eq!(p.0, 7);
551 }
552
553 #[test]
554 fn test_priority_low_bitrate_video() {
555 let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
556 bitrate: 1_000_000,
557 codec: "vp9".into(),
558 });
559 assert_eq!(p.0, 6);
560 }
561
562 #[test]
563 fn test_priority_audio() {
564 let p =
565 ContentCachePriority::for_type(&MediaContentType::AudioSegment { bitrate: 128_000 });
566 assert_eq!(p.0, 5);
567 }
568
569 #[test]
570 fn test_priority_image() {
571 let p = ContentCachePriority::for_type(&MediaContentType::Image {
572 width: 1920,
573 height: 1080,
574 });
575 assert_eq!(p.0, 4);
576 }
577
578 #[test]
579 fn test_priority_metadata() {
580 let p = ContentCachePriority::for_type(&MediaContentType::Metadata);
581 assert_eq!(p.0, 3);
582 }
583
584 // ── TTL ───────────────────────────────────────────────────────────────────
585
586 #[test]
587 fn test_ttl_manifest() {
588 assert_eq!(
589 ttl_for_type(&MediaContentType::Manifest),
590 Duration::from_secs(30)
591 );
592 }
593
594 #[test]
595 fn test_ttl_video_segment() {
596 let ct = MediaContentType::VideoSegment {
597 bitrate: 2_000_000,
598 codec: "av1".into(),
599 };
600 assert_eq!(ttl_for_type(&ct), Duration::from_secs(300));
601 }
602
603 #[test]
604 fn test_ttl_thumbnail() {
605 assert_eq!(
606 ttl_for_type(&MediaContentType::Thumbnail),
607 Duration::from_secs(86_400)
608 );
609 }
610
611 #[test]
612 fn test_ttl_image() {
613 let ct = MediaContentType::Image {
614 width: 100,
615 height: 100,
616 };
617 assert_eq!(ttl_for_type(&ct), Duration::from_secs(3_600));
618 }
619
620 // ── ContentAwareCache basic operations ────────────────────────────────────
621
622 #[test]
623 fn test_insert_and_get() {
624 let mut cache = ContentAwareCache::new(16);
625 cache.insert_media(
626 "seg1".into(),
627 vec![0u8; 1024],
628 MediaContentType::VideoSegment {
629 bitrate: 2_000_000,
630 codec: "av1".into(),
631 },
632 );
633 let entry = cache.get("seg1");
634 assert!(entry.is_some());
635 assert_eq!(entry.map(|e| e.size_bytes), Some(1024));
636 }
637
638 #[test]
639 fn test_get_absent_returns_none() {
640 let mut cache = ContentAwareCache::new(8);
641 assert!(cache.get("missing").is_none());
642 }
643
644 #[test]
645 fn test_len_and_is_empty() {
646 let mut cache = ContentAwareCache::new(8);
647 assert!(cache.is_empty());
648 cache.insert_media("m".into(), vec![1, 2], MediaContentType::Manifest);
649 assert_eq!(cache.len(), 1);
650 assert!(!cache.is_empty());
651 }
652
653 #[test]
654 fn test_remove() {
655 let mut cache = ContentAwareCache::new(8);
656 cache.insert_media("key".into(), vec![0u8; 512], MediaContentType::Metadata);
657 assert!(cache.remove("key"));
658 assert!(cache.get("key").is_none());
659 }
660
661 #[test]
662 fn test_remove_absent() {
663 let mut cache = ContentAwareCache::new(8);
664 assert!(!cache.remove("ghost"));
665 }
666
667 #[test]
668 fn test_total_bytes_tracking() {
669 let mut cache = ContentAwareCache::new(16);
670 cache.insert_media("a".into(), vec![0u8; 100], MediaContentType::Manifest);
671 cache.insert_media("b".into(), vec![0u8; 200], MediaContentType::Metadata);
672 assert_eq!(cache.total_bytes(), 300);
673 cache.remove("a");
674 assert_eq!(cache.total_bytes(), 200);
675 }
676
677 #[test]
678 fn test_capacity_reported() {
679 let cache = ContentAwareCache::new(32);
680 assert_eq!(cache.capacity(), 32);
681 }
682
683 // ── Eviction scoring ─────────────────────────────────────────────────────
684
685 #[test]
686 fn test_score_for_eviction_just_inserted_is_low() {
687 let entry = CacheEntry::new("k".into(), vec![0u8; 100], MediaContentType::Manifest);
688 // Just inserted → recency ≈ 1.0 → (1 - 1) * … ≈ 0.
689 let score = entry.score_for_eviction();
690 assert!(
691 score < 0.1,
692 "fresh entry should have low eviction score, got {score}"
693 );
694 }
695
696 #[test]
697 fn test_score_low_priority_higher_than_high_priority() {
698 // An aged metadata entry should score higher than an aged manifest entry
699 // because metadata has lower priority (easier to re-fetch).
700 let manifest_entry =
701 CacheEntry::new("m".into(), vec![0u8; 100], MediaContentType::Manifest);
702 let meta_entry = CacheEntry::new("d".into(), vec![0u8; 100], MediaContentType::Metadata);
703 // Force age by checking the formula with same recency.
704 // priority_manifest = 10, priority_metadata = 3 → 1/3 > 1/10.
705 let p_manifest = ContentCachePriority::for_type(&MediaContentType::Manifest).0 as f32;
706 let p_meta = ContentCachePriority::for_type(&MediaContentType::Metadata).0 as f32;
707 assert!(
708 1.0 / p_meta > 1.0 / p_manifest,
709 "metadata entry should evict before manifest"
710 );
711 drop(manifest_entry);
712 drop(meta_entry);
713 }
714
715 // ── Content-aware eviction when at capacity ───────────────────────────────
716
717 #[test]
718 fn test_eviction_prefers_low_priority_entries() {
719 // Capacity of 2: insert a Manifest + a Metadata.
720 // Then insert a third entry. The Metadata (priority=3) should be evicted
721 // over Manifest (priority=10), all else being equal.
722 let mut cache = ContentAwareCache::new(2);
723 // Insert manifest and metadata with tiny data.
724 cache.insert_media("manifest".into(), vec![0u8; 1], MediaContentType::Manifest);
725 cache.insert_media("meta".into(), vec![0u8; 1], MediaContentType::Metadata);
726 // Let both entries age so the score formula produces non-zero values,
727 // then refresh manifest's last_accessed. Without this sleep the age
728 // is <1 µs on fast (Linux/CUDA) machines and both scores collapse to
729 // (1 - 1) * … = 0, making eviction order non-deterministic (flaky).
730 std::thread::sleep(std::time::Duration::from_millis(100));
731 // Force the manifest to be "recently used" relative to the aged metadata.
732 let _ = cache.get("manifest");
733 // Insert third entry to trigger eviction.
734 cache.insert_media(
735 "new".into(),
736 vec![0u8; 1],
737 MediaContentType::VideoSegment {
738 bitrate: 2_000_000,
739 codec: "av1".into(),
740 },
741 );
742 assert_eq!(cache.len(), 2);
743 // Manifest should still be present (higher priority).
744 assert!(
745 cache.peek("manifest").is_some(),
746 "manifest should survive eviction"
747 );
748 }
749
750 // ── access_count and last_accessed updates ────────────────────────────────
751
752 #[test]
753 fn test_access_count_increments_on_get() {
754 let mut cache = ContentAwareCache::new(8);
755 cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Thumbnail);
756 cache.get("k");
757 cache.get("k");
758 let count = cache.peek("k").map(|e| e.access_count).unwrap_or(0);
759 assert_eq!(count, 2, "access_count should be 2 after two gets");
760 }
761
762 // ── Byte-level capacity ───────────────────────────────────────────────────
763
764 #[test]
765 fn test_max_bytes_triggers_eviction() {
766 let mut cache = ContentAwareCache::new(100).with_max_bytes(500);
767 // Insert 5 × 100-byte entries = 500 bytes (at limit).
768 for i in 0..5u32 {
769 cache.insert_media(
770 format!("seg_{i}"),
771 vec![0u8; 100],
772 MediaContentType::AudioSegment { bitrate: 128_000 },
773 );
774 }
775 assert!(cache.total_bytes() <= 500);
776 // Insert one more → must evict to stay within budget.
777 cache.insert_media("extra".into(), vec![0u8; 100], MediaContentType::Metadata);
778 assert!(
779 cache.total_bytes() <= 500,
780 "total bytes exceeded budget: {}",
781 cache.total_bytes()
782 );
783 }
784
785 // ── Peek does not update metadata ─────────────────────────────────────────
786
787 #[test]
788 fn test_peek_does_not_change_access_count() {
789 let mut cache = ContentAwareCache::new(8);
790 cache.insert_media("p".into(), vec![99], MediaContentType::Manifest);
791 let before = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
792 let _ = cache.peek("p");
793 let after = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
794 assert_eq!(before, after, "peek must not change access_count");
795 }
796
797 // ── Upsert behaviour ─────────────────────────────────────────────────────
798
799 #[test]
800 fn test_insert_same_key_updates_value() {
801 let mut cache = ContentAwareCache::new(8);
802 cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Manifest);
803 cache.insert_media("k".into(), vec![10, 20], MediaContentType::Manifest);
804 assert_eq!(cache.len(), 1, "duplicate key should not increase len");
805 assert_eq!(
806 cache.total_bytes(),
807 2,
808 "total_bytes should reflect updated size"
809 );
810 }
811
812 // ── evict_expired ─────────────────────────────────────────────────────────
813
814 #[test]
815 fn test_evict_expired_no_entries() {
816 let mut cache = ContentAwareCache::new(8);
817 assert_eq!(cache.evict_expired(), 0);
818 }
819
820 #[test]
821 fn test_evict_expired_fresh_entries_survive() {
822 let mut cache = ContentAwareCache::new(8);
823 cache.insert_media("fresh".into(), vec![0u8; 10], MediaContentType::Manifest);
824 // Manifest TTL is 30 s; the entry was just inserted → should not expire.
825 let evicted = cache.evict_expired();
826 assert_eq!(evicted, 0);
827 assert!(cache.peek("fresh").is_some());
828 }
829}