Skip to main content

ipfrs_storage/
eviction_policy.rs

1//! Pluggable cache eviction policy engine for IPFRS storage tier management.
2//!
3//! Supports LRU, LFU, FIFO, and SizePriority eviction strategies.
4
5use std::collections::HashMap;
6
7/// Eviction strategy selector.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub enum EvictionStrategy {
10    /// Least Recently Used — evict the entry accessed furthest in the past.
11    Lru,
12    /// Least Frequently Used — evict the entry with the fewest accesses;
13    /// ties broken by earliest insertion tick.
14    Lfu,
15    /// First In, First Out — evict the entry inserted earliest.
16    Fifo,
17    /// Size Priority — evict the largest entry first;
18    /// ties broken by smallest `block_id`.
19    SizePriority,
20}
21
22/// A single entry tracked by the eviction policy.
23#[derive(Clone, Debug)]
24pub struct CacheEntry {
25    /// Identifier of the cached block.
26    pub block_id: u64,
27    /// Size of the block in bytes.
28    pub size_bytes: u64,
29    /// Logical clock tick at which the entry was first inserted.
30    pub inserted_at_tick: u64,
31    /// Logical clock tick of the most recent access.
32    pub last_accessed_tick: u64,
33    /// Total number of times the entry has been accessed.
34    pub access_count: u64,
35}
36
37/// A block selected for eviction.
38#[derive(Clone, Debug)]
39pub struct EvictionCandidate {
40    /// Identifier of the evicted block.
41    pub block_id: u64,
42    /// Size of the evicted block in bytes.
43    pub size_bytes: u64,
44    /// The strategy that selected this entry for eviction.
45    pub reason: EvictionStrategy,
46}
47
48/// Aggregate statistics for a `StorageEvictionPolicy` instance.
49#[derive(Clone, Debug, Default)]
50pub struct PolicyStats {
51    /// Current number of entries in the cache.
52    pub total_entries: usize,
53    /// Current total size of all cached blocks in bytes.
54    pub total_size_bytes: u64,
55    /// Cumulative number of evictions since creation.
56    pub total_evictions: u64,
57    /// Cumulative cache hits (successful `access` calls).
58    pub hits: u64,
59    /// Cumulative cache misses (failed `access` calls).
60    pub misses: u64,
61}
62
63/// Pluggable storage-tier eviction policy engine.
64///
65/// Maintains a map of [`CacheEntry`] items, enforces a byte-capacity limit
66/// using the configured [`EvictionStrategy`], and tracks hit/miss/eviction
67/// statistics via [`PolicyStats`].
68pub struct StorageEvictionPolicy {
69    /// All currently cached entries, keyed by `block_id`.
70    pub entries: HashMap<u64, CacheEntry>,
71    /// Active eviction strategy.
72    pub strategy: EvictionStrategy,
73    /// Maximum total cached bytes before eviction is triggered.
74    pub capacity_bytes: u64,
75    /// Running statistics.
76    pub stats: PolicyStats,
77}
78
79impl StorageEvictionPolicy {
80    /// Create a new policy with the given strategy and byte capacity.
81    pub fn new(strategy: EvictionStrategy, capacity_bytes: u64) -> Self {
82        Self {
83            entries: HashMap::new(),
84            strategy,
85            capacity_bytes,
86            stats: PolicyStats::default(),
87        }
88    }
89
90    /// Insert a block into the cache.
91    ///
92    /// If `block_id` already exists, only `size_bytes` and
93    /// `last_accessed_tick` are updated; `inserted_at_tick` is preserved.
94    /// [`PolicyStats::total_size_bytes`] is adjusted accordingly.
95    pub fn insert(&mut self, block_id: u64, size_bytes: u64, current_tick: u64) {
96        if let Some(existing) = self.entries.get_mut(&block_id) {
97            // Adjust size delta without changing inserted_at_tick.
98            let old_size = existing.size_bytes;
99            existing.size_bytes = size_bytes;
100            existing.last_accessed_tick = current_tick;
101            // Saturating arithmetic to avoid overflow.
102            self.stats.total_size_bytes = self
103                .stats
104                .total_size_bytes
105                .saturating_sub(old_size)
106                .saturating_add(size_bytes);
107        } else {
108            let entry = CacheEntry {
109                block_id,
110                size_bytes,
111                inserted_at_tick: current_tick,
112                last_accessed_tick: current_tick,
113                access_count: 0,
114            };
115            self.entries.insert(block_id, entry);
116            self.stats.total_size_bytes = self.stats.total_size_bytes.saturating_add(size_bytes);
117            self.stats.total_entries = self.entries.len();
118        }
119        self.stats.total_entries = self.entries.len();
120    }
121
122    /// Record an access to `block_id`.
123    ///
124    /// Returns `true` and increments `stats.hits` when the entry exists.
125    /// Returns `false` and increments `stats.misses` when it does not.
126    pub fn access(&mut self, block_id: u64, current_tick: u64) -> bool {
127        if let Some(entry) = self.entries.get_mut(&block_id) {
128            entry.last_accessed_tick = current_tick;
129            entry.access_count = entry.access_count.saturating_add(1);
130            self.stats.hits = self.stats.hits.saturating_add(1);
131            true
132        } else {
133            self.stats.misses = self.stats.misses.saturating_add(1);
134            false
135        }
136    }
137
138    /// Evict entries until [`total_size_bytes`][PolicyStats::total_size_bytes]
139    /// is within [`capacity_bytes`][StorageEvictionPolicy::capacity_bytes].
140    ///
141    /// Returns the list of [`EvictionCandidate`]s that were removed, in the
142    /// order they were evicted.
143    pub fn evict_to_fit(&mut self) -> Vec<EvictionCandidate> {
144        let mut evicted = Vec::new();
145
146        while self.stats.total_size_bytes > self.capacity_bytes {
147            if self.entries.is_empty() {
148                break;
149            }
150
151            let victim_id = self.select_victim();
152
153            if let Some(entry) = self.entries.remove(&victim_id) {
154                self.stats.total_size_bytes =
155                    self.stats.total_size_bytes.saturating_sub(entry.size_bytes);
156                self.stats.total_evictions = self.stats.total_evictions.saturating_add(1);
157                self.stats.total_entries = self.entries.len();
158
159                evicted.push(EvictionCandidate {
160                    block_id: entry.block_id,
161                    size_bytes: entry.size_bytes,
162                    reason: self.strategy,
163                });
164            }
165        }
166
167        evicted
168    }
169
170    /// Select the `block_id` of the victim to evict based on the current strategy.
171    fn select_victim(&self) -> u64 {
172        match self.strategy {
173            EvictionStrategy::Lru => {
174                // Smallest last_accessed_tick.
175                self.entries
176                    .values()
177                    .min_by_key(|e| e.last_accessed_tick)
178                    .map(|e| e.block_id)
179                    .expect("entries is non-empty")
180            }
181            EvictionStrategy::Lfu => {
182                // Smallest access_count; tie: smallest inserted_at_tick.
183                self.entries
184                    .values()
185                    .min_by_key(|e| (e.access_count, e.inserted_at_tick))
186                    .map(|e| e.block_id)
187                    .expect("entries is non-empty")
188            }
189            EvictionStrategy::Fifo => {
190                // Smallest inserted_at_tick.
191                self.entries
192                    .values()
193                    .min_by_key(|e| e.inserted_at_tick)
194                    .map(|e| e.block_id)
195                    .expect("entries is non-empty")
196            }
197            EvictionStrategy::SizePriority => {
198                // Largest size_bytes; tie: smallest block_id.
199                self.entries
200                    .values()
201                    .max_by_key(|e| (e.size_bytes, std::cmp::Reverse(e.block_id)))
202                    .map(|e| e.block_id)
203                    .expect("entries is non-empty")
204            }
205        }
206    }
207
208    /// Remove a specific entry from the cache.
209    ///
210    /// Returns `true` if the entry existed and was removed, `false` otherwise.
211    pub fn remove(&mut self, block_id: u64) -> bool {
212        if let Some(entry) = self.entries.remove(&block_id) {
213            self.stats.total_size_bytes =
214                self.stats.total_size_bytes.saturating_sub(entry.size_bytes);
215            self.stats.total_entries = self.entries.len();
216            true
217        } else {
218            false
219        }
220    }
221
222    /// Returns `true` when [`total_size_bytes`][PolicyStats::total_size_bytes]
223    /// exceeds [`capacity_bytes`][StorageEvictionPolicy::capacity_bytes].
224    pub fn is_over_capacity(&self) -> bool {
225        self.stats.total_size_bytes > self.capacity_bytes
226    }
227
228    /// Return a reference to the current [`PolicyStats`].
229    pub fn stats(&self) -> &PolicyStats {
230        &self.stats
231    }
232
233    /// Replace the active eviction strategy.
234    pub fn set_strategy(&mut self, strategy: EvictionStrategy) {
235        self.strategy = strategy;
236    }
237}
238
239// ────────────────────────────────────────────────────────────────────────────
240// Tests
241// ────────────────────────────────────────────────────────────────────────────
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    // ── helpers ─────────────────────────────────────────────────────────────
248
249    fn policy(strategy: EvictionStrategy, cap: u64) -> StorageEvictionPolicy {
250        StorageEvictionPolicy::new(strategy, cap)
251    }
252
253    // ── insert ──────────────────────────────────────────────────────────────
254
255    #[test]
256    fn test_insert_adds_entry_and_updates_size() {
257        let mut p = policy(EvictionStrategy::Lru, 1000);
258        p.insert(1, 100, 1);
259        assert_eq!(p.entries.len(), 1);
260        assert_eq!(p.stats().total_size_bytes, 100);
261        assert_eq!(p.stats().total_entries, 1);
262    }
263
264    #[test]
265    fn test_insert_multiple_entries_accumulates_size() {
266        let mut p = policy(EvictionStrategy::Lru, 1000);
267        p.insert(1, 100, 1);
268        p.insert(2, 200, 2);
269        p.insert(3, 300, 3);
270        assert_eq!(p.stats().total_size_bytes, 600);
271        assert_eq!(p.stats().total_entries, 3);
272    }
273
274    #[test]
275    fn test_insert_existing_block_updates_size_not_inserted_tick() {
276        let mut p = policy(EvictionStrategy::Lru, 1000);
277        p.insert(42, 100, 5);
278        let original_inserted_at = p.entries[&42].inserted_at_tick;
279        p.insert(42, 250, 10);
280        assert_eq!(p.entries[&42].inserted_at_tick, original_inserted_at);
281        assert_eq!(p.entries[&42].size_bytes, 250);
282        assert_eq!(p.stats().total_size_bytes, 250);
283        assert_eq!(p.stats().total_entries, 1);
284    }
285
286    #[test]
287    fn test_insert_existing_block_size_decreases_correctly() {
288        let mut p = policy(EvictionStrategy::Lru, 1000);
289        p.insert(1, 500, 1);
290        p.insert(2, 200, 2);
291        p.insert(1, 100, 3); // shrink block 1
292        assert_eq!(p.stats().total_size_bytes, 300);
293    }
294
295    // ── access ──────────────────────────────────────────────────────────────
296
297    #[test]
298    fn test_access_returns_true_for_existing_entry() {
299        let mut p = policy(EvictionStrategy::Lru, 1000);
300        p.insert(7, 50, 1);
301        assert!(p.access(7, 2));
302    }
303
304    #[test]
305    fn test_access_returns_false_for_missing_entry() {
306        let mut p = policy(EvictionStrategy::Lru, 1000);
307        assert!(!p.access(99, 1));
308    }
309
310    #[test]
311    fn test_access_updates_last_accessed_tick_and_count() {
312        let mut p = policy(EvictionStrategy::Lru, 1000);
313        p.insert(1, 100, 1);
314        p.access(1, 10);
315        p.access(1, 20);
316        let e = &p.entries[&1];
317        assert_eq!(e.last_accessed_tick, 20);
318        assert_eq!(e.access_count, 2);
319    }
320
321    #[test]
322    fn test_access_increments_hits() {
323        let mut p = policy(EvictionStrategy::Lru, 1000);
324        p.insert(1, 100, 1);
325        p.access(1, 2);
326        p.access(1, 3);
327        assert_eq!(p.stats().hits, 2);
328        assert_eq!(p.stats().misses, 0);
329    }
330
331    #[test]
332    fn test_access_increments_misses() {
333        let mut p = policy(EvictionStrategy::Lru, 1000);
334        p.access(999, 1);
335        p.access(998, 1);
336        assert_eq!(p.stats().misses, 2);
337        assert_eq!(p.stats().hits, 0);
338    }
339
340    // ── evict_to_fit: LRU ───────────────────────────────────────────────────
341
342    #[test]
343    fn test_evict_lru_evicts_least_recently_used() {
344        let mut p = policy(EvictionStrategy::Lru, 200);
345        p.insert(1, 100, 1);
346        p.insert(2, 100, 2);
347        p.insert(3, 100, 3); // now over capacity (300 > 200)
348                             // Access block 1 to make it recently used.
349        p.access(1, 10);
350        // Block 2 has last_accessed_tick = 2, block 3 = 3, block 1 = 10.
351        let evicted = p.evict_to_fit();
352        assert_eq!(evicted.len(), 1);
353        assert_eq!(evicted[0].block_id, 2);
354        assert_eq!(evicted[0].reason, EvictionStrategy::Lru);
355    }
356
357    #[test]
358    fn test_evict_lru_multiple_rounds() {
359        let mut p = policy(EvictionStrategy::Lru, 100);
360        p.insert(1, 100, 1);
361        p.insert(2, 100, 2);
362        p.insert(3, 100, 3); // 300 > 100 → evict 2 rounds
363        let evicted = p.evict_to_fit();
364        assert_eq!(evicted.len(), 2);
365        let ids: Vec<u64> = evicted.iter().map(|c| c.block_id).collect();
366        assert!(ids.contains(&1));
367        assert!(ids.contains(&2));
368    }
369
370    // ── evict_to_fit: LFU ───────────────────────────────────────────────────
371
372    #[test]
373    fn test_evict_lfu_evicts_least_frequently_used() {
374        let mut p = policy(EvictionStrategy::Lfu, 200);
375        p.insert(1, 100, 1);
376        p.insert(2, 100, 2);
377        p.insert(3, 100, 3); // over capacity
378        p.access(1, 5);
379        p.access(1, 6);
380        p.access(2, 7);
381        // block 3 access_count=0, block 2=1, block 1=2 → evict block 3
382        let evicted = p.evict_to_fit();
383        assert_eq!(evicted.len(), 1);
384        assert_eq!(evicted[0].block_id, 3);
385        assert_eq!(evicted[0].reason, EvictionStrategy::Lfu);
386    }
387
388    #[test]
389    fn test_evict_lfu_tie_broken_by_earliest_inserted() {
390        let mut p = policy(EvictionStrategy::Lfu, 100);
391        // Both blocks inserted with access_count=0; block 1 inserted earlier.
392        p.insert(1, 100, 1);
393        p.insert(2, 100, 2); // now 200 > 100
394        let evicted = p.evict_to_fit();
395        assert_eq!(evicted.len(), 1);
396        assert_eq!(evicted[0].block_id, 1); // inserted_at_tick=1 < 2
397    }
398
399    // ── evict_to_fit: FIFO ──────────────────────────────────────────────────
400
401    #[test]
402    fn test_evict_fifo_evicts_oldest_first() {
403        let mut p = policy(EvictionStrategy::Fifo, 200);
404        p.insert(1, 100, 1);
405        p.insert(2, 100, 2);
406        p.insert(3, 100, 3); // over capacity
407                             // Access block 1 many times — FIFO ignores access recency.
408        p.access(1, 10);
409        p.access(1, 11);
410        p.access(1, 12);
411        let evicted = p.evict_to_fit();
412        assert_eq!(evicted.len(), 1);
413        assert_eq!(evicted[0].block_id, 1); // inserted_at_tick=1
414        assert_eq!(evicted[0].reason, EvictionStrategy::Fifo);
415    }
416
417    #[test]
418    fn test_evict_fifo_multiple_entries() {
419        let mut p = policy(EvictionStrategy::Fifo, 50);
420        p.insert(10, 100, 5);
421        p.insert(20, 100, 3);
422        p.insert(30, 100, 7); // 300 > 50 → evict all but one
423        let evicted = p.evict_to_fit();
424        // Order: block 20 (tick=3), block 10 (tick=5)
425        assert_eq!(evicted[0].block_id, 20);
426        assert_eq!(evicted[1].block_id, 10);
427    }
428
429    // ── evict_to_fit: SizePriority ──────────────────────────────────────────
430
431    #[test]
432    fn test_evict_size_priority_evicts_largest_first() {
433        let mut p = policy(EvictionStrategy::SizePriority, 200);
434        p.insert(1, 50, 1);
435        p.insert(2, 300, 2);
436        p.insert(3, 100, 3); // total=450 > 200
437        let evicted = p.evict_to_fit();
438        assert_eq!(evicted[0].block_id, 2); // largest = 300
439        assert_eq!(evicted[0].reason, EvictionStrategy::SizePriority);
440    }
441
442    #[test]
443    fn test_evict_size_priority_tie_smallest_block_id() {
444        let mut p = policy(EvictionStrategy::SizePriority, 100);
445        // Both blocks have size=200; tie broken by smaller block_id.
446        p.insert(5, 200, 1);
447        p.insert(3, 200, 2); // 400 > 100
448        let evicted = p.evict_to_fit();
449        assert_eq!(evicted[0].block_id, 3); // block_id 3 < 5
450    }
451
452    // ── evict_to_fit: stops when under capacity ──────────────────────────────
453
454    #[test]
455    fn test_evict_to_fit_stops_when_under_capacity() {
456        let mut p = policy(EvictionStrategy::Lru, 250);
457        p.insert(1, 100, 1);
458        p.insert(2, 100, 2);
459        p.insert(3, 100, 3); // total=300 > 250 → evict 1
460        let evicted = p.evict_to_fit();
461        assert_eq!(evicted.len(), 1);
462        assert!(p.stats().total_size_bytes <= 250);
463    }
464
465    #[test]
466    fn test_evict_to_fit_noop_when_under_capacity() {
467        let mut p = policy(EvictionStrategy::Lru, 1000);
468        p.insert(1, 100, 1);
469        let evicted = p.evict_to_fit();
470        assert!(evicted.is_empty());
471        assert_eq!(p.stats().total_evictions, 0);
472    }
473
474    // ── evict_to_fit: stats ─────────────────────────────────────────────────
475
476    #[test]
477    fn test_evict_increments_total_evictions() {
478        let mut p = policy(EvictionStrategy::Lru, 100);
479        p.insert(1, 100, 1);
480        p.insert(2, 100, 2);
481        p.insert(3, 100, 3); // evicts 2
482        p.evict_to_fit();
483        assert_eq!(p.stats().total_evictions, 2);
484    }
485
486    // ── remove ──────────────────────────────────────────────────────────────
487
488    #[test]
489    fn test_remove_existing_entry_returns_true_and_updates_size() {
490        let mut p = policy(EvictionStrategy::Lru, 1000);
491        p.insert(1, 400, 1);
492        p.insert(2, 200, 2);
493        assert!(p.remove(1));
494        assert_eq!(p.stats().total_size_bytes, 200);
495        assert_eq!(p.stats().total_entries, 1);
496        assert!(!p.entries.contains_key(&1));
497    }
498
499    #[test]
500    fn test_remove_nonexistent_entry_returns_false() {
501        let mut p = policy(EvictionStrategy::Lru, 1000);
502        assert!(!p.remove(999));
503        assert_eq!(p.stats().total_size_bytes, 0);
504    }
505
506    // ── is_over_capacity ────────────────────────────────────────────────────
507
508    #[test]
509    fn test_is_over_capacity_true() {
510        let mut p = policy(EvictionStrategy::Lru, 50);
511        p.insert(1, 100, 1);
512        assert!(p.is_over_capacity());
513    }
514
515    #[test]
516    fn test_is_over_capacity_false() {
517        let mut p = policy(EvictionStrategy::Lru, 500);
518        p.insert(1, 100, 1);
519        assert!(!p.is_over_capacity());
520    }
521
522    #[test]
523    fn test_is_over_capacity_exactly_at_capacity() {
524        let mut p = policy(EvictionStrategy::Lru, 100);
525        p.insert(1, 100, 1);
526        assert!(!p.is_over_capacity()); // equal is NOT over
527    }
528
529    // ── set_strategy ────────────────────────────────────────────────────────
530
531    #[test]
532    fn test_set_strategy_changes_policy() {
533        let mut p = policy(EvictionStrategy::Lru, 200);
534        assert_eq!(p.strategy, EvictionStrategy::Lru);
535        p.set_strategy(EvictionStrategy::Fifo);
536        assert_eq!(p.strategy, EvictionStrategy::Fifo);
537    }
538
539    #[test]
540    fn test_set_strategy_affects_subsequent_eviction() {
541        let mut p = policy(EvictionStrategy::Lru, 200);
542        p.insert(1, 100, 1);
543        p.insert(2, 100, 2);
544        p.insert(3, 100, 3); // over capacity
545                             // Switch to FIFO before evicting.
546        p.set_strategy(EvictionStrategy::Fifo);
547        let evicted = p.evict_to_fit();
548        assert_eq!(evicted[0].reason, EvictionStrategy::Fifo);
549        assert_eq!(evicted[0].block_id, 1); // oldest
550    }
551
552    // ── reason field ────────────────────────────────────────────────────────
553
554    #[test]
555    fn test_eviction_candidate_reason_matches_strategy_lru() {
556        let mut p = policy(EvictionStrategy::Lru, 50);
557        p.insert(1, 100, 1);
558        let evicted = p.evict_to_fit();
559        assert_eq!(evicted[0].reason, EvictionStrategy::Lru);
560    }
561
562    #[test]
563    fn test_eviction_candidate_reason_matches_strategy_lfu() {
564        let mut p = policy(EvictionStrategy::Lfu, 50);
565        p.insert(1, 100, 1);
566        let evicted = p.evict_to_fit();
567        assert_eq!(evicted[0].reason, EvictionStrategy::Lfu);
568    }
569
570    #[test]
571    fn test_eviction_candidate_reason_matches_strategy_fifo() {
572        let mut p = policy(EvictionStrategy::Fifo, 50);
573        p.insert(1, 100, 1);
574        let evicted = p.evict_to_fit();
575        assert_eq!(evicted[0].reason, EvictionStrategy::Fifo);
576    }
577
578    #[test]
579    fn test_eviction_candidate_reason_matches_strategy_size_priority() {
580        let mut p = policy(EvictionStrategy::SizePriority, 50);
581        p.insert(1, 100, 1);
582        let evicted = p.evict_to_fit();
583        assert_eq!(evicted[0].reason, EvictionStrategy::SizePriority);
584    }
585
586    // ── stats accessor ───────────────────────────────────────────────────────
587
588    #[test]
589    fn test_stats_returns_current_state() {
590        let mut p = policy(EvictionStrategy::Lru, 1000);
591        p.insert(1, 300, 1);
592        p.insert(2, 200, 2);
593        p.access(1, 5);
594        p.access(99, 6); // miss
595        let s = p.stats();
596        assert_eq!(s.total_entries, 2);
597        assert_eq!(s.total_size_bytes, 500);
598        assert_eq!(s.hits, 1);
599        assert_eq!(s.misses, 1);
600        assert_eq!(s.total_evictions, 0);
601    }
602
603    // ── eviction_candidate size field ────────────────────────────────────────
604
605    #[test]
606    fn test_eviction_candidate_carries_correct_size() {
607        let mut p = policy(EvictionStrategy::SizePriority, 50);
608        p.insert(7, 777, 1);
609        let evicted = p.evict_to_fit();
610        assert_eq!(evicted[0].size_bytes, 777);
611        assert_eq!(evicted[0].block_id, 7);
612    }
613}