Skip to main content

cache_mod/
tinylfu.rs

1//! TinyLFU cache — Count-Min Sketch frequency estimator + admission filter
2//! on top of an LRU-ordered main cache.
3
4use core::hash::{Hash, Hasher};
5use std::collections::hash_map::DefaultHasher;
6use std::collections::HashMap;
7use std::num::NonZeroUsize;
8use std::sync::Mutex;
9
10use crate::cache::Cache;
11use crate::error::CacheError;
12use crate::util::MutexExt;
13
14/// Count-Min Sketch depth (number of independent hash rows).
15const SKETCH_DEPTH: usize = 4;
16
17/// Width floor — the sketch is never narrower than this, regardless of
18/// configured capacity. Guards small-capacity caches from degenerate
19/// collision rates.
20const MIN_SKETCH_WIDTH: usize = 64;
21
22/// A bounded, thread-safe cache with **admission control**.
23///
24/// `TinyLfuCache` tracks the access frequency of *every* key it observes —
25/// including keys that aren't (yet) in the cache — using a fixed-size
26/// [Count-Min Sketch][cms]. On capacity overflow, an incoming key is
27/// **admitted only if its estimated frequency exceeds the LRU victim's**.
28/// One-hit-wonders are rejected at the door instead of evicting hot entries.
29///
30/// This is a deliberate semantic deviation from `LruCache` / `LfuCache` /
31/// `TtlCache`: a successful [`insert`](Cache::insert) call **does not
32/// guarantee** that the value is in the cache. The admission filter may
33/// have rejected it. Callers that need strict insertion guarantees should
34/// use `LruCache` or `LfuCache` instead.
35///
36/// Reference implementation details:
37///
38/// - depth-4 Count-Min Sketch with `u8` saturating counters
39/// - width = `max(MIN_SKETCH_WIDTH, 2 × capacity)`, rounded to the next power of two
40/// - periodic frequency decay: every `10 × capacity` increments, every counter
41///   is right-shifted by 1 (this is the "aging" step from the W-TinyLFU paper,
42///   which keeps the sketch responsive to shifting workloads)
43/// - main cache uses LRU ordering; eviction victim = least-recently-accessed
44/// - lock-minimized via `&self` + `Mutex<Inner>`; a true sharded / lock-free
45///   variant lands in a later minor without changing this public surface
46///
47/// # Example
48///
49/// ```
50/// use cache_mod::{Cache, TinyLfuCache};
51///
52/// let cache: TinyLfuCache<&'static str, u32> =
53///     TinyLfuCache::new(4).expect("capacity > 0");
54///
55/// // Build up the frequency signal for "hot".
56/// for _ in 0..16 {
57///     let _ = cache.get(&"hot");
58///     let _ = cache.insert("hot", 1);
59/// }
60///
61/// // A subsequent insert will see "hot" as warm in the sketch.
62/// assert_eq!(cache.get(&"hot"), Some(1));
63/// ```
64///
65/// [cms]: https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch
66pub struct TinyLfuCache<K, V> {
67    capacity: NonZeroUsize,
68    inner: Mutex<Inner<K, V>>,
69}
70
71struct Entry<V> {
72    value: V,
73    last_access: u64,
74}
75
76struct Inner<K, V> {
77    map: HashMap<K, Entry<V>>,
78    sketch: CountMinSketch,
79    clock: u64,
80}
81
82impl<K, V> TinyLfuCache<K, V>
83where
84    K: Eq + Hash + Clone,
85    V: Clone,
86{
87    /// Creates a cache with the given entry-count capacity.
88    ///
89    /// Returns [`CacheError::InvalidCapacity`] if `capacity == 0`.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use cache_mod::TinyLfuCache;
95    ///
96    /// let cache: TinyLfuCache<String, u32> =
97    ///     TinyLfuCache::new(256).expect("capacity > 0");
98    /// ```
99    pub fn new(capacity: usize) -> Result<Self, CacheError> {
100        let cap = NonZeroUsize::new(capacity).ok_or(CacheError::InvalidCapacity)?;
101        Ok(Self::with_capacity(cap))
102    }
103
104    /// Creates a cache with the given non-zero capacity. Infallible.
105    ///
106    /// # Example
107    ///
108    /// ```
109    /// use std::num::NonZeroUsize;
110    /// use cache_mod::TinyLfuCache;
111    ///
112    /// let cap = NonZeroUsize::new(256).expect("256 != 0");
113    /// let cache: TinyLfuCache<String, u32> = TinyLfuCache::with_capacity(cap);
114    /// ```
115    pub fn with_capacity(capacity: NonZeroUsize) -> Self {
116        let cap = capacity.get();
117        Self {
118            capacity,
119            inner: Mutex::new(Inner {
120                map: HashMap::with_capacity(cap),
121                sketch: CountMinSketch::new(cap),
122                clock: 0,
123            }),
124        }
125    }
126}
127
128impl<K, V> Cache<K, V> for TinyLfuCache<K, V>
129where
130    K: Eq + Hash + Clone,
131    V: Clone,
132{
133    fn get(&self, key: &K) -> Option<V> {
134        let mut inner = self.inner.lock_recover();
135        // Every observation feeds the sketch, even on cache miss — that's
136        // how the cache learns which keys are "hot" before they are admitted.
137        inner.sketch.increment(key);
138        inner.clock = inner.clock.wrapping_add(1);
139        let now = inner.clock;
140        let entry = inner.map.get_mut(key)?;
141        entry.last_access = now;
142        Some(entry.value.clone())
143    }
144
145    fn insert(&self, key: K, value: V) -> Option<V> {
146        let mut inner = self.inner.lock_recover();
147        inner.sketch.increment(&key);
148        inner.clock = inner.clock.wrapping_add(1);
149        let now = inner.clock;
150
151        // Live update: existing key always succeeds (no admission check).
152        if let Some(existing) = inner.map.get_mut(&key) {
153            let old = core::mem::replace(&mut existing.value, value);
154            existing.last_access = now;
155            return Some(old);
156        }
157
158        // New key. If at capacity, run the admission filter.
159        if inner.map.len() >= self.capacity.get() {
160            let candidate_freq = inner.sketch.estimate(&key);
161            let victim = find_lru_victim(&inner.map);
162            if let Some(victim_key) = victim {
163                let victim_freq = inner.sketch.estimate(&victim_key);
164                if candidate_freq <= victim_freq {
165                    // Reject the admission. The value is dropped at the
166                    // end of the function. Returning `None` matches the
167                    // "no prior entry was present" semantic.
168                    return None;
169                }
170                let _ = inner.map.remove(&victim_key);
171            }
172        }
173
174        let _ = inner.map.insert(
175            key,
176            Entry {
177                value,
178                last_access: now,
179            },
180        );
181        None
182    }
183
184    fn remove(&self, key: &K) -> Option<V> {
185        let mut inner = self.inner.lock_recover();
186        inner.map.remove(key).map(|e| e.value)
187    }
188
189    fn contains_key(&self, key: &K) -> bool {
190        self.inner.lock_recover().map.contains_key(key)
191    }
192
193    fn len(&self) -> usize {
194        self.inner.lock_recover().map.len()
195    }
196
197    fn clear(&self) {
198        let mut inner = self.inner.lock_recover();
199        inner.map.clear();
200        inner.sketch.reset();
201        inner.clock = 0;
202    }
203
204    fn capacity(&self) -> usize {
205        self.capacity.get()
206    }
207}
208
209fn find_lru_victim<K, V>(map: &HashMap<K, Entry<V>>) -> Option<K>
210where
211    K: Clone,
212{
213    map.iter()
214        .min_by_key(|(_, e)| e.last_access)
215        .map(|(k, _)| k.clone())
216}
217
218// -----------------------------------------------------------------------------
219// Count-Min Sketch
220// -----------------------------------------------------------------------------
221
222/// A small Count-Min Sketch with `u8` saturating counters and periodic
223/// halving. Used as the frequency estimator behind `TinyLfuCache`'s
224/// admission filter.
225struct CountMinSketch {
226    counters: Vec<u8>,
227    width: usize,
228    /// `width` as `u64` — pre-cached because every probe modulos by it.
229    width_u64: u64,
230    samples: u64,
231    /// Number of increments between sketch halvings (the "aging" trigger).
232    sample_window: u64,
233}
234
235impl CountMinSketch {
236    fn new(capacity: usize) -> Self {
237        let mut width = capacity.saturating_mul(2).max(MIN_SKETCH_WIDTH);
238        // Round up to the next power of two so the modulus could later be
239        // swapped for a mask without changing semantics.
240        width = width.next_power_of_two();
241        let sample_window = (capacity as u64).saturating_mul(10).max(64);
242        Self {
243            counters: vec![0; width.saturating_mul(SKETCH_DEPTH)],
244            width,
245            width_u64: width as u64,
246            samples: 0,
247            sample_window,
248        }
249    }
250
251    fn estimate<K: Hash>(&self, key: &K) -> u8 {
252        let mut min = u8::MAX;
253        for d in 0..SKETCH_DEPTH {
254            let idx = self.cell(d, key);
255            let observed = *self.counters.get(idx).unwrap_or(&0);
256            if observed < min {
257                min = observed;
258            }
259        }
260        min
261    }
262
263    fn increment<K: Hash>(&mut self, key: &K) {
264        for d in 0..SKETCH_DEPTH {
265            let idx = self.cell(d, key);
266            if let Some(slot) = self.counters.get_mut(idx) {
267                *slot = slot.saturating_add(1);
268            }
269        }
270        self.samples = self.samples.saturating_add(1);
271        if self.samples >= self.sample_window {
272            self.age();
273            self.samples = 0;
274        }
275    }
276
277    fn reset(&mut self) {
278        for c in self.counters.iter_mut() {
279            *c = 0;
280        }
281        self.samples = 0;
282    }
283
284    /// Halve every counter — the W-TinyLFU "aging" step. Recent activity
285    /// dominates over time-bygone activity, which lets the cache follow
286    /// workload shifts instead of being locked in to the first hot set.
287    fn age(&mut self) {
288        for c in self.counters.iter_mut() {
289            *c >>= 1;
290        }
291    }
292
293    /// Compute the absolute counter index for the `d`-th sketch row.
294    fn cell<K: Hash>(&self, d: usize, key: &K) -> usize {
295        let h = hash_with_seed(key, d as u64);
296        let col = (h % self.width_u64) as usize;
297        d.saturating_mul(self.width).saturating_add(col)
298    }
299}
300
301fn hash_with_seed<K: Hash>(key: &K, seed: u64) -> u64 {
302    let mut hasher = DefaultHasher::new();
303    seed.hash(&mut hasher);
304    key.hash(&mut hasher);
305    hasher.finish()
306}