Skip to main content

ipfrs_storage/
block_cache_manager.rs

1//! Multi-tier, policy-driven block cache manager for IPFS-style content-addressed storage.
2//!
3//! `BlockCacheManager` provides a three-tier caching system (Hot, Warm, Cold) with
4//! configurable eviction policies (LRU, LFU, TwoQ, ARC), promotion/demotion logic
5//! based on access-count thresholds, and pin-protection for blocks that must survive eviction.
6
7use std::collections::HashMap;
8
9// ────────────────────────────────────────────────────────────────────────────
10// CacheTier
11// ────────────────────────────────────────────────────────────────────────────
12
13/// Which tier a cached block lives in.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum CacheTier {
16    /// Frequently-accessed blocks; highest retention priority.
17    Hot,
18    /// Moderately-accessed blocks.
19    Warm,
20    /// Least-recently-accessed or newly-inserted blocks.
21    Cold,
22}
23
24impl CacheTier {
25    /// Numeric priority: higher means more valuable to keep.
26    #[must_use]
27    pub fn priority(&self) -> u8 {
28        match self {
29            CacheTier::Hot => 3,
30            CacheTier::Warm => 2,
31            CacheTier::Cold => 1,
32        }
33    }
34
35    /// The tier one step lower (Cold stays Cold).
36    fn demoted(self) -> Self {
37        match self {
38            CacheTier::Hot => CacheTier::Warm,
39            CacheTier::Warm => CacheTier::Cold,
40            CacheTier::Cold => CacheTier::Cold,
41        }
42    }
43
44    /// The tier one step higher (Hot stays Hot).
45    #[allow(dead_code)]
46    fn promoted(self) -> Self {
47        match self {
48            CacheTier::Cold => CacheTier::Warm,
49            CacheTier::Warm => CacheTier::Hot,
50            CacheTier::Hot => CacheTier::Hot,
51        }
52    }
53}
54
55// ────────────────────────────────────────────────────────────────────────────
56// EvictionPolicy
57// ────────────────────────────────────────────────────────────────────────────
58
59/// Strategy used to select victims during eviction.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum EvictionPolicy {
62    /// Least-Recently-Used: evict the block with the smallest `last_accessed`.
63    LRU,
64    /// Least-Frequently-Used: evict the block with the smallest `access_count`
65    /// (tie-broken by `last_accessed`).
66    LFU,
67    /// Two-Queue approximation (currently implemented as LRU).
68    TwoQ,
69    /// Adaptive Replacement Cache approximation (currently implemented as LRU).
70    ARC,
71}
72
73// ────────────────────────────────────────────────────────────────────────────
74// BcmCacheConfig
75// ────────────────────────────────────────────────────────────────────────────
76
77/// Configuration for [`BlockCacheManager`].
78///
79/// Note: named `BcmCacheConfig` to avoid collision with the `CacheConfig` in
80/// `ipfrs-storage::cache`.
81#[derive(Debug, Clone)]
82pub struct BcmCacheConfig {
83    /// Maximum total bytes that may reside in the Hot tier.
84    pub max_hot_bytes: u64,
85    /// Maximum total bytes that may reside in the Warm tier.
86    pub max_warm_bytes: u64,
87    /// Maximum total bytes that may reside in the Cold tier.
88    pub max_cold_bytes: u64,
89    /// Minimum `access_count` required to promote a block to Hot.
90    pub hot_threshold: u64,
91    /// Minimum `access_count` required to promote a block to Warm (must be < `hot_threshold`).
92    pub warm_threshold: u64,
93    /// Eviction algorithm to use within each tier.
94    pub eviction_policy: EvictionPolicy,
95}
96
97impl Default for BcmCacheConfig {
98    fn default() -> Self {
99        Self {
100            max_hot_bytes: 64 * 1024 * 1024,   // 64 MiB
101            max_warm_bytes: 128 * 1024 * 1024, // 128 MiB
102            max_cold_bytes: 256 * 1024 * 1024, // 256 MiB
103            hot_threshold: 10,
104            warm_threshold: 3,
105            eviction_policy: EvictionPolicy::LRU,
106        }
107    }
108}
109
110// ────────────────────────────────────────────────────────────────────────────
111// BcmCachedBlock
112// ────────────────────────────────────────────────────────────────────────────
113
114/// A single entry in the block cache.
115///
116/// Named `BcmCachedBlock` to avoid collision with any existing `CachedBlock`.
117#[derive(Debug, Clone)]
118pub struct BcmCachedBlock {
119    /// Content identifier (CID string) for this block.
120    pub cid: String,
121    /// Raw block data.
122    pub data: Vec<u8>,
123    /// Which tier this block currently lives in.
124    pub tier: CacheTier,
125    /// Unix-timestamp (or logical clock) when this block was first inserted.
126    pub inserted_at: u64,
127    /// Unix-timestamp (or logical clock) of the most recent access.
128    pub last_accessed: u64,
129    /// Number of times this block has been read since insertion.
130    pub access_count: u64,
131    /// If `true`, eviction is forbidden for this block.
132    pub pinned: bool,
133}
134
135impl BcmCachedBlock {
136    fn new(cid: String, data: Vec<u8>, now: u64) -> Self {
137        Self {
138            cid,
139            data,
140            tier: CacheTier::Cold,
141            inserted_at: now,
142            last_accessed: now,
143            access_count: 0,
144            pinned: false,
145        }
146    }
147
148    /// Byte size of the block data.
149    #[inline]
150    pub fn byte_size(&self) -> u64 {
151        self.data.len() as u64
152    }
153}
154
155// ────────────────────────────────────────────────────────────────────────────
156// BcmCacheStats
157// ────────────────────────────────────────────────────────────────────────────
158
159/// Snapshot of [`BlockCacheManager`] operational statistics.
160///
161/// Named `BcmCacheStats` to avoid collision with the existing `CacheStats`.
162#[derive(Debug, Clone, Default)]
163pub struct BcmCacheStats {
164    pub hot_count: usize,
165    pub warm_count: usize,
166    pub cold_count: usize,
167    pub hot_bytes: u64,
168    pub warm_bytes: u64,
169    pub cold_bytes: u64,
170    pub pinned_count: usize,
171    pub evictions: u64,
172    pub promotions: u64,
173    pub demotions: u64,
174}
175
176// ────────────────────────────────────────────────────────────────────────────
177// Internal helpers
178// ────────────────────────────────────────────────────────────────────────────
179
180/// Choose a victim CID from `tier_map` according to `policy`.
181/// Returns `None` if the tier is empty or every entry is pinned.
182fn choose_victim(
183    tier_map: &HashMap<String, BcmCachedBlock>,
184    policy: EvictionPolicy,
185) -> Option<String> {
186    let candidates: Vec<&BcmCachedBlock> = tier_map.values().filter(|b| !b.pinned).collect();
187    if candidates.is_empty() {
188        return None;
189    }
190
191    let victim = match policy {
192        EvictionPolicy::LFU => candidates.iter().min_by(|a, b| {
193            a.access_count
194                .cmp(&b.access_count)
195                .then_with(|| a.last_accessed.cmp(&b.last_accessed))
196        }),
197        // LRU / TwoQ / ARC all use last_accessed
198        EvictionPolicy::LRU | EvictionPolicy::TwoQ | EvictionPolicy::ARC => {
199            candidates.iter().min_by_key(|b| b.last_accessed)
200        }
201    };
202
203    victim.map(|b| b.cid.clone())
204}
205
206/// Sum the byte sizes of all entries in a tier map.
207fn tier_bytes(tier_map: &HashMap<String, BcmCachedBlock>) -> u64 {
208    tier_map.values().map(|b| b.byte_size()).sum()
209}
210
211// ────────────────────────────────────────────────────────────────────────────
212// BlockCacheManager
213// ────────────────────────────────────────────────────────────────────────────
214
215/// A multi-tier, policy-driven block cache.
216///
217/// Blocks are stored across three tiers (`Cold` → `Warm` → `Hot`) and migrate
218/// upward based on access frequency thresholds and downward via explicit
219/// `demote` calls or administrator policy.
220#[derive(Debug)]
221pub struct BlockCacheManager {
222    /// Cache configuration.
223    pub config: BcmCacheConfig,
224
225    // Tier storage — each keyed by CID string.
226    pub hot: HashMap<String, BcmCachedBlock>,
227    pub warm: HashMap<String, BcmCachedBlock>,
228    pub cold: HashMap<String, BcmCachedBlock>,
229
230    // Counters
231    evictions: u64,
232    promotions: u64,
233    demotions: u64,
234}
235
236impl BlockCacheManager {
237    // ── Construction ─────────────────────────────────────────────────────────
238
239    /// Create a new [`BlockCacheManager`] with the given configuration.
240    #[must_use]
241    pub fn new(config: BcmCacheConfig) -> Self {
242        Self {
243            config,
244            hot: HashMap::new(),
245            warm: HashMap::new(),
246            cold: HashMap::new(),
247            evictions: 0,
248            promotions: 0,
249            demotions: 0,
250        }
251    }
252
253    // ── Insertion ────────────────────────────────────────────────────────────
254
255    /// Insert a block into the Cold tier.
256    ///
257    /// Returns `false` if the CID is already present in any tier.
258    /// If the Cold tier is full, attempts eviction before inserting.
259    pub fn insert(&mut self, cid: String, data: Vec<u8>, now: u64) -> bool {
260        if self.contains(&cid) {
261            return false;
262        }
263
264        let needed = data.len() as u64;
265
266        // Make room in Cold tier if necessary.
267        let cold_used = tier_bytes(&self.cold);
268        if cold_used + needed > self.config.max_cold_bytes {
269            let to_free = (cold_used + needed).saturating_sub(self.config.max_cold_bytes);
270            self.evict_to_fit(CacheTier::Cold, to_free, now);
271        }
272
273        let block = BcmCachedBlock::new(cid.clone(), data, now);
274        self.cold.insert(cid, block);
275        true
276    }
277
278    // ── Retrieval ────────────────────────────────────────────────────────────
279
280    /// Return a reference to the block data, or `None` if not present.
281    ///
282    /// Updates `access_count` and `last_accessed`, then checks whether the
283    /// block should be promoted to a higher tier.
284    pub fn get(&mut self, cid: &str, now: u64) -> Option<&[u8]> {
285        // Update metadata in whichever tier holds the block.
286        let found_tier = if let Some(block) = self.hot.get_mut(cid) {
287            block.access_count = block.access_count.saturating_add(1);
288            block.last_accessed = now;
289            Some(CacheTier::Hot)
290        } else if let Some(block) = self.warm.get_mut(cid) {
291            block.access_count = block.access_count.saturating_add(1);
292            block.last_accessed = now;
293            Some(CacheTier::Warm)
294        } else if let Some(block) = self.cold.get_mut(cid) {
295            block.access_count = block.access_count.saturating_add(1);
296            block.last_accessed = now;
297            Some(CacheTier::Cold)
298        } else {
299            None
300        };
301
302        found_tier?;
303
304        // Attempt promotion (takes a clone of CID so we don't borrow twice).
305        let cid_owned = cid.to_owned();
306        self.promote(&cid_owned, now);
307
308        // Return reference from whichever tier now holds it.
309        if let Some(block) = self.hot.get(cid) {
310            return Some(&block.data);
311        }
312        if let Some(block) = self.warm.get(cid) {
313            return Some(&block.data);
314        }
315        if let Some(block) = self.cold.get(cid) {
316            return Some(&block.data);
317        }
318        None
319    }
320
321    // ── Pinning ───────────────────────────────────────────────────────────────
322
323    /// Mark a block as pinned so it cannot be evicted.  Returns `false` if not found.
324    pub fn pin(&mut self, cid: &str) -> bool {
325        if let Some(b) = self.hot.get_mut(cid) {
326            b.pinned = true;
327            return true;
328        }
329        if let Some(b) = self.warm.get_mut(cid) {
330            b.pinned = true;
331            return true;
332        }
333        if let Some(b) = self.cold.get_mut(cid) {
334            b.pinned = true;
335            return true;
336        }
337        false
338    }
339
340    /// Remove the pin from a block, allowing it to be evicted again.  Returns `false` if not found.
341    pub fn unpin(&mut self, cid: &str) -> bool {
342        if let Some(b) = self.hot.get_mut(cid) {
343            b.pinned = false;
344            return true;
345        }
346        if let Some(b) = self.warm.get_mut(cid) {
347            b.pinned = false;
348            return true;
349        }
350        if let Some(b) = self.cold.get_mut(cid) {
351            b.pinned = false;
352            return true;
353        }
354        false
355    }
356
357    // ── Eviction ─────────────────────────────────────────────────────────────
358
359    /// Evict unpinned blocks from `tier` until at least `needed_bytes` have been freed.
360    ///
361    /// Returns the total number of bytes actually freed.  Pinned blocks are
362    /// never evicted; if only pinned blocks remain, eviction stops early.
363    pub fn evict_to_fit(&mut self, tier: CacheTier, needed_bytes: u64, now: u64) -> u64 {
364        // `now` is kept for potential future use (e.g., time-aware eviction).
365        let _ = now;
366        let mut freed: u64 = 0;
367        let policy = self.config.eviction_policy;
368
369        let tier_map = match tier {
370            CacheTier::Hot => &mut self.hot,
371            CacheTier::Warm => &mut self.warm,
372            CacheTier::Cold => &mut self.cold,
373        };
374
375        while freed < needed_bytes {
376            match choose_victim(tier_map, policy) {
377                None => break,
378                Some(victim_cid) => {
379                    if let Some(block) = tier_map.remove(&victim_cid) {
380                        freed += block.byte_size();
381                        self.evictions += 1;
382                    }
383                }
384            }
385        }
386        freed
387    }
388
389    // ── Promotion / Demotion ──────────────────────────────────────────────────
390
391    /// Check whether the block identified by `cid` should be promoted to a
392    /// higher tier based on its current `access_count`, and move it if so.
393    ///
394    /// Promotion chain:
395    /// * Cold  → Warm  if `access_count >= warm_threshold`
396    /// * Warm  → Hot   if `access_count >= hot_threshold`
397    pub fn promote(&mut self, cid: &str, now: u64) {
398        // Determine current tier and access_count without a mutable borrow.
399        let (current_tier, access_count) = {
400            if let Some(b) = self.hot.get(cid) {
401                (CacheTier::Hot, b.access_count)
402            } else if let Some(b) = self.warm.get(cid) {
403                (CacheTier::Warm, b.access_count)
404            } else if let Some(b) = self.cold.get(cid) {
405                (CacheTier::Cold, b.access_count)
406            } else {
407                return; // block not found
408            }
409        };
410
411        let target_tier = {
412            if access_count >= self.config.hot_threshold {
413                CacheTier::Hot
414            } else if access_count >= self.config.warm_threshold {
415                CacheTier::Warm
416            } else {
417                // Below warm threshold — stays where it is.
418                return;
419            }
420        };
421
422        if target_tier == current_tier || (target_tier as u8) <= (current_tier as u8) {
423            // Already at target or higher.
424            if current_tier == target_tier {
425                return;
426            }
427            // If target is *lower* than current, don't demote during promote.
428            if target_tier.priority() <= current_tier.priority() {
429                return;
430            }
431        }
432
433        // Move from source to destination tier.
434        self.move_block(cid, current_tier, target_tier, now, true);
435    }
436
437    /// Move a block one tier down.  Has no effect if the block is in Cold tier
438    /// or not found.
439    pub fn demote(&mut self, cid: &str, now: u64) {
440        let current_tier = {
441            if self.hot.contains_key(cid) {
442                CacheTier::Hot
443            } else if self.warm.contains_key(cid) {
444                CacheTier::Warm
445            } else {
446                // Cold or missing — nothing to demote.
447                return;
448            }
449        };
450
451        let target_tier = current_tier.demoted();
452        if target_tier == current_tier {
453            return;
454        }
455
456        self.move_block(cid, current_tier, target_tier, now, false);
457    }
458
459    // ── Internal move helper ──────────────────────────────────────────────────
460
461    /// Move a block between tiers, evicting in the destination tier if needed.
462    fn move_block(
463        &mut self,
464        cid: &str,
465        from: CacheTier,
466        to: CacheTier,
467        now: u64,
468        is_promotion: bool,
469    ) {
470        // Extract block from source tier.
471        let mut block = match from {
472            CacheTier::Hot => self.hot.remove(cid),
473            CacheTier::Warm => self.warm.remove(cid),
474            CacheTier::Cold => self.cold.remove(cid),
475        };
476
477        let block = match block.as_mut() {
478            Some(b) => b,
479            None => return,
480        };
481
482        block.tier = to;
483        block.last_accessed = now;
484
485        // Make room in destination tier if needed.
486        let block_size = block.byte_size();
487        let max_dest = match to {
488            CacheTier::Hot => self.config.max_hot_bytes,
489            CacheTier::Warm => self.config.max_warm_bytes,
490            CacheTier::Cold => self.config.max_cold_bytes,
491        };
492        let dest_used = match to {
493            CacheTier::Hot => tier_bytes(&self.hot),
494            CacheTier::Warm => tier_bytes(&self.warm),
495            CacheTier::Cold => tier_bytes(&self.cold),
496        };
497        if dest_used + block_size > max_dest {
498            let to_free = (dest_used + block_size).saturating_sub(max_dest);
499            self.evict_to_fit(to, to_free, now);
500        }
501
502        let block = block.clone();
503        match to {
504            CacheTier::Hot => {
505                self.hot.insert(cid.to_owned(), block);
506            }
507            CacheTier::Warm => {
508                self.warm.insert(cid.to_owned(), block);
509            }
510            CacheTier::Cold => {
511                self.cold.insert(cid.to_owned(), block);
512            }
513        }
514
515        if is_promotion {
516            self.promotions += 1;
517        } else {
518            self.demotions += 1;
519        }
520    }
521
522    // ── Byte-size queries ─────────────────────────────────────────────────────
523
524    /// Total bytes used by the Hot tier.
525    #[must_use]
526    pub fn total_hot_bytes(&self) -> u64 {
527        tier_bytes(&self.hot)
528    }
529
530    /// Total bytes used by the Warm tier.
531    #[must_use]
532    pub fn total_warm_bytes(&self) -> u64 {
533        tier_bytes(&self.warm)
534    }
535
536    /// Total bytes used by the Cold tier.
537    #[must_use]
538    pub fn total_cold_bytes(&self) -> u64 {
539        tier_bytes(&self.cold)
540    }
541
542    /// Total bytes across all tiers.
543    #[must_use]
544    pub fn total_bytes(&self) -> u64 {
545        self.total_hot_bytes() + self.total_warm_bytes() + self.total_cold_bytes()
546    }
547
548    // ── Counting ─────────────────────────────────────────────────────────────
549
550    /// Total number of blocks across all tiers.
551    #[must_use]
552    pub fn len(&self) -> usize {
553        self.hot.len() + self.warm.len() + self.cold.len()
554    }
555
556    /// `true` if no blocks are currently cached.
557    #[must_use]
558    pub fn is_empty(&self) -> bool {
559        self.len() == 0
560    }
561
562    // ── Removal ──────────────────────────────────────────────────────────────
563
564    /// Remove a block from the cache, regardless of tier.
565    ///
566    /// Returns `false` if the block is pinned or not found.
567    pub fn remove(&mut self, cid: &str) -> bool {
568        // Check pinned state before removal.
569        let is_pinned = self.hot.get(cid).is_some_and(|b| b.pinned)
570            || self.warm.get(cid).is_some_and(|b| b.pinned)
571            || self.cold.get(cid).is_some_and(|b| b.pinned);
572
573        if is_pinned {
574            return false;
575        }
576
577        self.hot.remove(cid).is_some()
578            || self.warm.remove(cid).is_some()
579            || self.cold.remove(cid).is_some()
580    }
581
582    // ── Lookup ───────────────────────────────────────────────────────────────
583
584    /// Return `true` if the CID is cached in any tier.
585    #[must_use]
586    pub fn contains(&self, cid: &str) -> bool {
587        self.hot.contains_key(cid) || self.warm.contains_key(cid) || self.cold.contains_key(cid)
588    }
589
590    /// Return which tier the block lives in, or `None` if not cached.
591    #[must_use]
592    pub fn tier_of(&self, cid: &str) -> Option<CacheTier> {
593        if self.hot.contains_key(cid) {
594            Some(CacheTier::Hot)
595        } else if self.warm.contains_key(cid) {
596            Some(CacheTier::Warm)
597        } else if self.cold.contains_key(cid) {
598            Some(CacheTier::Cold)
599        } else {
600            None
601        }
602    }
603
604    // ── Statistics ────────────────────────────────────────────────────────────
605
606    /// Snapshot of current cache statistics.
607    #[must_use]
608    pub fn stats(&self) -> BcmCacheStats {
609        let pinned_count = self.hot.values().filter(|b| b.pinned).count()
610            + self.warm.values().filter(|b| b.pinned).count()
611            + self.cold.values().filter(|b| b.pinned).count();
612
613        BcmCacheStats {
614            hot_count: self.hot.len(),
615            warm_count: self.warm.len(),
616            cold_count: self.cold.len(),
617            hot_bytes: self.total_hot_bytes(),
618            warm_bytes: self.total_warm_bytes(),
619            cold_bytes: self.total_cold_bytes(),
620            pinned_count,
621            evictions: self.evictions,
622            promotions: self.promotions,
623            demotions: self.demotions,
624        }
625    }
626}
627
628// ────────────────────────────────────────────────────────────────────────────
629// CacheTier as u8 helper (for priority comparison)
630// ────────────────────────────────────────────────────────────────────────────
631
632impl From<CacheTier> for u8 {
633    fn from(tier: CacheTier) -> u8 {
634        tier.priority()
635    }
636}
637
638// ────────────────────────────────────────────────────────────────────────────
639// Tests
640// ────────────────────────────────────────────────────────────────────────────
641
642#[cfg(test)]
643mod tests {
644    use super::{
645        choose_victim, tier_bytes, BcmCacheConfig, BcmCacheStats, BcmCachedBlock,
646        BlockCacheManager, CacheTier, EvictionPolicy,
647    };
648    use std::collections::HashMap;
649
650    // ── Helpers ───────────────────────────────────────────────────────────────
651
652    fn default_config() -> BcmCacheConfig {
653        BcmCacheConfig {
654            max_hot_bytes: 1024,
655            max_warm_bytes: 2048,
656            max_cold_bytes: 4096,
657            hot_threshold: 5,
658            warm_threshold: 2,
659            eviction_policy: EvictionPolicy::LRU,
660        }
661    }
662
663    fn make_manager() -> BlockCacheManager {
664        BlockCacheManager::new(default_config())
665    }
666
667    fn make_block(cid: &str, size: usize, now: u64) -> BcmCachedBlock {
668        BcmCachedBlock::new(cid.to_owned(), vec![0u8; size], now)
669    }
670
671    // ── CacheTier ────────────────────────────────────────────────────────────
672
673    #[test]
674    fn test_tier_priority() {
675        assert_eq!(CacheTier::Hot.priority(), 3);
676        assert_eq!(CacheTier::Warm.priority(), 2);
677        assert_eq!(CacheTier::Cold.priority(), 1);
678    }
679
680    #[test]
681    fn test_tier_promoted() {
682        assert_eq!(CacheTier::Cold.promoted(), CacheTier::Warm);
683        assert_eq!(CacheTier::Warm.promoted(), CacheTier::Hot);
684        assert_eq!(CacheTier::Hot.promoted(), CacheTier::Hot);
685    }
686
687    #[test]
688    fn test_tier_demoted() {
689        assert_eq!(CacheTier::Hot.demoted(), CacheTier::Warm);
690        assert_eq!(CacheTier::Warm.demoted(), CacheTier::Cold);
691        assert_eq!(CacheTier::Cold.demoted(), CacheTier::Cold);
692    }
693
694    #[test]
695    fn test_tier_from_u8_priority() {
696        let hot: u8 = CacheTier::Hot.into();
697        let warm: u8 = CacheTier::Warm.into();
698        let cold: u8 = CacheTier::Cold.into();
699        assert!(hot > warm && warm > cold);
700    }
701
702    // ── BcmCachedBlock ────────────────────────────────────────────────────────
703
704    #[test]
705    fn test_cached_block_initial_state() {
706        let block = make_block("cid1", 128, 1000);
707        assert_eq!(block.cid, "cid1");
708        assert_eq!(block.data.len(), 128);
709        assert_eq!(block.tier, CacheTier::Cold);
710        assert_eq!(block.inserted_at, 1000);
711        assert_eq!(block.last_accessed, 1000);
712        assert_eq!(block.access_count, 0);
713        assert!(!block.pinned);
714    }
715
716    #[test]
717    fn test_cached_block_byte_size() {
718        let block = make_block("cid2", 64, 0);
719        assert_eq!(block.byte_size(), 64);
720    }
721
722    // ── tier_bytes helper ────────────────────────────────────────────────────
723
724    #[test]
725    fn test_tier_bytes_empty() {
726        let map: HashMap<String, BcmCachedBlock> = HashMap::new();
727        assert_eq!(tier_bytes(&map), 0);
728    }
729
730    #[test]
731    fn test_tier_bytes_sum() {
732        let mut map = HashMap::new();
733        map.insert("a".to_owned(), make_block("a", 100, 0));
734        map.insert("b".to_owned(), make_block("b", 200, 0));
735        assert_eq!(tier_bytes(&map), 300);
736    }
737
738    // ── choose_victim ────────────────────────────────────────────────────────
739
740    #[test]
741    fn test_choose_victim_empty() {
742        let map: HashMap<String, BcmCachedBlock> = HashMap::new();
743        assert!(choose_victim(&map, EvictionPolicy::LRU).is_none());
744    }
745
746    #[test]
747    fn test_choose_victim_all_pinned() {
748        let mut map = HashMap::new();
749        let mut b = make_block("cid1", 10, 0);
750        b.pinned = true;
751        map.insert("cid1".to_owned(), b);
752        assert!(choose_victim(&map, EvictionPolicy::LRU).is_none());
753    }
754
755    #[test]
756    fn test_choose_victim_lru_selects_oldest() {
757        let mut map = HashMap::new();
758        let mut b1 = make_block("cid_old", 10, 0);
759        b1.last_accessed = 1;
760        let mut b2 = make_block("cid_new", 10, 0);
761        b2.last_accessed = 100;
762        map.insert("cid_old".to_owned(), b1);
763        map.insert("cid_new".to_owned(), b2);
764        let victim = choose_victim(&map, EvictionPolicy::LRU).unwrap();
765        assert_eq!(victim, "cid_old");
766    }
767
768    #[test]
769    fn test_choose_victim_lfu_selects_least_frequent() {
770        let mut map = HashMap::new();
771        let mut b1 = make_block("cid_rare", 10, 0);
772        b1.access_count = 1;
773        b1.last_accessed = 50;
774        let mut b2 = make_block("cid_freq", 10, 0);
775        b2.access_count = 99;
776        b2.last_accessed = 10;
777        map.insert("cid_rare".to_owned(), b1);
778        map.insert("cid_freq".to_owned(), b2);
779        let victim = choose_victim(&map, EvictionPolicy::LFU).unwrap();
780        assert_eq!(victim, "cid_rare");
781    }
782
783    #[test]
784    fn test_choose_victim_lfu_tiebreak_by_last_accessed() {
785        let mut map = HashMap::new();
786        let mut b1 = make_block("cid_a", 10, 0);
787        b1.access_count = 3;
788        b1.last_accessed = 5;
789        let mut b2 = make_block("cid_b", 10, 0);
790        b2.access_count = 3;
791        b2.last_accessed = 10;
792        map.insert("cid_a".to_owned(), b1);
793        map.insert("cid_b".to_owned(), b2);
794        let victim = choose_victim(&map, EvictionPolicy::LFU).unwrap();
795        assert_eq!(victim, "cid_a");
796    }
797
798    // ── BlockCacheManager ─────────────────────────────────────────────────────
799
800    #[test]
801    fn test_new_empty() {
802        let mgr = make_manager();
803        assert!(mgr.is_empty());
804        assert_eq!(mgr.len(), 0);
805    }
806
807    #[test]
808    fn test_insert_basic() {
809        let mut mgr = make_manager();
810        assert!(mgr.insert("cid1".to_owned(), vec![1u8; 64], 100));
811        assert!(!mgr.is_empty());
812        assert_eq!(mgr.len(), 1);
813        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
814    }
815
816    #[test]
817    fn test_insert_duplicate_returns_false() {
818        let mut mgr = make_manager();
819        assert!(mgr.insert("cid1".to_owned(), vec![0u8; 64], 100));
820        assert!(!mgr.insert("cid1".to_owned(), vec![1u8; 64], 101));
821    }
822
823    #[test]
824    fn test_insert_multiple() {
825        let mut mgr = make_manager();
826        for i in 0..5u64 {
827            assert!(mgr.insert(format!("cid{i}"), vec![0u8; 50], i));
828        }
829        assert_eq!(mgr.len(), 5);
830    }
831
832    #[test]
833    fn test_contains() {
834        let mut mgr = make_manager();
835        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
836        assert!(mgr.contains("cid1"));
837        assert!(!mgr.contains("cid_missing"));
838    }
839
840    #[test]
841    fn test_get_returns_data() {
842        let mut mgr = make_manager();
843        let data = vec![42u8; 64];
844        mgr.insert("cid1".to_owned(), data.clone(), 0);
845        let retrieved = mgr.get("cid1", 1);
846        assert_eq!(retrieved, Some(data.as_slice()));
847    }
848
849    #[test]
850    fn test_get_missing() {
851        let mut mgr = make_manager();
852        assert!(mgr.get("cid_missing", 0).is_none());
853    }
854
855    #[test]
856    fn test_get_updates_access_count() {
857        let mut mgr = make_manager();
858        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
859        mgr.get("cid1", 1);
860        // After 1 get the cold block should still be cold (below warm_threshold=2).
861        // access_count should be 1.
862        let block = mgr.cold.get("cid1").unwrap();
863        assert_eq!(block.access_count, 1);
864    }
865
866    #[test]
867    fn test_get_updates_last_accessed() {
868        let mut mgr = make_manager();
869        mgr.insert("cid1".to_owned(), vec![0u8; 10], 100);
870        mgr.get("cid1", 200);
871        // Block may have promoted — check wherever it is.
872        let ts = mgr
873            .cold
874            .get("cid1")
875            .or_else(|| mgr.warm.get("cid1"))
876            .or_else(|| mgr.hot.get("cid1"))
877            .map(|b| b.last_accessed)
878            .unwrap_or(0);
879        assert_eq!(ts, 200);
880    }
881
882    // ── Promotion ────────────────────────────────────────────────────────────
883
884    #[test]
885    fn test_promote_cold_to_warm() {
886        let mut mgr = make_manager();
887        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
888        // access warm_threshold = 2 times to trigger warm promotion.
889        for i in 1..=2u64 {
890            mgr.get("cid1", i);
891        }
892        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Warm));
893        assert_eq!(mgr.stats().promotions, 1);
894    }
895
896    #[test]
897    fn test_promote_warm_to_hot() {
898        let mut mgr = make_manager();
899        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
900        // hot_threshold = 5
901        for i in 1..=5u64 {
902            mgr.get("cid1", i);
903        }
904        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Hot));
905        let s = mgr.stats();
906        // At least two promotions: cold→warm then warm→hot (may be more due to re-checks).
907        assert!(s.promotions >= 2);
908    }
909
910    #[test]
911    fn test_no_promotion_below_warm_threshold() {
912        let mut mgr = make_manager();
913        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
914        // Access just once — below warm_threshold=2.
915        mgr.get("cid1", 1);
916        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
917        assert_eq!(mgr.stats().promotions, 0);
918    }
919
920    // ── Demotion ─────────────────────────────────────────────────────────────
921
922    #[test]
923    fn test_demote_hot_to_warm() {
924        let mut mgr = make_manager();
925        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
926        // promote to Hot
927        for i in 1..=5u64 {
928            mgr.get("cid1", i);
929        }
930        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Hot));
931        mgr.demote("cid1", 10);
932        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Warm));
933        assert_eq!(mgr.stats().demotions, 1);
934    }
935
936    #[test]
937    fn test_demote_warm_to_cold() {
938        let mut mgr = make_manager();
939        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
940        for i in 1..=2u64 {
941            mgr.get("cid1", i);
942        }
943        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Warm));
944        mgr.demote("cid1", 10);
945        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
946    }
947
948    #[test]
949    fn test_demote_cold_is_noop() {
950        let mut mgr = make_manager();
951        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
952        mgr.demote("cid1", 1);
953        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
954        assert_eq!(mgr.stats().demotions, 0);
955    }
956
957    #[test]
958    fn test_demote_missing_is_noop() {
959        let mut mgr = make_manager();
960        mgr.demote("cid_missing", 0);
961        assert_eq!(mgr.stats().demotions, 0);
962    }
963
964    // ── Pinning ───────────────────────────────────────────────────────────────
965
966    #[test]
967    fn test_pin_and_unpin() {
968        let mut mgr = make_manager();
969        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
970        assert!(mgr.pin("cid1"));
971        assert_eq!(mgr.stats().pinned_count, 1);
972        assert!(mgr.unpin("cid1"));
973        assert_eq!(mgr.stats().pinned_count, 0);
974    }
975
976    #[test]
977    fn test_pin_missing() {
978        let mut mgr = make_manager();
979        assert!(!mgr.pin("cid_missing"));
980    }
981
982    #[test]
983    fn test_unpin_missing() {
984        let mut mgr = make_manager();
985        assert!(!mgr.unpin("cid_missing"));
986    }
987
988    #[test]
989    fn test_pinned_block_not_evicted() {
990        let mut mgr = make_manager();
991        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
992        mgr.pin("cid1");
993        // Force eviction of more bytes than the block uses.
994        let freed = mgr.evict_to_fit(CacheTier::Cold, 10, 1);
995        assert_eq!(freed, 0);
996        assert!(mgr.contains("cid1"));
997    }
998
999    #[test]
1000    fn test_remove_unpinned() {
1001        let mut mgr = make_manager();
1002        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
1003        assert!(mgr.remove("cid1"));
1004        assert!(!mgr.contains("cid1"));
1005    }
1006
1007    #[test]
1008    fn test_remove_pinned_fails() {
1009        let mut mgr = make_manager();
1010        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
1011        mgr.pin("cid1");
1012        assert!(!mgr.remove("cid1"));
1013        assert!(mgr.contains("cid1"));
1014    }
1015
1016    #[test]
1017    fn test_remove_missing() {
1018        let mut mgr = make_manager();
1019        assert!(!mgr.remove("cid_missing"));
1020    }
1021
1022    // ── Byte accounting ───────────────────────────────────────────────────────
1023
1024    #[test]
1025    fn test_total_bytes() {
1026        let mut mgr = make_manager();
1027        mgr.insert("cid1".to_owned(), vec![0u8; 100], 0);
1028        assert_eq!(mgr.total_bytes(), 100);
1029        assert_eq!(mgr.total_cold_bytes(), 100);
1030        assert_eq!(mgr.total_hot_bytes(), 0);
1031        assert_eq!(mgr.total_warm_bytes(), 0);
1032    }
1033
1034    #[test]
1035    fn test_bytes_after_promotion() {
1036        let mut mgr = make_manager();
1037        mgr.insert("cid1".to_owned(), vec![0u8; 100], 0);
1038        for i in 1..=2u64 {
1039            mgr.get("cid1", i);
1040        }
1041        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Warm));
1042        assert_eq!(mgr.total_warm_bytes(), 100);
1043        assert_eq!(mgr.total_cold_bytes(), 0);
1044        assert_eq!(mgr.total_bytes(), 100);
1045    }
1046
1047    // ── Eviction ─────────────────────────────────────────────────────────────
1048
1049    #[test]
1050    fn test_evict_to_fit_frees_bytes() {
1051        let mut mgr = make_manager();
1052        mgr.insert("cid1".to_owned(), vec![0u8; 100], 0);
1053        mgr.insert("cid2".to_owned(), vec![0u8; 100], 1);
1054        let freed = mgr.evict_to_fit(CacheTier::Cold, 100, 2);
1055        assert!(freed >= 100);
1056    }
1057
1058    #[test]
1059    fn test_evict_empty_tier_returns_zero() {
1060        let mut mgr = make_manager();
1061        let freed = mgr.evict_to_fit(CacheTier::Hot, 100, 0);
1062        assert_eq!(freed, 0);
1063    }
1064
1065    #[test]
1066    fn test_auto_eviction_on_cold_overflow() {
1067        // Cold capacity = 4096 bytes; insert blocks totaling > 4096.
1068        let mut mgr = make_manager();
1069        // Insert 50 blocks of 100 bytes = 5000 bytes > 4096.
1070        for i in 0..50u64 {
1071            mgr.insert(format!("cid{i}"), vec![0u8; 100], i);
1072        }
1073        assert!(
1074            mgr.total_cold_bytes() <= 4096,
1075            "Cold tier exceeded capacity"
1076        );
1077    }
1078
1079    #[test]
1080    fn test_evictions_counter() {
1081        let mut mgr = make_manager();
1082        for i in 0..50u64 {
1083            mgr.insert(format!("cid{i}"), vec![0u8; 100], i);
1084        }
1085        assert!(mgr.stats().evictions > 0);
1086    }
1087
1088    // ── Stats ─────────────────────────────────────────────────────────────────
1089
1090    #[test]
1091    fn test_stats_initial() {
1092        let mgr = make_manager();
1093        let s = mgr.stats();
1094        assert_eq!(s.hot_count, 0);
1095        assert_eq!(s.warm_count, 0);
1096        assert_eq!(s.cold_count, 0);
1097        assert_eq!(s.evictions, 0);
1098        assert_eq!(s.promotions, 0);
1099        assert_eq!(s.demotions, 0);
1100    }
1101
1102    #[test]
1103    fn test_stats_counts() {
1104        let mut mgr = make_manager();
1105        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
1106        mgr.insert("cid2".to_owned(), vec![0u8; 10], 1);
1107        let s = mgr.stats();
1108        assert_eq!(s.cold_count, 2);
1109    }
1110
1111    #[test]
1112    fn test_stats_default() {
1113        let s = BcmCacheStats::default();
1114        assert_eq!(s.evictions, 0);
1115        assert_eq!(s.hot_count, 0);
1116    }
1117
1118    // ── tier_of ───────────────────────────────────────────────────────────────
1119
1120    #[test]
1121    fn test_tier_of_missing() {
1122        let mgr = make_manager();
1123        assert!(mgr.tier_of("missing").is_none());
1124    }
1125
1126    #[test]
1127    fn test_tier_of_cold() {
1128        let mut mgr = make_manager();
1129        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
1130        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
1131    }
1132
1133    // ── LFU policy ────────────────────────────────────────────────────────────
1134
1135    #[test]
1136    fn test_lfu_evicts_least_frequent() {
1137        let config = BcmCacheConfig {
1138            max_cold_bytes: 200,
1139            eviction_policy: EvictionPolicy::LFU,
1140            ..default_config()
1141        };
1142        let mut mgr = BlockCacheManager::new(config);
1143
1144        mgr.insert("cid_rare".to_owned(), vec![0u8; 100], 0);
1145        // access cid_rare only once
1146        mgr.get("cid_rare", 1);
1147
1148        mgr.insert("cid_freq".to_owned(), vec![0u8; 100], 2);
1149        // access cid_freq many times — keep it below warm_threshold to avoid promotion
1150        // (warm_threshold = 2, so access 1 time to stay in cold but have higher count than cid_rare's 1)
1151        // We need cid_rare to remain cold too. Reset access_count tricks won't work.
1152        // Instead: cid_rare was accessed at t=1 (count=1), cid_freq at t=2 (count=0 still).
1153        // So LFU should evict cid_freq (count=0).
1154
1155        // Now force insert a third block to trigger eviction.
1156        mgr.insert("cid_new".to_owned(), vec![0u8; 100], 3);
1157
1158        // The block with lower access_count (cid_freq, count=0) should be evicted.
1159        assert!(
1160            !mgr.contains("cid_freq"),
1161            "cid_freq should have been evicted by LFU"
1162        );
1163        assert!(mgr.contains("cid_rare"), "cid_rare should still be present");
1164    }
1165
1166    // ── TwoQ / ARC fall back to LRU ──────────────────────────────────────────
1167
1168    #[test]
1169    fn test_twoq_behaves_like_lru() {
1170        let config = BcmCacheConfig {
1171            max_cold_bytes: 200,
1172            eviction_policy: EvictionPolicy::TwoQ,
1173            ..default_config()
1174        };
1175        let mut mgr = BlockCacheManager::new(config);
1176        mgr.insert("cid_old".to_owned(), vec![0u8; 100], 0);
1177        mgr.insert("cid_new".to_owned(), vec![0u8; 100], 10);
1178        // force eviction
1179        mgr.insert("cid_extra".to_owned(), vec![0u8; 100], 20);
1180        // cid_old (last_accessed=0) should be evicted over cid_new (last_accessed=10).
1181        assert!(!mgr.contains("cid_old"));
1182    }
1183
1184    #[test]
1185    fn test_arc_behaves_like_lru() {
1186        let config = BcmCacheConfig {
1187            max_cold_bytes: 200,
1188            eviction_policy: EvictionPolicy::ARC,
1189            ..default_config()
1190        };
1191        let mut mgr = BlockCacheManager::new(config);
1192        mgr.insert("cid_old".to_owned(), vec![0u8; 100], 0);
1193        mgr.insert("cid_new".to_owned(), vec![0u8; 100], 10);
1194        mgr.insert("cid_extra".to_owned(), vec![0u8; 100], 20);
1195        assert!(!mgr.contains("cid_old"));
1196    }
1197
1198    // ── Promote explicitly called ─────────────────────────────────────────────
1199
1200    #[test]
1201    fn test_explicit_promote_missing_is_noop() {
1202        let mut mgr = make_manager();
1203        mgr.promote("cid_missing", 0); // should not panic
1204    }
1205
1206    #[test]
1207    fn test_explicit_demote_on_hot_block() {
1208        let mut mgr = make_manager();
1209        mgr.insert("cid1".to_owned(), vec![0u8; 10], 0);
1210        for i in 1..=5u64 {
1211            mgr.get("cid1", i);
1212        }
1213        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Hot));
1214
1215        mgr.demote("cid1", 10);
1216        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Warm));
1217        mgr.demote("cid1", 11);
1218        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
1219        // Cold → demote → still Cold
1220        mgr.demote("cid1", 12);
1221        assert_eq!(mgr.tier_of("cid1"), Some(CacheTier::Cold));
1222    }
1223
1224    // ── Default config ────────────────────────────────────────────────────────
1225
1226    #[test]
1227    fn test_default_config_values() {
1228        let cfg = BcmCacheConfig::default();
1229        assert_eq!(cfg.max_hot_bytes, 64 * 1024 * 1024);
1230        assert_eq!(cfg.max_warm_bytes, 128 * 1024 * 1024);
1231        assert_eq!(cfg.max_cold_bytes, 256 * 1024 * 1024);
1232        assert_eq!(cfg.hot_threshold, 10);
1233        assert_eq!(cfg.warm_threshold, 3);
1234    }
1235
1236    // ── Large insert / eviction stress ───────────────────────────────────────
1237
1238    #[test]
1239    fn test_large_insert_respects_cold_cap() {
1240        let mut mgr = make_manager(); // cold cap = 4096
1241        for i in 0..100u64 {
1242            mgr.insert(format!("c{i}"), vec![9u8; 100], i);
1243        }
1244        assert!(
1245            mgr.total_cold_bytes() <= 4096 + 100,
1246            "cold bytes {} exceed cap",
1247            mgr.total_cold_bytes()
1248        );
1249    }
1250
1251    #[test]
1252    fn test_total_bytes_consistent() {
1253        let mut mgr = make_manager();
1254        for i in 0..10u64 {
1255            mgr.insert(format!("c{i}"), vec![0u8; 50], i);
1256        }
1257        let s = mgr.stats();
1258        assert_eq!(s.hot_bytes + s.warm_bytes + s.cold_bytes, mgr.total_bytes());
1259    }
1260}