cachekit/policy/nru.rs
1//! NRU (Not Recently Used) cache replacement policy.
2//!
3//! Implements the Not Recently Used algorithm, which uses a single reference bit
4//! per entry to approximate LRU with minimal overhead. The reference bit provides
5//! a coarse distinction between "recently used" and "not recently used" items.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! ┌─────────────────────────────────────────────────────────────────────────────┐
11//! │ NruCache<K, V> Layout │
12//! │ │
13//! │ ┌─────────────────────────────────────────────────────────────────────┐ │
14//! │ │ map: HashMap<K, Entry<V>> keys: Vec<K> │ │
15//! │ │ key → (index, value, ref) dense array of keys │ │
16//! │ │ │ │
17//! │ │ ┌──────────┬─────────────────┐ ┌─────┬─────┬─────┬─────┐ │ │
18//! │ │ │ Key │ Entry │ │ 0 │ 1 │ 2 │ 3 │ │ │
19//! │ │ ├──────────┼─────────────────┤ ├─────┼─────┼─────┼─────┤ │ │
20//! │ │ │ "page1" │(0, v1, ref=1) │──┐ │ p1 │ p2 │ p3 │ p4 │ │ │
21//! │ │ │ "page2" │(1, v2, ref=0) │──┼──►└─────┴─────┴─────┴─────┘ │ │
22//! │ │ │ "page3" │(2, v3, ref=1) │──┘ │ │
23//! │ │ │ "page4" │(3, v4, ref=0) │ ← Eviction candidate (ref=0) │ │
24//! │ │ └──────────┴─────────────────┘ │ │
25//! │ └─────────────────────────────────────────────────────────────────────┘ │
26//! │ │
27//! │ ┌─────────────────────────────────────────────────────────────────────┐ │
28//! │ │ NRU Eviction (O(n) worst case) │ │
29//! │ │ │ │
30//! │ │ 1. Scan keys vec for first entry with ref=0 │ │
31//! │ │ 2. If found: evict that entry │ │
32//! │ │ 3. If not found: clear all ref bits, then evict first entry │ │
33//! │ │ │ │
34//! │ │ epoch_counter: Tracks when to do bulk ref bit clearing │ │
35//! │ │ epoch_threshold: Number of accesses before clearing all bits │ │
36//! │ └─────────────────────────────────────────────────────────────────────┘ │
37//! │ │
38//! └─────────────────────────────────────────────────────────────────────────────┘
39//!
40//! Access Flow
41//! ──────────────────────
42//!
43//! get("key"):
44//! 1. Lookup entry in map
45//! 2. Set entry.referenced = true
46//! 3. Return &value
47//!
48//! Insert Flow (new key)
49//! ──────────────────────
50//!
51//! insert("new_key", value):
52//! 1. Check map - not found
53//! 2. Evict if at capacity
54//! 3. Get next index: idx = keys.len()
55//! 4. Push key to keys vec
56//! 5. Insert Entry{idx, value, referenced: false} into map
57//!
58//! Eviction Flow
59//! ─────────────
60//!
61//! evict_nru():
62//! 1. Scan keys for first entry with referenced=false
63//! 2. If found: remove that entry (swap-remove)
64//! 3. If not found (all referenced):
65//! a. Clear all reference bits
66//! b. Evict first entry (now all have ref=false)
67//! ```
68//!
69//! ## Algorithm
70//!
71//! ```text
72//! GET(key):
73//! 1. Look up entry in hash map
74//! 2. Set referenced = true
75//! 3. Return value
76//! Cost: O(1) - just a hash lookup and bit set
77//!
78//! INSERT(key, value):
79//! 1. If key exists: update value, set referenced = true
80//! 2. If at capacity: run eviction
81//! 3. Insert entry with referenced = true
82//!
83//! EVICT():
84//! // Phase 1: Try to find unreferenced entry
85//! for each entry in keys:
86//! if entry.referenced == false:
87//! remove entry
88//! return
89//!
90//! // Phase 2: All referenced - clear and pick first
91//! for each entry:
92//! entry.referenced = false
93//! remove first entry
94//! ```
95//!
96//! ## Performance Characteristics
97//!
98//! | Operation | Time | Notes |
99//! |-----------|---------|-----------------------------------|
100//! | `get` | O(1) | Hash lookup + bit set |
101//! | `insert` | O(n)* | *Worst case if all entries ref'd |
102//! | `contains`| O(1) | Hash lookup only |
103//! | `remove` | O(1) | Hash lookup + swap-remove |
104//!
105//! ## Trade-offs
106//!
107//! | Aspect | NRU | Clock | LRU |
108//! |---------------|--------------------------|-------------------------|-------------------------|
109//! | Access cost | O(1) bit set | O(1) bit set | O(1) list move |
110//! | Eviction cost | O(n) worst case | O(n) worst case | O(1) |
111//! | Granularity | Binary (used/not used) | Binary with hand sweep | Full order |
112//! | Overhead | 1 bit per entry | 1 bit per entry + hand | 16 bytes per entry |
113//!
114//! ## When to Use
115//!
116//! **Use NRU when:**
117//! - You need simple, coarse eviction tracking
118//! - Memory for full LRU list is too expensive
119//! - You can tolerate O(n) eviction in worst case
120//! - Access patterns have temporal locality
121//!
122//! **Avoid NRU when:**
123//! - You need strict LRU ordering (use LRU)
124//! - You need O(1) eviction guarantees (use Clock with hand)
125//! - You need scan resistance (use S3-FIFO, LRU-K)
126//!
127//! ## Example Usage
128//!
129//! ```
130//! use cachekit::policy::nru::NruCache;
131//! use cachekit::traits::{CoreCache, ReadOnlyCache};
132//!
133//! let mut cache = NruCache::new(100);
134//!
135//! // Insert items
136//! cache.insert("page1", "content1");
137//! cache.insert("page2", "content2");
138//!
139//! // Access sets reference bit
140//! assert_eq!(cache.get(&"page1"), Some(&"content1"));
141//!
142//! // Unreferenced items are evicted first
143//! // Referenced items protected until next epoch
144//! ```
145//!
146//! ## Implementation
147//!
148//! This implementation uses:
149//! - `HashMap<K, Entry<V>>` for O(1) lookup (stores index, value, referenced bit)
150//! - `Vec<K>` for dense key storage and eviction scanning
151//! - Swap-remove technique for O(1) removal (updates index in moved entry)
152//! - Lazy clearing of reference bits (only when needed during eviction)
153//!
154//! ## Thread Safety
155//!
156//! - [`NruCache`]: Not thread-safe, designed for single-threaded use
157//! - For concurrent access, wrap in external synchronization (e.g., `Mutex`)
158//!
159//! ## References
160//!
161//! - Wikipedia: Cache replacement policies
162
163use crate::prelude::ReadOnlyCache;
164use crate::traits::{CoreCache, MutableCache};
165use rustc_hash::FxHashMap;
166use std::fmt::{Debug, Formatter};
167use std::hash::Hash;
168
169#[cfg(feature = "metrics")]
170use crate::metrics::metrics_impl::NruMetrics;
171#[cfg(feature = "metrics")]
172use crate::metrics::snapshot::NruMetricsSnapshot;
173#[cfg(feature = "metrics")]
174use crate::metrics::traits::MetricsSnapshotProvider;
175
176/// Entry in the NRU cache containing value, index, and reference bit.
177#[derive(Debug, Clone)]
178struct Entry<V> {
179 /// Index in the keys vector
180 index: usize,
181 /// Cached value
182 value: V,
183 /// Reference bit - set on access, cleared during epoch reset
184 referenced: bool,
185}
186
187/// NRU (Not Recently Used) cache implementation.
188///
189/// Uses a single reference bit per entry to distinguish between recently used
190/// and not recently used items. Provides O(1) access but O(n) worst-case eviction.
191///
192/// # Type Parameters
193///
194/// - `K`: Key type, must be `Clone + Eq + Hash`
195/// - `V`: Value type
196///
197/// # Example
198///
199/// ```
200/// use cachekit::policy::nru::NruCache;
201/// use cachekit::traits::{CoreCache, ReadOnlyCache};
202///
203/// let mut cache = NruCache::new(100);
204///
205/// cache.insert(1, "value1");
206/// cache.insert(2, "value2");
207///
208/// // Access sets reference bit
209/// assert_eq!(cache.get(&1), Some(&"value1"));
210///
211/// // When cache is full, unreferenced items are evicted first
212/// for i in 3..=110 {
213/// cache.insert(i, "value");
214/// }
215///
216/// assert_eq!(cache.len(), 100);
217/// ```
218///
219/// # Eviction Behavior
220///
221/// When capacity is exceeded:
222/// 1. Scans for first entry with `referenced = false`
223/// 2. If all entries are referenced, clears all reference bits then evicts first entry
224///
225/// # Implementation
226///
227/// Uses HashMap + Vec for O(1) access with swap-remove for eviction.
228pub struct NruCache<K, V>
229where
230 K: Clone + Eq + Hash,
231{
232 /// HashMap for O(1) key lookup
233 map: FxHashMap<K, Entry<V>>,
234 /// Dense array of keys for eviction scanning
235 keys: Vec<K>,
236 /// Maximum capacity
237 capacity: usize,
238 #[cfg(feature = "metrics")]
239 metrics: NruMetrics,
240}
241
242impl<K, V> NruCache<K, V>
243where
244 K: Clone + Eq + Hash,
245{
246 /// Creates a new NRU cache with the specified capacity.
247 ///
248 /// # Example
249 ///
250 /// ```
251 /// use cachekit::policy::nru::NruCache;
252 /// use cachekit::traits::{CoreCache, ReadOnlyCache};
253 ///
254 /// let cache: NruCache<String, i32> = NruCache::new(100);
255 /// assert_eq!(cache.capacity(), 100);
256 /// assert!(cache.is_empty());
257 /// ```
258 #[inline]
259 pub fn new(capacity: usize) -> Self {
260 Self {
261 map: FxHashMap::default(),
262 keys: Vec::with_capacity(capacity),
263 capacity,
264 #[cfg(feature = "metrics")]
265 metrics: NruMetrics::default(),
266 }
267 }
268
269 /// Returns `true` if the cache is empty.
270 #[inline]
271 pub fn is_empty(&self) -> bool {
272 self.map.is_empty()
273 }
274
275 /// Evicts an entry using NRU policy.
276 ///
277 /// First tries to find an unreferenced entry. If all entries are referenced,
278 /// clears all reference bits and evicts the first entry.
279 ///
280 /// Returns the evicted (key, value) pair.
281 fn evict_one(&mut self) -> Option<(K, V)> {
282 if self.keys.is_empty() {
283 return None;
284 }
285
286 // Phase 1: Try to find an unreferenced entry
287 for (idx, key) in self.keys.iter().enumerate() {
288 #[cfg(feature = "metrics")]
289 {
290 self.metrics.sweep_steps += 1;
291 }
292 if let Some(entry) = self.map.get(key) {
293 if !entry.referenced {
294 let victim_key = self.keys.swap_remove(idx);
295
296 if idx < self.keys.len() {
297 let swapped_key = &self.keys[idx];
298 if let Some(swapped_entry) = self.map.get_mut(swapped_key) {
299 swapped_entry.index = idx;
300 }
301 }
302
303 let victim_entry = self.map.remove(&victim_key)?;
304 return Some((victim_key, victim_entry.value));
305 }
306 }
307 }
308
309 // Phase 2: All entries are referenced - clear all bits and evict first
310 for key in &self.keys {
311 if let Some(entry) = self.map.get_mut(key) {
312 if entry.referenced {
313 entry.referenced = false;
314 #[cfg(feature = "metrics")]
315 {
316 self.metrics.ref_bit_resets += 1;
317 }
318 }
319 }
320 }
321
322 if !self.keys.is_empty() {
323 let victim_key = self.keys.swap_remove(0);
324
325 if !self.keys.is_empty() {
326 let swapped_key = &self.keys[0];
327 if let Some(swapped_entry) = self.map.get_mut(swapped_key) {
328 swapped_entry.index = 0;
329 }
330 }
331
332 let victim_entry = self.map.remove(&victim_key)?;
333 return Some((victim_key, victim_entry.value));
334 }
335
336 None
337 }
338}
339
340impl<K, V> ReadOnlyCache<K, V> for NruCache<K, V>
341where
342 K: Clone + Eq + Hash,
343{
344 /// Returns `true` if the cache contains the key.
345 ///
346 /// Does not affect the reference bit.
347 #[inline]
348 fn contains(&self, key: &K) -> bool {
349 self.map.contains_key(key)
350 }
351
352 /// Returns the number of entries in the cache.
353 #[inline]
354 fn len(&self) -> usize {
355 self.map.len()
356 }
357
358 /// Returns the maximum capacity of the cache.
359 #[inline]
360 fn capacity(&self) -> usize {
361 self.capacity
362 }
363}
364
365impl<K, V> CoreCache<K, V> for NruCache<K, V>
366where
367 K: Clone + Eq + Hash,
368{
369 /// Inserts a key-value pair into the cache.
370 ///
371 /// If the key exists, updates the value and sets the reference bit.
372 /// If at capacity, evicts using the NRU algorithm.
373 ///
374 /// # Example
375 ///
376 /// ```
377 /// use cachekit::policy::nru::NruCache;
378 /// use cachekit::traits::{CoreCache, ReadOnlyCache};
379 ///
380 /// let mut cache = NruCache::new(2);
381 /// cache.insert("a", 1);
382 /// cache.insert("b", 2);
383 ///
384 /// // Update existing
385 /// let old = cache.insert("a", 10);
386 /// assert_eq!(old, Some(1));
387 /// ```
388 #[inline]
389 fn insert(&mut self, key: K, value: V) -> Option<V> {
390 #[cfg(feature = "metrics")]
391 {
392 self.metrics.insert_calls += 1;
393 }
394
395 if self.capacity == 0 {
396 return None;
397 }
398 if let Some(entry) = self.map.get_mut(&key) {
399 #[cfg(feature = "metrics")]
400 {
401 self.metrics.insert_updates += 1;
402 }
403 let old_value = std::mem::replace(&mut entry.value, value);
404 entry.referenced = true;
405 return Some(old_value);
406 }
407
408 #[cfg(feature = "metrics")]
409 {
410 self.metrics.insert_new += 1;
411 }
412
413 if self.map.len() >= self.capacity {
414 #[cfg(feature = "metrics")]
415 {
416 self.metrics.evict_calls += 1;
417 }
418 if self.evict_one().is_some() {
419 #[cfg(feature = "metrics")]
420 {
421 self.metrics.evicted_entries += 1;
422 }
423 }
424 }
425
426 let index = self.keys.len();
427 self.keys.push(key.clone());
428 self.map.insert(
429 key,
430 Entry {
431 index,
432 value,
433 referenced: false,
434 },
435 );
436
437 None
438 }
439
440 /// Gets a reference to the value for a key.
441 ///
442 /// Sets the reference bit on access.
443 ///
444 /// # Example
445 ///
446 /// ```
447 /// use cachekit::policy::nru::NruCache;
448 /// use cachekit::traits::{CoreCache, ReadOnlyCache};
449 ///
450 /// let mut cache = NruCache::new(10);
451 /// cache.insert("key", 42);
452 ///
453 /// // Access sets reference bit - this entry gets protection
454 /// assert_eq!(cache.get(&"key"), Some(&42));
455 /// ```
456 #[inline]
457 fn get(&mut self, key: &K) -> Option<&V> {
458 if let Some(entry) = self.map.get_mut(key) {
459 entry.referenced = true;
460 #[cfg(feature = "metrics")]
461 {
462 self.metrics.get_calls += 1;
463 self.metrics.get_hits += 1;
464 }
465 Some(&entry.value)
466 } else {
467 #[cfg(feature = "metrics")]
468 {
469 self.metrics.get_calls += 1;
470 self.metrics.get_misses += 1;
471 }
472 None
473 }
474 }
475
476 /// Clears all entries from the cache.
477 fn clear(&mut self) {
478 self.map.clear();
479 self.keys.clear();
480 #[cfg(feature = "metrics")]
481 {
482 use crate::metrics::traits::CoreMetricsRecorder;
483 self.metrics.record_clear();
484 }
485 }
486}
487
488impl<K, V> MutableCache<K, V> for NruCache<K, V>
489where
490 K: Clone + Eq + Hash,
491{
492 /// Removes a key from the cache.
493 ///
494 /// # Example
495 ///
496 /// ```
497 /// use cachekit::policy::nru::NruCache;
498 /// use cachekit::traits::{CoreCache, MutableCache, ReadOnlyCache};
499 ///
500 /// let mut cache = NruCache::new(10);
501 /// cache.insert("key", 42);
502 ///
503 /// let removed = cache.remove(&"key");
504 /// assert_eq!(removed, Some(42));
505 /// assert!(!cache.contains(&"key"));
506 /// ```
507 #[inline]
508 fn remove(&mut self, key: &K) -> Option<V> {
509 let entry = self.map.remove(key)?;
510 let idx = entry.index;
511
512 // Swap-remove from keys vec
513 self.keys.swap_remove(idx);
514
515 // Update index of swapped key if we didn't remove the last element
516 if idx < self.keys.len() {
517 let swapped_key = &self.keys[idx];
518 if let Some(swapped_entry) = self.map.get_mut(swapped_key) {
519 swapped_entry.index = idx;
520 }
521 }
522
523 Some(entry.value)
524 }
525}
526
527#[cfg(feature = "metrics")]
528impl<K, V> NruCache<K, V>
529where
530 K: Clone + Eq + Hash,
531{
532 /// Returns a snapshot of cache metrics.
533 pub fn metrics_snapshot(&self) -> NruMetricsSnapshot {
534 NruMetricsSnapshot {
535 get_calls: self.metrics.get_calls,
536 get_hits: self.metrics.get_hits,
537 get_misses: self.metrics.get_misses,
538 insert_calls: self.metrics.insert_calls,
539 insert_updates: self.metrics.insert_updates,
540 insert_new: self.metrics.insert_new,
541 evict_calls: self.metrics.evict_calls,
542 evicted_entries: self.metrics.evicted_entries,
543 sweep_steps: self.metrics.sweep_steps,
544 ref_bit_resets: self.metrics.ref_bit_resets,
545 cache_len: self.map.len(),
546 capacity: self.capacity,
547 }
548 }
549}
550
551#[cfg(feature = "metrics")]
552impl<K, V> MetricsSnapshotProvider<NruMetricsSnapshot> for NruCache<K, V>
553where
554 K: Clone + Eq + Hash,
555{
556 fn snapshot(&self) -> NruMetricsSnapshot {
557 self.metrics_snapshot()
558 }
559}
560
561impl<K, V> Debug for NruCache<K, V>
562where
563 K: Clone + Eq + Hash + std::fmt::Debug,
564 V: std::fmt::Debug,
565{
566 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
567 f.debug_struct("NruCache")
568 .field("capacity", &self.capacity)
569 .field("len", &self.map.len())
570 .field("keys", &self.keys)
571 .finish()
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::traits::{CoreCache, MutableCache};
579
580 #[test]
581 fn test_new() {
582 let cache: NruCache<i32, i32> = NruCache::new(10);
583 assert_eq!(cache.capacity(), 10);
584 assert_eq!(cache.len(), 0);
585 assert!(cache.is_empty());
586 }
587
588 #[test]
589 fn test_insert_and_get() {
590 let mut cache = NruCache::new(3);
591
592 cache.insert(1, 100);
593 cache.insert(2, 200);
594 cache.insert(3, 300);
595
596 assert_eq!(cache.get(&1), Some(&100));
597 assert_eq!(cache.get(&2), Some(&200));
598 assert_eq!(cache.get(&3), Some(&300));
599 assert_eq!(cache.len(), 3);
600 }
601
602 #[test]
603 fn test_update_existing() {
604 let mut cache = NruCache::new(3);
605
606 cache.insert(1, 100);
607 let old = cache.insert(1, 999);
608
609 assert_eq!(old, Some(100));
610 assert_eq!(cache.get(&1), Some(&999));
611 assert_eq!(cache.len(), 1);
612 }
613
614 #[test]
615 fn test_eviction_unreferenced() {
616 let mut cache = NruCache::new(3);
617
618 // Insert 3 items
619 cache.insert(1, 100);
620 cache.insert(2, 200);
621 cache.insert(3, 300);
622
623 // Access only 1 and 3
624 let _ = cache.get(&1);
625 let _ = cache.get(&3);
626
627 // Insert 4th item - should evict 2 (unreferenced)
628 cache.insert(4, 400);
629
630 assert_eq!(cache.len(), 3);
631 assert!(cache.contains(&1));
632 assert!(!cache.contains(&2)); // 2 was evicted
633 assert!(cache.contains(&3));
634 assert!(cache.contains(&4));
635 }
636
637 #[test]
638 fn test_eviction_all_referenced() {
639 let mut cache = NruCache::new(3);
640
641 // Insert and access all 3 items
642 cache.insert(1, 100);
643 cache.insert(2, 200);
644 cache.insert(3, 300);
645 let _ = cache.get(&1);
646 let _ = cache.get(&2);
647 let _ = cache.get(&3);
648
649 // Insert 4th item - all are referenced, so clears bits and evicts one
650 cache.insert(4, 400);
651
652 assert_eq!(cache.len(), 3);
653 assert!(cache.contains(&4));
654 }
655
656 #[test]
657 fn test_remove() {
658 let mut cache = NruCache::new(3);
659
660 cache.insert(1, 100);
661 cache.insert(2, 200);
662 cache.insert(3, 300);
663
664 let removed = cache.remove(&2);
665 assert_eq!(removed, Some(200));
666 assert_eq!(cache.len(), 2);
667 assert!(!cache.contains(&2));
668 assert!(cache.contains(&1));
669 assert!(cache.contains(&3));
670 }
671
672 #[test]
673 fn test_clear() {
674 let mut cache = NruCache::new(3);
675
676 cache.insert(1, 100);
677 cache.insert(2, 200);
678 cache.insert(3, 300);
679
680 cache.clear();
681
682 assert_eq!(cache.len(), 0);
683 assert!(cache.is_empty());
684 assert!(!cache.contains(&1));
685 }
686
687 #[test]
688 fn test_contains_does_not_set_reference() {
689 let mut cache = NruCache::new(2);
690
691 cache.insert(1, 100);
692 cache.insert(2, 200);
693
694 // contains() doesn't set reference bit
695 assert!(cache.contains(&1));
696
697 // Manually clear reference bit by checking internals
698 // (In real usage, this would happen during eviction phase)
699 if let Some(entry) = cache.map.get_mut(&1) {
700 entry.referenced = false;
701 }
702
703 // Now 1 should be evictable
704 cache.insert(3, 300);
705
706 assert!(!cache.contains(&1)); // 1 was evicted
707 assert!(cache.contains(&2));
708 assert!(cache.contains(&3));
709 }
710
711 #[test]
712 fn test_zero_capacity() {
713 let mut cache = NruCache::new(0);
714 assert_eq!(cache.capacity(), 0);
715
716 cache.insert(1, 100);
717 assert!(!cache.contains(&1));
718 }
719}