Skip to main content

ipfrs_storage/
block_cache.rs

1//! In-memory LRU/LFU block cache with TTL-based and size-based eviction.
2//!
3//! # Overview
4//!
5//! [`BlockCache`] is a standalone, synchronous in-memory cache for raw block
6//! data (identified by CID strings).  It supports three eviction policies:
7//!
8//! * [`EvictionPolicy::LRU`] — evict the entry that was accessed least recently.
9//! * [`EvictionPolicy::LFU`] — evict the entry that was accessed least frequently
10//!   (tie-broken by last-access time so cold entries leave first).
11//! * [`EvictionPolicy::TTL`] — evict expired entries first; if none are expired,
12//!   fall back to LRU.
13//!
14//! Capacity is bounded by **both** a maximum entry count and a maximum byte
15//! footprint.  Expired entries are lazily removed on access and can also be
16//! swept in bulk via [`BlockCache::evict_expired`].
17
18use std::collections::HashMap;
19
20// ---------------------------------------------------------------------------
21// CacheEntry
22// ---------------------------------------------------------------------------
23
24/// A single cached block together with its bookkeeping metadata.
25#[derive(Debug, Clone)]
26pub struct CacheEntry {
27    /// Content identifier of the block.
28    pub cid: String,
29    /// Raw block bytes.
30    pub data: Vec<u8>,
31    /// Unix timestamp (seconds) at which this entry was first inserted.
32    pub inserted_at_secs: u64,
33    /// Unix timestamp (seconds) of the most recent cache hit; updated on every
34    /// successful [`BlockCache::get`] call.
35    pub last_accessed_secs: u64,
36    /// Number of times this entry has been returned by [`BlockCache::get`].
37    pub access_count: u64,
38}
39
40impl CacheEntry {
41    /// Returns `true` when the entry has lived longer than `ttl_secs` seconds
42    /// relative to `now_secs`.
43    #[inline]
44    pub fn is_expired(&self, ttl_secs: u64, now_secs: u64) -> bool {
45        now_secs.saturating_sub(self.inserted_at_secs) >= ttl_secs
46    }
47
48    /// Returns the size of the cached data in bytes.
49    #[inline]
50    pub fn size_bytes(&self) -> usize {
51        self.data.len()
52    }
53}
54
55// ---------------------------------------------------------------------------
56// EvictionPolicy
57// ---------------------------------------------------------------------------
58
59/// Determines which entry is chosen for eviction when the cache is full.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum EvictionPolicy {
62    /// Evict the entry that was accessed least recently (minimum
63    /// `last_accessed_secs`).
64    LRU,
65    /// Evict the entry that has been accessed fewest times (`access_count`),
66    /// with `last_accessed_secs` as a tie-breaker (older access loses).
67    LFU,
68    /// Evict expired entries first.  If no entries are expired, fall back to
69    /// LRU eviction.
70    TTL,
71}
72
73// ---------------------------------------------------------------------------
74// CacheConfig
75// ---------------------------------------------------------------------------
76
77/// Configuration for a [`BlockCache`].
78#[derive(Debug, Clone)]
79pub struct CacheConfig {
80    /// Maximum number of entries the cache may hold simultaneously.
81    ///
82    /// Default: 1 000.
83    pub max_entries: usize,
84    /// Maximum total byte footprint of all cached blocks.
85    ///
86    /// Default: 64 MiB.
87    pub max_bytes: usize,
88    /// Time-to-live for each entry in seconds.  Entries older than this
89    /// (relative to their `inserted_at_secs`) are considered expired.
90    ///
91    /// Default: 300 s (5 minutes).
92    pub ttl_secs: u64,
93    /// Eviction policy applied when the cache is at capacity.
94    ///
95    /// Default: [`EvictionPolicy::LRU`].
96    pub policy: EvictionPolicy,
97}
98
99impl Default for CacheConfig {
100    fn default() -> Self {
101        Self {
102            max_entries: 1_000,
103            max_bytes: 64 * 1024 * 1024,
104            ttl_secs: 300,
105            policy: EvictionPolicy::LRU,
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// CacheStats
112// ---------------------------------------------------------------------------
113
114/// Cumulative statistics for a [`BlockCache`].
115#[derive(Debug, Clone, Default)]
116pub struct CacheStats {
117    /// Total number of successful cache lookups.
118    pub hits: u64,
119    /// Total number of failed cache lookups (including expired-entry misses).
120    pub misses: u64,
121    /// Total number of entries evicted due to capacity pressure.
122    pub evictions: u64,
123    /// Subset of `misses` caused by an expired entry being removed.
124    pub expired_evictions: u64,
125    /// Current aggregate size (bytes) of all live entries.
126    pub total_bytes: u64,
127    /// Current number of live entries.
128    pub entry_count: u64,
129}
130
131impl CacheStats {
132    /// Returns the fraction of lookups that were satisfied from the cache.
133    ///
134    /// Returns `0.0` when neither hits nor misses have been recorded yet.
135    pub fn hit_rate(&self) -> f64 {
136        let total = self.hits + self.misses;
137        if total == 0 {
138            0.0
139        } else {
140            self.hits as f64 / total as f64
141        }
142    }
143}
144
145// ---------------------------------------------------------------------------
146// BlockCache
147// ---------------------------------------------------------------------------
148
149/// An in-memory cache for raw block data with LRU/LFU/TTL eviction and
150/// size-based capacity limits.
151#[derive(Debug)]
152pub struct BlockCache {
153    entries: HashMap<String, CacheEntry>,
154    config: CacheConfig,
155    stats: CacheStats,
156}
157
158impl BlockCache {
159    /// Creates a new `BlockCache` with the given [`CacheConfig`].
160    pub fn new(config: CacheConfig) -> Self {
161        Self {
162            entries: HashMap::new(),
163            config,
164            stats: CacheStats::default(),
165        }
166    }
167
168    // -----------------------------------------------------------------------
169    // Public API
170    // -----------------------------------------------------------------------
171
172    /// Looks up a block by CID.
173    ///
174    /// * **Hit (fresh)**: updates `last_accessed_secs` and `access_count`,
175    ///   increments `stats.hits`, and returns a slice of the data.
176    /// * **Hit (expired)**: removes the entry, increments `stats.misses` *and*
177    ///   `stats.expired_evictions`, and returns `None`.
178    /// * **Miss**: increments `stats.misses` and returns `None`.
179    pub fn get(&mut self, cid: &str, now_secs: u64) -> Option<&[u8]> {
180        // Check expiry without a borrow on `self.entries` first so we can
181        // mutate the map afterwards.
182        let expired = self
183            .entries
184            .get(cid)
185            .map(|e| e.is_expired(self.config.ttl_secs, now_secs))
186            .unwrap_or(false);
187
188        if expired {
189            // Remove the entry and account for freed bytes.
190            if let Some(entry) = self.entries.remove(cid) {
191                let freed = entry.size_bytes() as u64;
192                self.stats.total_bytes = self.stats.total_bytes.saturating_sub(freed);
193                self.stats.entry_count = self.stats.entry_count.saturating_sub(1);
194            }
195            self.stats.misses += 1;
196            self.stats.expired_evictions += 1;
197            return None;
198        }
199
200        if let Some(entry) = self.entries.get_mut(cid) {
201            entry.last_accessed_secs = now_secs;
202            entry.access_count += 1;
203            self.stats.hits += 1;
204            // SAFETY: the borrow of `entry` ends here; we re-borrow immutably
205            // for the return value.
206            return Some(&self.entries[cid].data);
207        }
208
209        self.stats.misses += 1;
210        None
211    }
212
213    /// Inserts a new block into the cache.
214    ///
215    /// If the cache is at or above capacity (entry count **or** byte budget),
216    /// entries are evicted according to [`CacheConfig::policy`] until the new
217    /// block fits.  If the block itself exceeds `max_bytes` on its own, all
218    /// existing entries are evicted and the oversized block is still inserted
219    /// (single-block cache).
220    pub fn insert(&mut self, cid: String, data: Vec<u8>, now_secs: u64) {
221        let new_size = data.len() as u64;
222
223        // If this CID is already cached, remove the old entry first so we
224        // don't double-count its bytes.
225        if let Some(old) = self.entries.remove(&cid) {
226            let freed = old.size_bytes() as u64;
227            self.stats.total_bytes = self.stats.total_bytes.saturating_sub(freed);
228            self.stats.entry_count = self.stats.entry_count.saturating_sub(1);
229        }
230
231        // Evict until both constraints are satisfied.
232        while !self.entries.is_empty()
233            && (self.entries.len() >= self.config.max_entries
234                || self.stats.total_bytes + new_size > self.config.max_bytes as u64)
235        {
236            if !self.evict_one(now_secs) {
237                // Nothing left to evict.
238                break;
239            }
240        }
241
242        let entry = CacheEntry {
243            cid: cid.clone(),
244            data,
245            inserted_at_secs: now_secs,
246            last_accessed_secs: now_secs,
247            access_count: 0,
248        };
249
250        self.stats.total_bytes += new_size;
251        self.stats.entry_count += 1;
252        self.entries.insert(cid, entry);
253    }
254
255    /// Evicts a single entry according to the configured policy.
256    ///
257    /// Returns `true` if an entry was removed, `false` if the cache was already
258    /// empty.
259    pub fn evict_one(&mut self, now_secs: u64) -> bool {
260        if self.entries.is_empty() {
261            return false;
262        }
263
264        let victim_cid = match self.config.policy {
265            EvictionPolicy::LRU => self.find_lru_victim(),
266            EvictionPolicy::LFU => self.find_lfu_victim(),
267            EvictionPolicy::TTL => {
268                // Prefer an expired entry; fall back to LRU.
269                self.find_expired_victim(now_secs)
270                    .or_else(|| self.find_lru_victim())
271            }
272        };
273
274        if let Some(cid) = victim_cid {
275            if let Some(entry) = self.entries.remove(&cid) {
276                let freed = entry.size_bytes() as u64;
277                self.stats.total_bytes = self.stats.total_bytes.saturating_sub(freed);
278                self.stats.entry_count = self.stats.entry_count.saturating_sub(1);
279                self.stats.evictions += 1;
280                return true;
281            }
282        }
283
284        false
285    }
286
287    /// Removes all expired entries from the cache in one pass.
288    ///
289    /// Returns the number of entries that were removed.
290    pub fn evict_expired(&mut self, now_secs: u64) -> usize {
291        let ttl = self.config.ttl_secs;
292        let expired_cids: Vec<String> = self
293            .entries
294            .iter()
295            .filter(|(_, e)| e.is_expired(ttl, now_secs))
296            .map(|(cid, _)| cid.clone())
297            .collect();
298
299        let count = expired_cids.len();
300        for cid in &expired_cids {
301            if let Some(entry) = self.entries.remove(cid) {
302                let freed = entry.size_bytes() as u64;
303                self.stats.total_bytes = self.stats.total_bytes.saturating_sub(freed);
304                self.stats.entry_count = self.stats.entry_count.saturating_sub(1);
305                self.stats.evictions += 1;
306                self.stats.expired_evictions += 1;
307            }
308        }
309
310        count
311    }
312
313    /// Explicitly removes a cached entry by CID.
314    ///
315    /// Returns `true` if the entry existed and was removed.
316    pub fn remove(&mut self, cid: &str) -> bool {
317        if let Some(entry) = self.entries.remove(cid) {
318            let freed = entry.size_bytes() as u64;
319            self.stats.total_bytes = self.stats.total_bytes.saturating_sub(freed);
320            self.stats.entry_count = self.stats.entry_count.saturating_sub(1);
321            true
322        } else {
323            false
324        }
325    }
326
327    /// Returns `true` if a non-expired entry for `cid` is currently in the
328    /// cache.  Unlike [`get`](Self::get) this method does **not** update any
329    /// access metadata and does **not** remove expired entries.
330    pub fn contains(&self, cid: &str, now_secs: u64) -> bool {
331        self.entries
332            .get(cid)
333            .map(|e| !e.is_expired(self.config.ttl_secs, now_secs))
334            .unwrap_or(false)
335    }
336
337    /// Returns a reference to the cumulative [`CacheStats`].
338    pub fn stats(&self) -> &CacheStats {
339        &self.stats
340    }
341
342    /// Removes all entries and resets all statistics.
343    pub fn clear(&mut self) {
344        self.entries.clear();
345        self.stats = CacheStats::default();
346    }
347
348    // -----------------------------------------------------------------------
349    // Private helpers
350    // -----------------------------------------------------------------------
351
352    /// Finds the CID of the entry with the smallest `last_accessed_secs`.
353    fn find_lru_victim(&self) -> Option<String> {
354        self.entries
355            .iter()
356            .min_by_key(|(_, e)| e.last_accessed_secs)
357            .map(|(cid, _)| cid.clone())
358    }
359
360    /// Finds the CID of the entry with the smallest `access_count`, breaking
361    /// ties by preferring the entry with the oldest `last_accessed_secs`.
362    fn find_lfu_victim(&self) -> Option<String> {
363        self.entries
364            .iter()
365            .min_by(|(_, a), (_, b)| {
366                a.access_count
367                    .cmp(&b.access_count)
368                    .then_with(|| a.last_accessed_secs.cmp(&b.last_accessed_secs))
369            })
370            .map(|(cid, _)| cid.clone())
371    }
372
373    /// Finds any expired entry.  The specific choice is deterministic (minimum
374    /// `inserted_at_secs`) so tests can reason about which entry is chosen.
375    fn find_expired_victim(&self, now_secs: u64) -> Option<String> {
376        let ttl = self.config.ttl_secs;
377        self.entries
378            .iter()
379            .filter(|(_, e)| e.is_expired(ttl, now_secs))
380            .min_by_key(|(_, e)| e.inserted_at_secs)
381            .map(|(cid, _)| cid.clone())
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Convenience constructor: current-time helpers
387// ---------------------------------------------------------------------------
388
389/// Returns the current Unix time in seconds, falling back to 0 on error.
390///
391/// Provided as a free function so callers that already have a timestamp do not
392/// need to touch the system clock.
393pub fn current_unix_secs() -> u64 {
394    std::time::SystemTime::now()
395        .duration_since(std::time::UNIX_EPOCH)
396        .map(|d| d.as_secs())
397        .unwrap_or(0)
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    // Helper: build a cache with a given capacity and TTL.
409    fn make_cache(
410        max_entries: usize,
411        max_bytes: usize,
412        ttl_secs: u64,
413        policy: EvictionPolicy,
414    ) -> BlockCache {
415        BlockCache::new(CacheConfig {
416            max_entries,
417            max_bytes,
418            ttl_secs,
419            policy,
420        })
421    }
422
423    // -----------------------------------------------------------------------
424    // 1. new() with default config
425    // -----------------------------------------------------------------------
426    #[test]
427    fn test_new_default_config() {
428        let cache = BlockCache::new(CacheConfig::default());
429        assert_eq!(cache.stats().entry_count, 0);
430        assert_eq!(cache.stats().total_bytes, 0);
431        assert_eq!(cache.stats().hits, 0);
432        assert_eq!(cache.stats().misses, 0);
433    }
434
435    // -----------------------------------------------------------------------
436    // 2. insert and get — cache hit
437    // -----------------------------------------------------------------------
438    #[test]
439    fn test_insert_and_get_hit() {
440        let mut cache = make_cache(100, 1024 * 1024, 300, EvictionPolicy::LRU);
441        cache.insert("cid1".to_string(), vec![1u8, 2, 3], 1000);
442        let result = cache.get("cid1", 1001);
443        assert_eq!(result, Some([1u8, 2, 3].as_ref()));
444        assert_eq!(cache.stats().hits, 1);
445    }
446
447    // -----------------------------------------------------------------------
448    // 3. get miss increments misses
449    // -----------------------------------------------------------------------
450    #[test]
451    fn test_get_miss_increments_misses() {
452        let mut cache = make_cache(100, 1024 * 1024, 300, EvictionPolicy::LRU);
453        let result = cache.get("nonexistent", 1000);
454        assert!(result.is_none());
455        assert_eq!(cache.stats().misses, 1);
456        assert_eq!(cache.stats().hits, 0);
457    }
458
459    // -----------------------------------------------------------------------
460    // 4. get on expired entry removes it and returns None
461    // -----------------------------------------------------------------------
462    #[test]
463    fn test_get_expired_removed_returns_none() {
464        let mut cache = make_cache(100, 1024 * 1024, 10, EvictionPolicy::LRU);
465        cache.insert("cid_exp".to_string(), vec![9u8; 8], 1000);
466
467        // now_secs is 20 seconds after insertion — TTL is 10 s, so expired.
468        let result = cache.get("cid_exp", 1020);
469        assert!(result.is_none());
470        assert_eq!(cache.stats().misses, 1);
471        assert_eq!(cache.stats().expired_evictions, 1);
472        assert_eq!(cache.stats().entry_count, 0);
473        assert_eq!(cache.stats().total_bytes, 0);
474    }
475
476    // -----------------------------------------------------------------------
477    // 5. insert at entry capacity evicts LRU
478    // -----------------------------------------------------------------------
479    #[test]
480    fn test_insert_at_entry_capacity_evicts_lru() {
481        // capacity = 2 entries
482        let mut cache = make_cache(2, 1024 * 1024, 3600, EvictionPolicy::LRU);
483
484        // Insert two entries at different times so we know the LRU victim.
485        cache.insert("oldest".to_string(), vec![1u8], 100);
486        cache.insert("newer".to_string(), vec![2u8], 200);
487
488        // Simulate a hit on "oldest" to make "newer" the least-recently-used
489        // entry, then verify the right one is evicted.
490        // Actually: after insertion both entries have last_accessed == inserted.
491        // "oldest" has last_accessed=100, "newer" has last_accessed=200.
492        // So LRU victim is "oldest".
493
494        // Inserting a third entry should evict "oldest".
495        cache.insert("third".to_string(), vec![3u8], 300);
496        assert_eq!(cache.stats().evictions, 1);
497        assert!(cache.get("oldest", 300).is_none()); // misses
498        assert!(cache.get("newer", 300).is_some());
499        assert!(cache.get("third", 300).is_some());
500    }
501
502    // -----------------------------------------------------------------------
503    // 6. insert at byte capacity evicts until it fits
504    // -----------------------------------------------------------------------
505    #[test]
506    fn test_insert_at_byte_capacity_evicts_until_fits() {
507        // max_bytes = 20; each block is 8 bytes.
508        // After 2 entries (16 bytes), adding another 8-byte block (24 > 20)
509        // requires eviction.
510        let mut cache = make_cache(1000, 20, 3600, EvictionPolicy::LRU);
511        cache.insert("a".to_string(), vec![0u8; 8], 100);
512        cache.insert("b".to_string(), vec![0u8; 8], 200);
513        assert_eq!(cache.stats().total_bytes, 16);
514
515        // Adding 8 more bytes (total 24 > 20) should evict "a" (LRU, last_accessed=100).
516        cache.insert("c".to_string(), vec![0u8; 8], 300);
517        assert_eq!(cache.stats().total_bytes, 16); // b(8) + c(8)
518        assert_eq!(cache.stats().evictions, 1);
519        assert!(cache.get("a", 300).is_none());
520        assert!(cache.get("b", 300).is_some());
521        assert!(cache.get("c", 300).is_some());
522    }
523
524    // -----------------------------------------------------------------------
525    // 7. LFU eviction picks lowest access_count
526    // -----------------------------------------------------------------------
527    #[test]
528    fn test_lfu_eviction_picks_lowest_access_count() {
529        let mut cache = make_cache(2, 1024 * 1024, 3600, EvictionPolicy::LFU);
530        cache.insert("popular".to_string(), vec![1u8], 100);
531        cache.insert("cold".to_string(), vec![2u8], 100);
532
533        // Access "popular" twice.
534        cache.get("popular", 101);
535        cache.get("popular", 102);
536        // "cold" has access_count=0, "popular" has access_count=2.
537
538        // Inserting a third entry should evict "cold".
539        cache.insert("new".to_string(), vec![3u8], 200);
540        assert_eq!(cache.stats().evictions, 1);
541        assert!(cache.get("cold", 200).is_none()); // evicted
542        assert!(cache.get("popular", 200).is_some());
543        assert!(cache.get("new", 200).is_some());
544    }
545
546    // -----------------------------------------------------------------------
547    // 8. TTL policy evicts expired first, then falls back to LRU
548    // -----------------------------------------------------------------------
549    #[test]
550    fn test_ttl_policy_expired_first_then_lru() {
551        // TTL = 50 s; capacity = 2.
552        let mut cache = make_cache(2, 1024 * 1024, 50, EvictionPolicy::TTL);
553
554        // Insert "stale" at t=0 and "fresh" at t=0.
555        cache.insert("stale".to_string(), vec![1u8], 0);
556        cache.insert("fresh".to_string(), vec![2u8], 0);
557
558        // At t=100, "stale" is expired (inserted_at=0, ttl=50, 100 >= 50).
559        // "fresh" is also expired. Both are expired.
560        // The TTL policy picks the one with minimum inserted_at_secs first.
561        // Both have inserted_at=0, so the sort is stable on cid string order.
562        // Regardless: eviction should remove an expired one.
563        cache.insert("new".to_string(), vec![3u8], 100);
564        assert_eq!(cache.stats().evictions, 1);
565        // At least one entry from the original two should be gone.
566        let stale_present = cache.get("stale", 100).is_some();
567        let fresh_present = cache.get("fresh", 100).is_some();
568        // Both are actually expired at t=100 so whichever was evicted is None,
569        // the other is also a miss due to TTL-on-get.  Either way "new" must be there.
570        assert!(!stale_present && !fresh_present);
571        assert!(cache.get("new", 100).is_some());
572    }
573
574    // -----------------------------------------------------------------------
575    // 9. access_count incremented on each hit
576    // -----------------------------------------------------------------------
577    #[test]
578    fn test_access_count_incremented_on_hit() {
579        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
580        cache.insert("x".to_string(), vec![42u8], 1000);
581        cache.get("x", 1001);
582        cache.get("x", 1002);
583        cache.get("x", 1003);
584
585        let entry = cache.entries.get("x").expect("entry must exist");
586        assert_eq!(entry.access_count, 3);
587    }
588
589    // -----------------------------------------------------------------------
590    // 10. last_accessed_secs updated on hit
591    // -----------------------------------------------------------------------
592    #[test]
593    fn test_last_accessed_updated_on_hit() {
594        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
595        cache.insert("y".to_string(), vec![7u8], 1000);
596        cache.get("y", 2000);
597
598        let entry = cache.entries.get("y").expect("entry must exist");
599        assert_eq!(entry.last_accessed_secs, 2000);
600    }
601
602    // -----------------------------------------------------------------------
603    // 11. evict_expired removes multiple entries
604    // -----------------------------------------------------------------------
605    #[test]
606    fn test_evict_expired_removes_multiple() {
607        let mut cache = make_cache(100, 1024 * 1024, 30, EvictionPolicy::LRU);
608        cache.insert("e1".to_string(), vec![1u8], 0);
609        cache.insert("e2".to_string(), vec![2u8], 0);
610        cache.insert("fresh".to_string(), vec![3u8], 50);
611
612        // At t=40, e1 and e2 are expired (age 40 >= ttl 30); "fresh" is not.
613        let removed = cache.evict_expired(40);
614        assert_eq!(removed, 2);
615        assert_eq!(cache.stats().entry_count, 1);
616        // "fresh" inserted at t=50 is not yet expired at t=40 (age is negative
617        // conceptually — it was inserted in the future relative to now_secs).
618        // Actually: age = now_secs(40) - inserted_at(50) = saturating_sub -> 0.
619        // 0 < 30, so not expired. Correct.
620    }
621
622    // -----------------------------------------------------------------------
623    // 12. evict_expired returns correct count
624    // -----------------------------------------------------------------------
625    #[test]
626    fn test_evict_expired_returns_correct_count() {
627        let mut cache = make_cache(100, 1024 * 1024, 10, EvictionPolicy::LRU);
628        for i in 0u64..5 {
629            cache.insert(format!("cid_{i}"), vec![i as u8], i * 2);
630        }
631        // TTL = 10 s; at t=12:
632        //   cid_0: age=12 >= 10 → expired
633        //   cid_1: age=10 >= 10 → expired
634        //   cid_2: age=8  < 10  → fresh
635        //   cid_3: age=6  < 10  → fresh
636        //   cid_4: age=4  < 10  → fresh
637        let count = cache.evict_expired(12);
638        assert_eq!(count, 2);
639    }
640
641    // -----------------------------------------------------------------------
642    // 13. remove explicit returns true
643    // -----------------------------------------------------------------------
644    #[test]
645    fn test_remove_explicit_returns_true() {
646        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
647        cache.insert("r1".to_string(), vec![0u8; 16], 1000);
648        let removed = cache.remove("r1");
649        assert!(removed);
650        assert_eq!(cache.stats().entry_count, 0);
651        assert_eq!(cache.stats().total_bytes, 0);
652    }
653
654    // -----------------------------------------------------------------------
655    // 14. remove non-existent returns false
656    // -----------------------------------------------------------------------
657    #[test]
658    fn test_remove_nonexistent_returns_false() {
659        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
660        let removed = cache.remove("ghost");
661        assert!(!removed);
662    }
663
664    // -----------------------------------------------------------------------
665    // 15. contains() true for fresh entry
666    // -----------------------------------------------------------------------
667    #[test]
668    fn test_contains_true_for_fresh_entry() {
669        let mut cache = make_cache(100, 1024 * 1024, 300, EvictionPolicy::LRU);
670        cache.insert("c1".to_string(), vec![1u8], 1000);
671        assert!(cache.contains("c1", 1100));
672    }
673
674    // -----------------------------------------------------------------------
675    // 16. contains() false for expired entry
676    // -----------------------------------------------------------------------
677    #[test]
678    fn test_contains_false_for_expired() {
679        let mut cache = make_cache(100, 1024 * 1024, 10, EvictionPolicy::LRU);
680        cache.insert("c2".to_string(), vec![1u8], 1000);
681        // age = 1020 - 1000 = 20 >= ttl 10 → expired
682        assert!(!cache.contains("c2", 1020));
683    }
684
685    // -----------------------------------------------------------------------
686    // 17. hit_rate() calculation
687    // -----------------------------------------------------------------------
688    #[test]
689    fn test_hit_rate_calculation() {
690        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
691        // Initially 0/0 → 0.0
692        assert_eq!(cache.stats().hit_rate(), 0.0);
693
694        cache.insert("hr".to_string(), vec![1u8], 1000);
695        cache.get("hr", 1001); // hit
696        cache.get("hr", 1002); // hit
697        cache.get("nope", 1003); // miss
698
699        // 2 hits, 1 miss → 2/3
700        let rate = cache.stats().hit_rate();
701        assert!((rate - 2.0 / 3.0).abs() < 1e-9, "expected 2/3, got {rate}");
702    }
703
704    // -----------------------------------------------------------------------
705    // 18. clear() resets everything including stats
706    // -----------------------------------------------------------------------
707    #[test]
708    fn test_clear_resets_everything() {
709        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
710        cache.insert("z1".to_string(), vec![0u8; 32], 1000);
711        cache.insert("z2".to_string(), vec![0u8; 64], 1000);
712        cache.get("z1", 1001);
713
714        cache.clear();
715
716        assert_eq!(cache.stats().hits, 0);
717        assert_eq!(cache.stats().misses, 0);
718        assert_eq!(cache.stats().evictions, 0);
719        assert_eq!(cache.stats().expired_evictions, 0);
720        assert_eq!(cache.stats().total_bytes, 0);
721        assert_eq!(cache.stats().entry_count, 0);
722        assert!(cache.get("z1", 1002).is_none());
723        assert!(cache.get("z2", 1002).is_none());
724    }
725
726    // -----------------------------------------------------------------------
727    // 19. CacheEntry::is_expired boundary conditions
728    // -----------------------------------------------------------------------
729    #[test]
730    fn test_cache_entry_is_expired_boundary() {
731        let entry = CacheEntry {
732            cid: "x".to_string(),
733            data: vec![],
734            inserted_at_secs: 100,
735            last_accessed_secs: 100,
736            access_count: 0,
737        };
738        // age == ttl → expired (>=)
739        assert!(entry.is_expired(50, 150));
740        // age < ttl → not expired
741        assert!(!entry.is_expired(50, 149));
742    }
743
744    // -----------------------------------------------------------------------
745    // 20. Reinsertion of existing CID updates data without double-counting bytes
746    // -----------------------------------------------------------------------
747    #[test]
748    fn test_reinsertion_updates_data() {
749        let mut cache = make_cache(100, 1024 * 1024, 3600, EvictionPolicy::LRU);
750        cache.insert("dup".to_string(), vec![0u8; 8], 1000);
751        assert_eq!(cache.stats().total_bytes, 8);
752
753        cache.insert("dup".to_string(), vec![0u8; 16], 1001);
754        // Only the new 16 bytes should be accounted for.
755        assert_eq!(cache.stats().total_bytes, 16);
756        assert_eq!(cache.stats().entry_count, 1);
757    }
758}