Skip to main content

featherdb_storage/
eviction.rs

1//! Eviction policies for the buffer pool
2//!
3//! This module provides different eviction policies:
4//! - Clock: Simple clock-sweep algorithm (original)
5//! - LRU-2: Tracks last 2 access times, evicts by oldest second-to-last access
6//! - LIRS: Low Inter-reference Recency Set - better scan resistance
7
8use featherdb_core::PageId;
9use std::collections::{HashMap, VecDeque};
10use std::time::Instant;
11
12/// Eviction policy trait
13///
14/// Implementations of this trait decide which page to evict when the buffer pool is full.
15pub trait EvictionPolicy: Send + Sync {
16    /// Called when a page is accessed (cache hit)
17    fn on_access(&mut self, page_id: PageId);
18
19    /// Called when a new page is loaded into the buffer pool
20    fn on_load(&mut self, page_id: PageId);
21
22    /// Select a victim page for eviction
23    /// Returns None if no page can be evicted
24    fn select_victim(&mut self) -> Option<PageId>;
25
26    /// Remove a page from tracking (called when page is evicted or freed)
27    fn remove(&mut self, page_id: PageId);
28
29    /// Get the name of this eviction policy
30    fn name(&self) -> &'static str;
31}
32
33// =============================================================================
34// Clock Eviction Policy
35// =============================================================================
36
37/// Clock eviction policy (second-chance algorithm)
38///
39/// Simple and efficient. Each page has a reference bit that is set on access.
40/// The clock hand sweeps through pages, evicting the first page with reference bit = 0,
41/// and clearing reference bits as it goes.
42pub struct ClockEviction {
43    /// Ordered list of page IDs in clock order
44    pages: VecDeque<PageId>,
45    /// Reference bits for each page
46    reference_bits: HashMap<PageId, bool>,
47    /// Current clock hand position
48    hand: usize,
49}
50
51impl ClockEviction {
52    pub fn new() -> Self {
53        ClockEviction {
54            pages: VecDeque::new(),
55            reference_bits: HashMap::new(),
56            hand: 0,
57        }
58    }
59}
60
61impl Default for ClockEviction {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl EvictionPolicy for ClockEviction {
68    fn on_access(&mut self, page_id: PageId) {
69        self.reference_bits.insert(page_id, true);
70    }
71
72    fn on_load(&mut self, page_id: PageId) {
73        self.pages.push_back(page_id);
74        self.reference_bits.insert(page_id, true);
75    }
76
77    fn select_victim(&mut self) -> Option<PageId> {
78        let len = self.pages.len();
79        if len == 0 {
80            return None;
81        }
82
83        // Try up to 2 full sweeps
84        for _ in 0..len * 2 {
85            self.hand %= len;
86            let page_id = self.pages[self.hand];
87
88            if let Some(ref_bit) = self.reference_bits.get_mut(&page_id) {
89                if *ref_bit {
90                    // Give second chance
91                    *ref_bit = false;
92                    self.hand = (self.hand + 1) % len;
93                } else {
94                    // Found victim
95                    return Some(page_id);
96                }
97            } else {
98                // Page not tracked, can be evicted
99                return Some(page_id);
100            }
101        }
102
103        // All pages have reference bit set, just pick current one
104        Some(self.pages[self.hand])
105    }
106
107    fn remove(&mut self, page_id: PageId) {
108        if let Some(pos) = self.pages.iter().position(|&id| id == page_id) {
109            self.pages.remove(pos);
110            // Adjust hand if necessary
111            if self.hand >= self.pages.len() && !self.pages.is_empty() {
112                self.hand = 0;
113            }
114        }
115        self.reference_bits.remove(&page_id);
116    }
117
118    fn name(&self) -> &'static str {
119        "Clock"
120    }
121}
122
123// =============================================================================
124// LRU-2 Eviction Policy
125// =============================================================================
126
127/// Access history for a page in LRU-K algorithm
128#[derive(Debug, Clone)]
129struct Lru2Entry {
130    /// Most recent access time
131    last_access: Instant,
132    /// Second-to-last access time (K=2 distance)
133    prev_access: Option<Instant>,
134    /// Number of accesses
135    access_count: u32,
136    /// Timestamp when this entry was created (used for cold page ordering)
137    created_at: Instant,
138}
139
140impl Lru2Entry {
141    fn new() -> Self {
142        let now = Instant::now();
143        Lru2Entry {
144            last_access: now,
145            prev_access: None,
146            access_count: 1,
147            created_at: now,
148        }
149    }
150
151    fn record_access(&mut self) {
152        self.prev_access = Some(self.last_access);
153        self.last_access = Instant::now();
154        self.access_count = self.access_count.saturating_add(1);
155    }
156
157    /// Check if this page is "hot" (has been accessed more than once)
158    fn is_hot(&self) -> bool {
159        self.prev_access.is_some()
160    }
161
162    /// Get the K-distance for this page (for sorting hot pages)
163    /// For LRU-2, this is the time of the second-to-last access
164    fn k_distance(&self) -> Option<Instant> {
165        self.prev_access
166    }
167}
168
169/// LRU-2 eviction policy
170///
171/// Tracks the last 2 access times for each page. Pages are evicted based on
172/// the oldest second-to-last access time. This provides better resistance to
173/// scan pollution compared to plain LRU.
174///
175/// Key insight: A page that was accessed only once recently (like during a scan)
176/// will be evicted before a page that was accessed twice (indicating actual use).
177pub struct Lru2Eviction {
178    /// Access history for each page
179    entries: HashMap<PageId, Lru2Entry>,
180    /// Correlated reference period (CRP) - pages within this window are not evicted
181    /// This prevents recently loaded pages from being immediately evicted
182    correlated_reference_period_ms: u64,
183}
184
185impl Lru2Eviction {
186    pub fn new() -> Self {
187        Lru2Eviction {
188            entries: HashMap::new(),
189            // 10ms default CRP - pages accessed within 10ms are considered correlated
190            correlated_reference_period_ms: 10,
191        }
192    }
193
194    /// Create with custom correlated reference period
195    pub fn with_crp(crp_ms: u64) -> Self {
196        Lru2Eviction {
197            entries: HashMap::new(),
198            correlated_reference_period_ms: crp_ms,
199        }
200    }
201}
202
203impl Default for Lru2Eviction {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209impl EvictionPolicy for Lru2Eviction {
210    fn on_access(&mut self, page_id: PageId) {
211        let now = Instant::now();
212
213        if let Some(entry) = self.entries.get_mut(&page_id) {
214            // Only record access if outside correlated reference period
215            let elapsed_ms = now.duration_since(entry.last_access).as_millis() as u64;
216            if elapsed_ms > self.correlated_reference_period_ms {
217                entry.record_access();
218            }
219        } else {
220            // New page
221            self.entries.insert(page_id, Lru2Entry::new());
222        }
223    }
224
225    fn on_load(&mut self, page_id: PageId) {
226        self.entries.insert(page_id, Lru2Entry::new());
227    }
228
229    fn select_victim(&mut self) -> Option<PageId> {
230        if self.entries.is_empty() {
231            return None;
232        }
233
234        // LRU-2 algorithm provides scan resistance by treating cold pages (1 access)
235        // differently from hot pages (2+ accesses).
236        //
237        // Priority 1: Evict cold pages first (pages accessed only once)
238        //             Sort by creation time (oldest first)
239        // Priority 2: If no cold pages, evict hot page with oldest K-distance
240        //             (oldest second-to-last access time)
241
242        // Separate cold and hot pages
243        let mut cold_pages: Vec<_> = self
244            .entries
245            .iter()
246            .filter(|(_, entry)| !entry.is_hot())
247            .map(|(&id, entry)| (id, entry.created_at))
248            .collect();
249
250        let mut hot_pages: Vec<_> = self
251            .entries
252            .iter()
253            .filter(|(_, entry)| entry.is_hot())
254            .map(|(&id, entry)| (id, entry.k_distance().unwrap()))
255            .collect();
256
257        // Prefer evicting cold pages (scan pollution victims)
258        if !cold_pages.is_empty() {
259            // Sort by creation time (oldest first)
260            cold_pages.sort_by_key(|(_, created_at)| *created_at);
261            return Some(cold_pages[0].0);
262        }
263
264        // No cold pages, evict the hot page with oldest K-distance
265        if !hot_pages.is_empty() {
266            // Sort by K-distance (oldest prev_access first)
267            hot_pages.sort_by_key(|(_, k_distance)| *k_distance);
268            return Some(hot_pages[0].0);
269        }
270
271        None
272    }
273
274    fn remove(&mut self, page_id: PageId) {
275        self.entries.remove(&page_id);
276    }
277
278    fn name(&self) -> &'static str {
279        "LRU-2"
280    }
281}
282
283// =============================================================================
284// LIRS Eviction Policy
285// =============================================================================
286
287/// Block status in LIRS algorithm
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum LirsStatus {
290    /// Low Inter-reference Recency (hot)
291    Lir,
292    /// High Inter-reference Recency, resident in cache
293    HirResident,
294    /// High Inter-reference Recency, not resident (only metadata)
295    HirNonResident,
296}
297
298/// Entry in the LIRS stack
299#[derive(Debug, Clone)]
300struct LirsEntry {
301    status: LirsStatus,
302    /// Recency (position in S stack, lower = more recent)
303    recency: u64,
304    /// True if in the S stack (LIRS stack)
305    in_stack_s: bool,
306    /// True if in the Q list (resident HIR blocks)
307    in_list_q: bool,
308}
309
310/// LIRS (Low Inter-reference Recency Set) eviction policy
311///
312/// LIRS separates pages into two sets:
313/// - LIR set: Pages with low inter-reference recency (hot, frequently accessed)
314/// - HIR set: Pages with high inter-reference recency (cold, infrequently accessed)
315///
316/// Only HIR resident pages can be evicted. When a HIR page is accessed twice
317/// within the LIR set's recency window, it becomes LIR.
318///
319/// This provides excellent scan resistance and adapts to workload changes.
320pub struct LirsEviction {
321    /// All tracked pages
322    entries: HashMap<PageId, LirsEntry>,
323    /// Stack S - LIRS stack ordered by recency (most recent at end)
324    stack_s: VecDeque<PageId>,
325    /// List Q - Resident HIR pages in FIFO order
326    list_q: VecDeque<PageId>,
327    /// Maximum size of LIR set (typically ~99% of cache size)
328    lir_size_limit: usize,
329    /// Current size of LIR set
330    lir_count: usize,
331    /// Current size of HIR resident set
332    hir_count: usize,
333    /// Global recency counter
334    recency_counter: u64,
335}
336
337impl LirsEviction {
338    pub fn new(capacity: usize) -> Self {
339        // LIR set is ~99% of cache, HIR is ~1%
340        let lir_limit = (capacity as f64 * 0.99) as usize;
341
342        LirsEviction {
343            entries: HashMap::new(),
344            stack_s: VecDeque::new(),
345            list_q: VecDeque::new(),
346            lir_size_limit: lir_limit.max(1),
347            lir_count: 0,
348            hir_count: 0,
349            recency_counter: 0,
350        }
351    }
352
353    /// Set the LIR size ratio (0.0 to 1.0)
354    pub fn with_lir_ratio(mut self, ratio: f64) -> Self {
355        let total = self.lir_size_limit + 1; // Approximate capacity
356        self.lir_size_limit = ((total as f64) * ratio) as usize;
357        self
358    }
359
360    /// Move a page to the top of stack S (most recent)
361    fn move_to_stack_top(&mut self, page_id: PageId) {
362        // Remove from current position if present
363        self.stack_s.retain(|&id| id != page_id);
364        // Add to end (most recent)
365        self.stack_s.push_back(page_id);
366
367        self.recency_counter += 1;
368        if let Some(entry) = self.entries.get_mut(&page_id) {
369            entry.recency = self.recency_counter;
370            entry.in_stack_s = true;
371        }
372    }
373
374    /// Prune the bottom of stack S (remove non-LIR pages from bottom)
375    fn prune_stack(&mut self) {
376        while let Some(&page_id) = self.stack_s.front() {
377            // First, check the status
378            let status = self.entries.get(&page_id).map(|e| e.status);
379
380            match status {
381                Some(LirsStatus::Lir) => {
382                    // Stop at first LIR page
383                    break;
384                }
385                Some(status) => {
386                    // Remove non-LIR page from bottom of stack
387                    self.stack_s.pop_front();
388
389                    // Update in_stack_s flag
390                    if let Some(e) = self.entries.get_mut(&page_id) {
391                        e.in_stack_s = false;
392                    }
393
394                    // If it's non-resident HIR, we can remove the entry entirely
395                    if status == LirsStatus::HirNonResident {
396                        self.entries.remove(&page_id);
397                    }
398                }
399                None => {
400                    // Entry doesn't exist, remove from stack
401                    self.stack_s.pop_front();
402                }
403            }
404        }
405    }
406
407    /// Convert the bottom LIR block to HIR (when promoting a HIR to LIR)
408    fn demote_bottom_lir(&mut self) {
409        // Find the bottom LIR block in stack S
410        let mut bottom_lir = None;
411        for &page_id in &self.stack_s {
412            if let Some(entry) = self.entries.get(&page_id) {
413                if entry.status == LirsStatus::Lir {
414                    bottom_lir = Some(page_id);
415                    break;
416                }
417            }
418        }
419
420        if let Some(page_id) = bottom_lir {
421            if let Some(entry) = self.entries.get_mut(&page_id) {
422                entry.status = LirsStatus::HirResident;
423                entry.in_list_q = true;
424                self.lir_count = self.lir_count.saturating_sub(1);
425                self.hir_count += 1;
426                self.list_q.push_back(page_id);
427            }
428
429            // Prune stack after demotion
430            self.prune_stack();
431        }
432    }
433}
434
435impl Default for LirsEviction {
436    fn default() -> Self {
437        Self::new(1000) // Default capacity, should be set properly
438    }
439}
440
441impl EvictionPolicy for LirsEviction {
442    fn on_access(&mut self, page_id: PageId) {
443        if let Some(entry) = self.entries.get(&page_id).cloned() {
444            match entry.status {
445                LirsStatus::Lir => {
446                    // LIR hit: move to top of stack S
447                    self.move_to_stack_top(page_id);
448                    self.prune_stack();
449                }
450                LirsStatus::HirResident => {
451                    if entry.in_stack_s {
452                        // HIR hit and in stack S: promote to LIR
453                        if let Some(e) = self.entries.get_mut(&page_id) {
454                            e.status = LirsStatus::Lir;
455                            e.in_list_q = false;
456                            self.list_q.retain(|&id| id != page_id);
457                            self.lir_count += 1;
458                            self.hir_count = self.hir_count.saturating_sub(1);
459                        }
460
461                        // Move to stack top
462                        self.move_to_stack_top(page_id);
463
464                        // Demote bottom LIR if over limit
465                        if self.lir_count > self.lir_size_limit {
466                            self.demote_bottom_lir();
467                        }
468                    } else {
469                        // HIR hit but not in stack S: move to end of Q and add to S
470                        self.list_q.retain(|&id| id != page_id);
471                        self.list_q.push_back(page_id);
472                        self.move_to_stack_top(page_id);
473                    }
474                }
475                LirsStatus::HirNonResident => {
476                    // This shouldn't happen for on_access (page should be loaded first)
477                    // But handle it by promoting to HIR resident
478                    if let Some(e) = self.entries.get_mut(&page_id) {
479                        e.status = LirsStatus::HirResident;
480                        e.in_list_q = true;
481                        self.list_q.push_back(page_id);
482                        self.hir_count += 1;
483                    }
484                    self.move_to_stack_top(page_id);
485                }
486            }
487        }
488    }
489
490    fn on_load(&mut self, page_id: PageId) {
491        // Check if this page was previously tracked (as non-resident HIR)
492        let was_in_stack = self
493            .entries
494            .get(&page_id)
495            .map(|e| e.in_stack_s)
496            .unwrap_or(false);
497
498        if self.lir_count < self.lir_size_limit {
499            // Space in LIR set, add as LIR
500            self.entries.insert(
501                page_id,
502                LirsEntry {
503                    status: LirsStatus::Lir,
504                    recency: self.recency_counter,
505                    in_stack_s: true,
506                    in_list_q: false,
507                },
508            );
509            self.lir_count += 1;
510            self.move_to_stack_top(page_id);
511        } else if was_in_stack {
512            // Was in stack as non-resident, promote to LIR
513            if let Some(entry) = self.entries.get_mut(&page_id) {
514                entry.status = LirsStatus::Lir;
515                entry.in_list_q = false;
516            }
517            self.lir_count += 1;
518            self.move_to_stack_top(page_id);
519
520            // Demote bottom LIR to maintain limit
521            if self.lir_count > self.lir_size_limit {
522                self.demote_bottom_lir();
523            }
524        } else {
525            // New page, add as HIR resident
526            self.entries.insert(
527                page_id,
528                LirsEntry {
529                    status: LirsStatus::HirResident,
530                    recency: self.recency_counter,
531                    in_stack_s: true,
532                    in_list_q: true,
533                },
534            );
535            self.hir_count += 1;
536            self.list_q.push_back(page_id);
537            self.move_to_stack_top(page_id);
538        }
539    }
540
541    fn select_victim(&mut self) -> Option<PageId> {
542        // Evict from front of Q (oldest HIR resident)
543        while let Some(page_id) = self.list_q.pop_front() {
544            if let Some(entry) = self.entries.get_mut(&page_id) {
545                if entry.status == LirsStatus::HirResident {
546                    entry.in_list_q = false;
547
548                    // If in stack S, keep as non-resident HIR for future detection
549                    if entry.in_stack_s {
550                        entry.status = LirsStatus::HirNonResident;
551                        self.hir_count = self.hir_count.saturating_sub(1);
552                    }
553
554                    return Some(page_id);
555                }
556            }
557        }
558
559        // Fallback: if Q is empty, evict bottom LIR (shouldn't normally happen)
560        if let Some(&page_id) = self.stack_s.front() {
561            return Some(page_id);
562        }
563
564        None
565    }
566
567    fn remove(&mut self, page_id: PageId) {
568        if let Some(entry) = self.entries.remove(&page_id) {
569            if entry.status == LirsStatus::Lir {
570                self.lir_count = self.lir_count.saturating_sub(1);
571            } else if entry.status == LirsStatus::HirResident {
572                self.hir_count = self.hir_count.saturating_sub(1);
573            }
574        }
575        self.stack_s.retain(|&id| id != page_id);
576        self.list_q.retain(|&id| id != page_id);
577    }
578
579    fn name(&self) -> &'static str {
580        "LIRS"
581    }
582}
583
584// =============================================================================
585// Eviction Policy Type (re-exported from core)
586// =============================================================================
587
588// Re-export the EvictionPolicyType from core
589pub use featherdb_core::EvictionPolicyType;
590
591/// Extension trait for creating eviction policies
592pub trait EvictionPolicyFactory {
593    /// Create an eviction policy of this type
594    fn create(&self, capacity: usize) -> Box<dyn EvictionPolicy>;
595}
596
597impl EvictionPolicyFactory for EvictionPolicyType {
598    /// Create an eviction policy of this type
599    fn create(&self, capacity: usize) -> Box<dyn EvictionPolicy> {
600        match self {
601            EvictionPolicyType::Clock => Box::new(ClockEviction::new()),
602            EvictionPolicyType::Lru2 => Box::new(Lru2Eviction::new()),
603            EvictionPolicyType::Lirs => Box::new(LirsEviction::new(capacity)),
604        }
605    }
606}
607
608// =============================================================================
609// Tests
610// =============================================================================
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn test_clock_basic() {
618        let mut clock = ClockEviction::new();
619
620        // Load some pages
621        clock.on_load(PageId(1));
622        clock.on_load(PageId(2));
623        clock.on_load(PageId(3));
624
625        // All have reference bit set, first sweep clears them
626        // Second sweep should find victims
627        let victim = clock.select_victim();
628        assert!(victim.is_some());
629
630        // Access page 2 to protect it
631        clock.on_access(PageId(2));
632
633        // After victim selection, page 2 should be protected
634        let victim = clock.select_victim();
635        assert!(victim.is_some());
636        assert_ne!(victim, Some(PageId(2)));
637    }
638
639    #[test]
640    fn test_clock_remove() {
641        let mut clock = ClockEviction::new();
642
643        clock.on_load(PageId(1));
644        clock.on_load(PageId(2));
645
646        clock.remove(PageId(1));
647
648        // Should only find page 2
649        let victim = clock.select_victim();
650        assert_eq!(victim, Some(PageId(2)));
651    }
652
653    #[test]
654    fn test_lru2_scan_resistance() {
655        let mut lru2 = Lru2Eviction::new();
656
657        // Load a "hot" page and access it multiple times
658        lru2.on_load(PageId(1));
659        std::thread::sleep(std::time::Duration::from_millis(20));
660        lru2.on_access(PageId(1)); // Now has 2 accesses
661
662        // Simulate scan: load many cold pages
663        for i in 2..=10 {
664            lru2.on_load(PageId(i as u64));
665        }
666
667        // Cold pages (single access) should be evicted before hot page
668        let victim = lru2.select_victim();
669        assert!(victim.is_some());
670        assert_ne!(
671            victim,
672            Some(PageId(1)),
673            "Hot page should not be evicted first"
674        );
675    }
676
677    #[test]
678    fn test_lru2_cold_page_eviction() {
679        let mut lru2 = Lru2Eviction::new();
680
681        // Load pages
682        lru2.on_load(PageId(1));
683        std::thread::sleep(std::time::Duration::from_millis(150)); // Past the 100ms threshold
684        lru2.on_load(PageId(2));
685
686        // Page 1 should be evicted first (older single access)
687        let victim = lru2.select_victim();
688        assert_eq!(victim, Some(PageId(1)));
689    }
690
691    #[test]
692    fn test_lirs_basic() {
693        let mut lirs = LirsEviction::new(10);
694
695        // Load pages (they become LIR until limit reached)
696        for i in 1..=5 {
697            lirs.on_load(PageId(i as u64));
698        }
699
700        assert_eq!(lirs.lir_count, 5);
701
702        // Access some pages
703        lirs.on_access(PageId(1));
704        lirs.on_access(PageId(2));
705    }
706
707    #[test]
708    fn test_lirs_eviction() {
709        let mut lirs = LirsEviction::new(5);
710
711        // Fill up LIR set
712        for i in 1..=10 {
713            lirs.on_load(PageId(i as u64));
714        }
715
716        // Should have some HIR pages
717        assert!(lirs.hir_count > 0);
718
719        // Eviction should come from HIR set
720        let victim = lirs.select_victim();
721        assert!(victim.is_some());
722    }
723
724    #[test]
725    fn test_policy_type_create() {
726        let clock = EvictionPolicyType::Clock.create(100);
727        assert_eq!(clock.name(), "Clock");
728
729        let lru2 = EvictionPolicyType::Lru2.create(100);
730        assert_eq!(lru2.name(), "LRU-2");
731
732        let lirs = EvictionPolicyType::Lirs.create(100);
733        assert_eq!(lirs.name(), "LIRS");
734    }
735}