Skip to main content

grafeo_core/cache/
second_chance.rs

1//! Second-chance (clock) LRU cache implementation.
2//!
3//! This cache uses the second-chance algorithm (also known as clock algorithm)
4//! for eviction. Each entry has an "accessed" flag that is set on every access
5//! and cleared during eviction scans. Entries that have been accessed get a
6//! "second chance" before eviction.
7//!
8//! The key advantage over traditional LRU is that access marking is lock-free
9//! (using atomic bools), reducing contention on the hot read path.
10
11use std::collections::VecDeque;
12use std::hash::Hash;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use hashbrown::HashMap;
16
17/// LRU cache with second-chance eviction algorithm.
18///
19/// Access marking is lock-free (atomic bool), reducing contention
20/// on the hot read path. Only eviction requires exclusive access.
21///
22/// # Example
23///
24/// ```
25/// use grafeo_core::cache::SecondChanceLru;
26///
27/// let mut cache = SecondChanceLru::new(3);
28/// cache.insert("a", 1);
29/// cache.insert("b", 2);
30/// cache.insert("c", 3);
31///
32/// // Access "a" to give it a second chance
33/// let _ = cache.get(&"a");
34///
35/// // Insert "d" - should evict "b" (not accessed), not "a"
36/// cache.insert("d", 4);
37///
38/// assert!(cache.get(&"a").is_some());
39/// assert!(cache.get(&"b").is_none()); // evicted
40/// ```
41pub struct SecondChanceLru<K, V> {
42    /// Map from key to (value, accessed_flag).
43    cache: HashMap<K, (V, AtomicBool)>,
44    /// Eviction order queue (clock hand).
45    queue: VecDeque<K>,
46    /// Maximum cache entries.
47    capacity: usize,
48}
49
50impl<K: Hash + Eq + Clone, V> SecondChanceLru<K, V> {
51    /// Creates a new cache with the given capacity.
52    ///
53    /// The cache will hold at most `capacity` entries. When full, the
54    /// second-chance algorithm determines which entry to evict.
55    #[must_use]
56    pub fn new(capacity: usize) -> Self {
57        Self {
58            cache: HashMap::with_capacity(capacity),
59            queue: VecDeque::with_capacity(capacity),
60            capacity,
61        }
62    }
63
64    /// Gets a value, marking it as accessed (lock-free flag set).
65    ///
66    /// Returns `None` if the key is not in the cache.
67    #[must_use]
68    pub fn get(&self, key: &K) -> Option<&V> {
69        self.cache.get(key).map(|(val, accessed)| {
70            // Mark as accessed - atomic, no lock needed
71            accessed.store(true, Ordering::Relaxed);
72            val
73        })
74    }
75
76    /// Gets a mutable reference to a value, marking it as accessed.
77    ///
78    /// Returns `None` if the key is not in the cache.
79    pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
80        self.cache.get_mut(key).map(|(val, accessed)| {
81            accessed.store(true, Ordering::Relaxed);
82            val
83        })
84    }
85
86    /// Checks if the cache contains the given key.
87    ///
88    /// This does NOT mark the entry as accessed.
89    #[must_use]
90    pub fn contains_key(&self, key: &K) -> bool {
91        self.cache.contains_key(key)
92    }
93
94    /// Inserts a value, evicting if at capacity.
95    ///
96    /// If the key already exists, the value is updated and marked as accessed.
97    /// If the cache is at capacity and this is a new key, one entry is evicted
98    /// using the second-chance algorithm.
99    pub fn insert(&mut self, key: K, value: V) {
100        // If key exists, update in place
101        if let Some((existing, accessed)) = self.cache.get_mut(&key) {
102            *existing = value;
103            accessed.store(true, Ordering::Relaxed);
104            return;
105        }
106
107        // Need to insert new entry - evict if at capacity
108        if self.cache.len() >= self.capacity {
109            self.evict_one();
110        }
111
112        // New entries start with accessed=false; only get() marks as accessed
113        self.cache
114            .insert(key.clone(), (value, AtomicBool::new(false)));
115        self.queue.push_back(key);
116    }
117
118    /// Removes a specific key from the cache.
119    ///
120    /// Returns the value if it was present.
121    pub fn remove(&mut self, key: &K) -> Option<V> {
122        self.cache.remove(key).map(|(v, _)| v)
123    }
124
125    /// Evicts one item using the second-chance algorithm.
126    ///
127    /// Scans entries in FIFO order. If an entry's accessed flag is set,
128    /// clears it and gives the entry a "second chance" by moving it to
129    /// the back of the queue. Otherwise, evicts the entry.
130    fn evict_one(&mut self) {
131        // Limit iterations to prevent infinite loop if all entries are accessed
132        let max_iterations = self.queue.len() * 2;
133
134        for _ in 0..max_iterations {
135            if let Some(key) = self.queue.pop_front() {
136                if let Some((_, accessed)) = self.cache.get(&key) {
137                    if accessed.swap(false, Ordering::Relaxed) {
138                        // Was accessed - give second chance
139                        self.queue.push_back(key);
140                    } else {
141                        // Not accessed - evict
142                        self.cache.remove(&key);
143                        return;
144                    }
145                }
146                // Key not in cache (shouldn't happen, but handle gracefully)
147            } else {
148                return; // Queue is empty
149            }
150        }
151
152        // All entries accessed - evict the first one anyway
153        if let Some(key) = self.queue.pop_front() {
154            self.cache.remove(&key);
155        }
156    }
157
158    /// Returns the number of entries in the cache.
159    #[must_use]
160    pub fn len(&self) -> usize {
161        self.cache.len()
162    }
163
164    /// Returns true if the cache is empty.
165    #[must_use]
166    pub fn is_empty(&self) -> bool {
167        self.cache.is_empty()
168    }
169
170    /// Clears all entries from the cache.
171    pub fn clear(&mut self) {
172        self.cache.clear();
173        self.queue.clear();
174    }
175
176    /// Returns the maximum capacity of the cache.
177    #[must_use]
178    pub fn capacity(&self) -> usize {
179        self.capacity
180    }
181
182    /// Returns an iterator over the keys in the cache.
183    ///
184    /// The order is unspecified and does not reflect access patterns.
185    pub fn keys(&self) -> impl Iterator<Item = &K> {
186        self.cache.keys()
187    }
188
189    /// Returns an iterator over the values in the cache.
190    ///
191    /// The order is unspecified. Values are not marked as accessed.
192    pub fn values(&self) -> impl Iterator<Item = &V> {
193        self.cache.values().map(|(v, _)| v)
194    }
195
196    /// Returns an iterator over key-value pairs in the cache.
197    ///
198    /// The order is unspecified. Values are not marked as accessed.
199    pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
200        self.cache.iter().map(|(k, (v, _))| (k, v))
201    }
202}
203
204impl<K: Hash + Eq + Clone, V> Default for SecondChanceLru<K, V> {
205    fn default() -> Self {
206        Self::new(16)
207    }
208}
209
210impl<K: Hash + Eq + Clone, V: Clone> Clone for SecondChanceLru<K, V> {
211    fn clone(&self) -> Self {
212        let mut cache = HashMap::with_capacity(self.capacity);
213        for (k, (v, accessed)) in &self.cache {
214            cache.insert(
215                k.clone(),
216                (v.clone(), AtomicBool::new(accessed.load(Ordering::Relaxed))),
217            );
218        }
219        Self {
220            cache,
221            queue: self.queue.clone(),
222            capacity: self.capacity,
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_basic_operations() {
233        let mut cache = SecondChanceLru::new(3);
234
235        cache.insert("a", 1);
236        cache.insert("b", 2);
237        cache.insert("c", 3);
238
239        assert_eq!(cache.len(), 3);
240        assert_eq!(*cache.get(&"a").unwrap(), 1);
241        assert_eq!(*cache.get(&"b").unwrap(), 2);
242        assert_eq!(*cache.get(&"c").unwrap(), 3);
243        assert!(cache.get(&"d").is_none());
244    }
245
246    #[test]
247    fn test_second_chance_eviction() {
248        let mut cache = SecondChanceLru::new(3);
249
250        cache.insert("a", 1);
251        cache.insert("b", 2);
252        cache.insert("c", 3);
253
254        // Access "a" to give it second chance
255        let _ = cache.get(&"a");
256
257        // Insert "d" - should evict "b" (oldest non-accessed), not "a"
258        cache.insert("d", 4);
259
260        assert!(
261            cache.get(&"a").is_some(),
262            "a should survive (second chance)"
263        );
264        assert!(cache.get(&"b").is_none(), "b should be evicted");
265        assert!(cache.get(&"c").is_some(), "c should survive");
266        assert!(cache.get(&"d").is_some(), "d was just inserted");
267    }
268
269    #[test]
270    fn test_capacity_respected() {
271        let mut cache = SecondChanceLru::new(2);
272
273        cache.insert("a", 1);
274        cache.insert("b", 2);
275        cache.insert("c", 3);
276
277        assert_eq!(cache.len(), 2);
278        assert_eq!(cache.capacity(), 2);
279    }
280
281    #[test]
282    fn test_update_existing() {
283        let mut cache = SecondChanceLru::new(2);
284
285        cache.insert("a", 1);
286        cache.insert("a", 2);
287
288        assert_eq!(cache.len(), 1);
289        assert_eq!(*cache.get(&"a").unwrap(), 2);
290    }
291
292    #[test]
293    fn test_remove() {
294        let mut cache = SecondChanceLru::new(3);
295
296        cache.insert("a", 1);
297        cache.insert("b", 2);
298
299        let removed = cache.remove(&"a");
300        assert_eq!(removed, Some(1));
301        assert!(cache.get(&"a").is_none());
302        assert_eq!(cache.len(), 1);
303    }
304
305    #[test]
306    fn test_clear() {
307        let mut cache = SecondChanceLru::new(3);
308
309        cache.insert("a", 1);
310        cache.insert("b", 2);
311        cache.clear();
312
313        assert!(cache.is_empty());
314        assert_eq!(cache.len(), 0);
315    }
316
317    #[test]
318    fn test_get_mut() {
319        let mut cache = SecondChanceLru::new(3);
320
321        cache.insert("a", 1);
322
323        if let Some(val) = cache.get_mut(&"a") {
324            *val = 10;
325        }
326
327        assert_eq!(*cache.get(&"a").unwrap(), 10);
328    }
329
330    #[test]
331    fn test_contains_key() {
332        let mut cache = SecondChanceLru::new(3);
333
334        cache.insert("a", 1);
335
336        assert!(cache.contains_key(&"a"));
337        assert!(!cache.contains_key(&"b"));
338    }
339
340    #[test]
341    fn test_all_accessed_eviction() {
342        let mut cache = SecondChanceLru::new(2);
343
344        cache.insert("a", 1);
345        cache.insert("b", 2);
346
347        // Access both entries
348        let _ = cache.get(&"a");
349        let _ = cache.get(&"b");
350
351        // Insert new entry - must evict one even though both are accessed
352        cache.insert("c", 3);
353
354        assert_eq!(cache.len(), 2);
355        // One of a or b should be evicted, c should be present
356        assert!(cache.get(&"c").is_some());
357    }
358
359    #[test]
360    fn test_iterators() {
361        let mut cache = SecondChanceLru::new(3);
362
363        cache.insert("a", 1);
364        cache.insert("b", 2);
365
366        let keys: Vec<_> = cache.keys().collect();
367        assert_eq!(keys.len(), 2);
368
369        let values: Vec<_> = cache.values().collect();
370        assert_eq!(values.len(), 2);
371
372        let pairs: Vec<_> = cache.iter().collect();
373        assert_eq!(pairs.len(), 2);
374    }
375
376    #[test]
377    fn test_clone() {
378        let mut cache = SecondChanceLru::new(3);
379
380        cache.insert("a", 1);
381        cache.insert("b", 2);
382        let _ = cache.get(&"a"); // Mark "a" as accessed
383
384        let cloned = cache.clone();
385
386        assert_eq!(cloned.len(), 2);
387        assert_eq!(*cloned.get(&"a").unwrap(), 1);
388        assert_eq!(*cloned.get(&"b").unwrap(), 2);
389    }
390}