Skip to main content

loonfs_core/
recency.rs

1//! [`Recency`]: the least-recently-used order the runtime's stamped caches
2//! evict by.
3
4use std::collections::VecDeque;
5
6/// Shortest queue worth compacting, so a nearly empty cache does not compact
7/// on every touch.
8const MIN_COMPACTION_POSITIONS: usize = 16;
9
10/// A recency queue with lazy deletion.
11///
12/// A hit appends the key with a fresh stamp in constant time and leaves its
13/// old position behind as a ghost. The caller stores the returned stamp on
14/// its own entry and answers `is_live` from it, so this holds no second copy
15/// of the entry table and never duplicates whatever hangs off a key.
16/// Eviction skips ghosts as it pops them, and ghosts are dropped in bulk
17/// once they outnumber the live entries they shadow — amortized constant,
18/// like a vector reallocation. Nothing is scheduled.
19#[derive(Debug)]
20pub struct Recency<K> {
21    order: VecDeque<(K, u64)>,
22    counter: u64,
23}
24
25impl<K> Default for Recency<K> {
26    fn default() -> Self {
27        Self {
28            order: VecDeque::new(),
29            counter: 0,
30        }
31    }
32}
33
34impl<K: Clone> Recency<K> {
35    /// Records an access and returns the stamp the caller must store on its
36    /// entry before calling anything else here: until the entry carries it,
37    /// the position just appended reads as a ghost.
38    ///
39    /// Stamps start at 1, so an entry that has never been touched can carry
40    /// 0 and read as a ghost.
41    pub fn touch(&mut self, key: &K) -> u64 {
42        self.counter = self.counter.saturating_add(1);
43        self.order.push_back((key.clone(), self.counter));
44        self.counter
45    }
46
47    /// Removes and returns the least recently used live key.
48    ///
49    /// `None` means the queue holds no live position at all, so an eviction
50    /// loop must stop: popping again cannot make progress.
51    pub fn pop_oldest(&mut self, mut is_live: impl FnMut(&K, u64) -> bool) -> Option<K> {
52        while let Some((key, stamp)) = self.order.pop_front() {
53            if is_live(&key, stamp) {
54                return Some(key);
55            }
56        }
57        None
58    }
59
60    /// Drops ghost positions in place once they outnumber the `live_len`
61    /// entries they shadow. Cheap enough to call on every touch.
62    pub fn compact(&mut self, live_len: usize, mut is_live: impl FnMut(&K, u64) -> bool) {
63        let positions_before_compacting = live_len.saturating_mul(2).max(MIN_COMPACTION_POSITIONS);
64        if self.order.len() <= positions_before_compacting {
65            return;
66        }
67        self.order.retain(|(key, stamp)| is_live(key, *stamp));
68    }
69
70    /// Queue positions held, live and ghost alike.
71    pub fn positions(&self) -> usize {
72        self.order.len()
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::Recency;
79    use std::collections::HashMap;
80
81    /// A stand-in for a cache's entry table: keys mapped to the stamp the
82    /// queue last handed out for them.
83    #[derive(Default)]
84    struct Entries(HashMap<&'static str, u64>);
85
86    impl Entries {
87        fn is_live(&self, key: &&'static str, stamp: u64) -> bool {
88            self.0.get(key).is_some_and(|live| *live == stamp)
89        }
90    }
91
92    fn touch(recency: &mut Recency<&'static str>, entries: &mut Entries, key: &'static str) {
93        let stamp = recency.touch(&key);
94        entries.0.insert(key, stamp);
95        recency.compact(entries.0.len(), |key, stamp| entries.is_live(key, stamp));
96    }
97
98    #[test]
99    fn eviction_returns_keys_least_recently_touched_first() {
100        let mut recency = Recency::default();
101        let mut entries = Entries::default();
102        for key in ["a", "b", "c"] {
103            touch(&mut recency, &mut entries, key);
104        }
105        touch(&mut recency, &mut entries, "a");
106
107        let evicted = recency
108            .pop_oldest(|key, stamp| entries.is_live(key, stamp))
109            .expect("a live position");
110        assert_eq!(evicted, "b");
111    }
112
113    #[test]
114    fn eviction_skips_the_ghosts_a_re_touch_left_behind() {
115        let mut recency = Recency::default();
116        let mut entries = Entries::default();
117        touch(&mut recency, &mut entries, "a");
118        touch(&mut recency, &mut entries, "a");
119        touch(&mut recency, &mut entries, "b");
120
121        let evicted = recency
122            .pop_oldest(|key, stamp| entries.is_live(key, stamp))
123            .expect("a live position");
124        assert_eq!(evicted, "a", "the stale position for a must not evict b");
125    }
126
127    /// An eviction loop asks for a key it can drop; a queue holding only
128    /// ghosts has none, and saying so is what stops the loop spinning.
129    #[test]
130    fn eviction_reports_a_queue_of_ghosts_as_empty() {
131        let mut recency = Recency::default();
132        let mut entries = Entries::default();
133        touch(&mut recency, &mut entries, "a");
134        entries.0.clear();
135
136        assert!(recency
137            .pop_oldest(|key, stamp| entries.is_live(key, stamp))
138            .is_none());
139    }
140
141    #[test]
142    fn repeated_hits_on_one_key_keep_the_queue_bounded() {
143        let mut recency = Recency::default();
144        let mut entries = Entries::default();
145        for key in ["a", "b", "c"] {
146            touch(&mut recency, &mut entries, key);
147        }
148        for _ in 0..10_000 {
149            touch(&mut recency, &mut entries, "a");
150        }
151
152        assert_eq!(entries.0.len(), 3);
153        assert!(
154            recency.positions() <= (entries.0.len() * 2).max(16),
155            "compaction must bound the queue, positions = {}",
156            recency.positions()
157        );
158    }
159
160    #[test]
161    fn compaction_holds_off_until_ghosts_outnumber_live_entries() {
162        let mut recency = Recency::default();
163        let mut entries = Entries::default();
164        touch(&mut recency, &mut entries, "a");
165        assert_eq!(recency.positions(), 1);
166
167        // Under the floor nothing is dropped, so the ghosts stay visible.
168        for _ in 0..15 {
169            touch(&mut recency, &mut entries, "a");
170        }
171        assert_eq!(recency.positions(), 16);
172
173        touch(&mut recency, &mut entries, "a");
174        assert_eq!(recency.positions(), 1, "crossing the floor drops ghosts");
175    }
176}