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}