Skip to main content

mbr_core/
cache.rs

1//! Shared primitives for size-bounded in-memory caches.
2//!
3//! The concrete caches in this crate ([`crate::oembed_cache`],
4//! `video_metadata_cache`, `video_transcode_cache`) all bound memory by an
5//! approximate byte budget with the same conventions:
6//!
7//! - A `max_size_bytes` of `0` disables the cache entirely.
8//! - Each stored entry is weighed once at insert time (value estimate + key
9//!   bytes + fixed per-entry overhead).
10//! - Overwriting an existing key subtracts the replaced entry's accounted
11//!   size first, so the running total reflects the replacement rather than
12//!   ratcheting upward.
13//! - When the total exceeds the budget, entries are evicted in a
14//!   caller-defined priority order until enough bytes are freed.
15//!
16//! [`SizeBoundedMap`] implements those conventions on top of a lock-free
17//! papaya [`HashMap`] with an [`AtomicUsize`] size counter, and is the shared
18//! core for the papaya-backed caches. [`Entry`] carries the per-entry
19//! bookkeeping and is also reused by the mutex-guarded LRU oembed cache,
20//! which shares the weighing/accounting conventions but not the storage.
21
22use papaya::{Compute, HashMap, Operation};
23use std::borrow::Borrow;
24use std::hash::Hash;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::time::Instant;
27
28/// A cached value together with the bookkeeping used for size accounting and
29/// insertion-order eviction.
30pub struct Entry<V> {
31    /// The cached value.
32    pub value: V,
33    /// When this entry was inserted (used for oldest-first eviction and TTLs).
34    pub inserted_at: Instant,
35    /// Accounted memory size in bytes.
36    pub size_bytes: usize,
37}
38
39impl<V> Entry<V> {
40    /// Creates an entry inserted now.
41    pub fn new(value: V, size_bytes: usize) -> Self {
42        Self {
43            value,
44            inserted_at: Instant::now(),
45            size_bytes,
46        }
47    }
48
49    /// Total accounted size for an entry: the estimated value size plus the
50    /// key's bytes plus the fixed per-entry bookkeeping overhead.
51    pub fn weigh(value_size: usize, key_len: usize) -> usize {
52        value_size + key_len + std::mem::size_of::<Self>()
53    }
54}
55
56/// Outcome of [`SizeBoundedMap::claim_or`].
57pub enum Claim<R> {
58    /// The caller's value now occupies the slot.
59    Claimed,
60    /// An existing entry was kept; carries the value the decision closure
61    /// produced for it.
62    Retained(R),
63}
64
65/// Statistics from an eviction pass.
66pub struct EvictionStats {
67    /// Number of entries removed.
68    pub evicted: usize,
69    /// Total accounted bytes freed.
70    pub freed: usize,
71}
72
73/// A lock-free, size-bounded concurrent map.
74///
75/// Reads and writes go through a papaya [`HashMap`]; the approximate total
76/// size is tracked in an [`AtomicUsize`]. Eviction is approximate and happens
77/// at insert time under the caller's control (see [`Self::evict_until_freed`]),
78/// which keeps `get` entirely lock-free.
79pub struct SizeBoundedMap<K, V> {
80    /// The underlying concurrent map.
81    map: HashMap<K, Entry<V>>,
82    /// Current total accounted size in bytes (approximate).
83    current_size: AtomicUsize,
84    /// Maximum allowed size in bytes; `0` disables the cache.
85    max_size: usize,
86}
87
88impl<K, V> SizeBoundedMap<K, V>
89where
90    K: Hash + Eq + Clone,
91{
92    /// Creates a new map with the specified maximum size in bytes.
93    ///
94    /// A `max_size_bytes` of `0` disables the cache: lookups always miss and
95    /// inserts are no-ops.
96    pub fn new(max_size_bytes: usize) -> Self {
97        Self {
98            map: HashMap::new(),
99            current_size: AtomicUsize::new(0),
100            max_size: max_size_bytes,
101        }
102    }
103
104    /// Returns true when the cache is disabled (`max_size_bytes == 0`).
105    pub fn is_disabled(&self) -> bool {
106        self.max_size == 0
107    }
108
109    /// Returns the configured maximum size in bytes.
110    pub fn max_size(&self) -> usize {
111        self.max_size
112    }
113
114    /// Returns the current approximate accounted size in bytes.
115    pub fn current_size(&self) -> usize {
116        self.current_size.load(Ordering::Relaxed)
117    }
118
119    /// Subtracts `bytes` from the running total, saturating at zero.
120    ///
121    /// The total is maintained by several independent lock-free operations, so
122    /// a duplicated or stale subtraction is possible under contention. A plain
123    /// `fetch_sub` would wrap to a near-`usize::MAX` total and leave the cache
124    /// permanently "over budget", evicting on every insert; saturating keeps
125    /// the drift bounded and self-correcting.
126    // `fetch_update` is deprecated as of Rust 1.99 in favour of `update` /
127    // `try_update`, which only stabilized in 1.95 — newer than this crate's
128    // `rust-version`. Switch to `update` once the MSRV reaches 1.95.
129    #[allow(deprecated)]
130    fn sub_current_size(&self, bytes: usize) {
131        let _ = self
132            .current_size
133            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
134                Some(current.saturating_sub(bytes))
135            });
136    }
137
138    /// Returns the number of entries in the map.
139    pub fn len(&self) -> usize {
140        self.map.pin().len()
141    }
142
143    /// Returns true if the map is empty.
144    pub fn is_empty(&self) -> bool {
145        self.map.pin().is_empty()
146    }
147
148    /// Applies `f` to the entry for `key`, if present.
149    ///
150    /// Returns `None` when the key is absent or the cache is disabled. This
151    /// gives callers access to the entry's bookkeeping (e.g. `inserted_at`
152    /// for TTL checks) without cloning more than they need.
153    pub fn with_entry<Q, R>(&self, key: &Q, f: impl FnOnce(&Entry<V>) -> R) -> Option<R>
154    where
155        K: Borrow<Q>,
156        Q: Hash + Eq + ?Sized,
157    {
158        if self.is_disabled() {
159            return None;
160        }
161        self.map.pin().get(key).map(f)
162    }
163
164    /// Inserts `value` accounted at `size_bytes`, timestamped now.
165    ///
166    /// See [`Self::insert_weighted_at`].
167    pub fn insert_weighted(&self, key: K, value: V, size_bytes: usize) -> (Option<V>, usize)
168    where
169        V: Clone,
170    {
171        self.insert_weighted_at(key, value, size_bytes, Instant::now())
172    }
173
174    /// Inserts `value` accounted at `size_bytes` with an explicit insertion
175    /// timestamp (exposed for TTL tests that need to backdate entries).
176    ///
177    /// If an entry already existed for this key, its accounted size is
178    /// subtracted first so the running total reflects the replacement rather
179    /// than ratcheting upward on every overwrite.
180    ///
181    /// Returns the replaced value (if any) and the new approximate total
182    /// size. Eviction is not performed here; callers decide if and how to
183    /// evict based on the returned total (see [`Self::evict_until_freed`]).
184    pub fn insert_weighted_at(
185        &self,
186        key: K,
187        value: V,
188        size_bytes: usize,
189        inserted_at: Instant,
190    ) -> (Option<V>, usize)
191    where
192        V: Clone,
193    {
194        if self.is_disabled() {
195            return (None, 0);
196        }
197        let entry = Entry {
198            value,
199            inserted_at,
200            size_bytes,
201        };
202        let guard = self.map.pin();
203        let (replaced_value, replaced_size) = guard
204            .insert(key, entry)
205            .map_or((None, 0), |old| (Some(old.value.clone()), old.size_bytes));
206        self.sub_current_size(replaced_size);
207        let new_total = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
208        (replaced_value, new_total)
209    }
210
211    /// Atomically installs `value` for `key` unless an existing entry is kept.
212    ///
213    /// `decide` is consulted for an entry that is already present: it returns
214    /// `Some(reason)` to keep that entry (nothing is written, `reason` is
215    /// handed back) or `None` to replace it. A vacant slot is always claimed.
216    ///
217    /// The read-modify-write is a single papaya compare-and-swap, so of many
218    /// concurrent callers exactly one claims a given slot. A `get` followed by
219    /// an `insert` cannot promise that: every caller can observe the vacancy
220    /// and every caller then claims it, which is how single-flight guards leak
221    /// duplicate producers.
222    ///
223    /// `decide` may run more than once (the CAS retries when the entry changes
224    /// underneath it), so it must be free of side effects.
225    ///
226    /// When the cache is disabled nothing is stored and every caller is told it
227    /// claimed the slot — with no storage there is nothing to deduplicate on.
228    pub fn claim_or<R>(
229        &self,
230        key: K,
231        value: V,
232        size_bytes: usize,
233        decide: impl Fn(&Entry<V>) -> Option<R>,
234    ) -> Claim<R>
235    where
236        V: Clone,
237    {
238        if self.is_disabled() {
239            return Claim::Claimed;
240        }
241
242        let guard = self.map.pin();
243        let outcome = guard.compute(key, |existing| {
244            let keep = existing.and_then(|(_, entry)| decide(entry));
245            match keep {
246                Some(reason) => Operation::Abort(reason),
247                None => Operation::Insert(Entry::new(value.clone(), size_bytes)),
248            }
249        });
250
251        match outcome {
252            Compute::Aborted(reason) => Claim::Retained(reason),
253            Compute::Inserted(_, _) => {
254                self.current_size.fetch_add(size_bytes, Ordering::Relaxed);
255                Claim::Claimed
256            }
257            Compute::Updated {
258                old: (_, replaced), ..
259            } => {
260                self.sub_current_size(replaced.size_bytes);
261                self.current_size.fetch_add(size_bytes, Ordering::Relaxed);
262                Claim::Claimed
263            }
264            // `Operation::Remove` is never produced above, so the map cannot
265            // report a removal. Keep the accounting honest and treat the now
266            // vacant slot as claimed rather than panicking on an unreachable
267            // state.
268            Compute::Removed(_, removed) => {
269                self.sub_current_size(removed.size_bytes);
270                Claim::Claimed
271            }
272        }
273    }
274
275    /// Removes the entry for `key` when `predicate` accepts it, adjusting the
276    /// accounted total. Returns whether an entry was removed.
277    ///
278    /// The removal is a single atomic compare-and-remove, which is what makes
279    /// this safe for withdrawing a marker a caller owns: if another writer has
280    /// already replaced the entry, `predicate` sees the new value and can
281    /// decline, so a concurrent legitimate write is never clobbered.
282    pub fn remove_if(&self, key: K, predicate: impl Fn(&Entry<V>) -> bool) -> bool {
283        if self.is_disabled() {
284            return false;
285        }
286        let guard = self.map.pin();
287        let outcome = guard.compute(key, |existing| match existing {
288            Some((_, entry)) if predicate(entry) => Operation::Remove,
289            _ => Operation::Abort(()),
290        });
291
292        match outcome {
293            Compute::Removed(_, entry) => {
294                self.current_size
295                    .fetch_sub(entry.size_bytes, Ordering::Relaxed);
296                true
297            }
298            _ => false,
299        }
300    }
301
302    /// Removes evictable entries in ascending `priority` order until at least
303    /// `target_bytes` have been freed (or no candidates remain).
304    ///
305    /// `priority` returns `Some(order_key)` for evictable entries — smaller
306    /// keys are evicted first — and `None` for entries that must be kept.
307    pub fn evict_until_freed<P: Ord>(
308        &self,
309        target_bytes: usize,
310        priority: impl Fn(&K, &Entry<V>) -> Option<P>,
311    ) -> EvictionStats {
312        let guard = self.map.pin();
313        let mut candidates: Vec<(K, P)> = guard
314            .iter()
315            .filter_map(|(k, e)| priority(k, e).map(|p| (k.clone(), p)))
316            .collect();
317
318        // Evict in ascending priority order (e.g. oldest first).
319        candidates.sort_by(|(_, a), (_, b)| a.cmp(b));
320
321        let mut freed = 0usize;
322        let mut evicted = 0usize;
323        for (key, _) in candidates {
324            if freed >= target_bytes {
325                break;
326            }
327            // Account the entry that was *actually* removed rather than the one
328            // observed while iterating: a concurrent overwrite between the
329            // snapshot and this removal replaces the entry, and subtracting the
330            // stale size would leave `current_size` permanently drifted from
331            // the map's real contents.
332            if let Some(removed) = guard.remove(&key) {
333                freed += removed.size_bytes;
334                evicted += 1;
335                self.sub_current_size(removed.size_bytes);
336            }
337        }
338
339        EvictionStats { evicted, freed }
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_insert_and_with_entry_roundtrip() {
349        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
350
351        let (replaced, total) = map.insert_weighted("k".to_string(), "value".to_string(), 10);
352        assert!(replaced.is_none());
353        assert_eq!(total, 10);
354
355        let value = map.with_entry("k", |e| e.value.clone());
356        assert_eq!(value.as_deref(), Some("value"));
357        assert_eq!(map.current_size(), 10);
358        assert_eq!(map.len(), 1);
359        assert!(!map.is_empty());
360    }
361
362    #[test]
363    fn test_disabled_map_misses_and_ignores_inserts() {
364        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(0);
365        assert!(map.is_disabled());
366
367        let (replaced, total) = map.insert_weighted("k".to_string(), "v".to_string(), 10);
368        assert!(replaced.is_none());
369        assert_eq!(total, 0);
370        assert!(map.with_entry("k", |e| e.value.clone()).is_none());
371        assert!(map.is_empty());
372        assert_eq!(map.current_size(), 0);
373    }
374
375    #[test]
376    fn test_overwrite_subtracts_replaced_size() {
377        // The running total must reflect the replacement, not ratchet upward.
378        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
379
380        map.insert_weighted("k".to_string(), "small".to_string(), 100);
381        assert_eq!(map.current_size(), 100);
382
383        let (replaced, total) = map.insert_weighted("k".to_string(), "large".to_string(), 250);
384        assert_eq!(replaced.as_deref(), Some("small"));
385        assert_eq!(total, 250);
386        assert_eq!(map.current_size(), 250);
387        assert_eq!(map.len(), 1);
388    }
389
390    #[test]
391    fn test_remove_if_removes_only_matching_entries_and_adjusts_size() {
392        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
393        map.insert_weighted("keep".to_string(), "keep".to_string(), 100);
394        map.insert_weighted("drop".to_string(), "drop".to_string(), 250);
395        assert_eq!(map.current_size(), 350);
396
397        // A declining predicate must leave the entry, and the total, alone.
398        assert!(!map.remove_if("keep".to_string(), |entry| entry.value == "drop"));
399        assert_eq!(map.current_size(), 350);
400        assert_eq!(map.len(), 2);
401
402        // An accepting predicate removes the entry and reclaims its bytes.
403        assert!(map.remove_if("drop".to_string(), |entry| entry.value == "drop"));
404        assert_eq!(map.current_size(), 100);
405        assert_eq!(map.len(), 1);
406        assert!(map.with_entry("drop", |e| e.value.clone()).is_none());
407    }
408
409    #[test]
410    fn test_remove_if_absent_key_is_a_no_op() {
411        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(1024);
412        assert!(!map.remove_if("missing".to_string(), |_| true));
413        assert_eq!(map.current_size(), 0);
414    }
415
416    #[test]
417    fn test_remove_if_on_disabled_map_is_a_no_op() {
418        let map: SizeBoundedMap<String, String> = SizeBoundedMap::new(0);
419        assert!(!map.remove_if("k".to_string(), |_| true));
420    }
421
422    #[test]
423    fn test_evict_until_freed_oldest_first() {
424        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
425
426        // Insert with explicit timestamps so ordering is deterministic.
427        let base = Instant::now();
428        map.insert_weighted_at("old".to_string(), 1, 100, base);
429        map.insert_weighted_at(
430            "new".to_string(),
431            2,
432            100,
433            base + std::time::Duration::from_secs(1),
434        );
435
436        let stats = map.evict_until_freed(100, |_, e| Some(e.inserted_at));
437        assert_eq!(stats.evicted, 1);
438        assert_eq!(stats.freed, 100);
439        assert!(map.with_entry("old", |_| ()).is_none());
440        assert!(map.with_entry("new", |_| ()).is_some());
441        assert_eq!(map.current_size(), 100);
442    }
443
444    #[test]
445    fn test_evict_skips_entries_without_priority() {
446        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
447
448        map.insert_weighted("keep".to_string(), 0, 100);
449        map.insert_weighted("evictable".to_string(), 1, 100);
450
451        // Only entries with value > 0 are evictable; the target exceeds what
452        // can be freed, so eviction stops when candidates run out.
453        let stats = map.evict_until_freed(1000, |_, e| (e.value > 0).then_some(e.inserted_at));
454        assert_eq!(stats.evicted, 1);
455        assert_eq!(stats.freed, 100);
456        assert!(map.with_entry("keep", |_| ()).is_some());
457        assert!(map.with_entry("evictable", |_| ()).is_none());
458    }
459
460    #[test]
461    fn test_evict_accounts_the_entry_actually_removed() {
462        // Regression: the eviction pass used to subtract the size it snapshotted
463        // while iterating. Replacing the entry between that snapshot and the
464        // removal (what a concurrent insert does) then left `current_size`
465        // permanently above the map's real contents.
466        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
467        map.insert_weighted("k".to_string(), 1, 100);
468        assert_eq!(map.current_size(), 100);
469
470        // The priority closure runs during the snapshot pass, so overwriting
471        // from inside it reproduces the interleaving deterministically.
472        let stats = map.evict_until_freed(50, |_, entry| {
473            map.insert_weighted("k".to_string(), 2, 900);
474            Some(entry.inserted_at)
475        });
476
477        assert_eq!(stats.evicted, 1);
478        assert_eq!(
479            stats.freed, 900,
480            "freed bytes must reflect the removed entry, not the stale snapshot"
481        );
482        assert!(map.is_empty());
483        assert_eq!(
484            map.current_size(),
485            0,
486            "an emptied map must account exactly zero bytes"
487        );
488    }
489
490    #[test]
491    fn test_current_size_subtraction_saturates_at_zero() {
492        // Defence in depth: an over-subtraction must clamp instead of wrapping
493        // to ~usize::MAX, which would make the cache look permanently over
494        // budget and evict on every insert.
495        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
496        map.insert_weighted("k".to_string(), 1, 10);
497
498        map.sub_current_size(usize::MAX);
499
500        assert_eq!(map.current_size(), 0);
501    }
502
503    #[test]
504    fn test_claim_or_claims_vacant_slot_and_accounts_size() {
505        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
506
507        let claim = map.claim_or("k".to_string(), 7, 40, |_| Some("occupied"));
508
509        assert!(matches!(claim, Claim::Claimed));
510        assert_eq!(map.with_entry("k", |e| e.value), Some(7));
511        assert_eq!(map.current_size(), 40);
512    }
513
514    #[test]
515    fn test_claim_or_retains_existing_entry() {
516        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
517        map.insert_weighted("k".to_string(), 7, 40);
518
519        let claim = map.claim_or("k".to_string(), 9, 40, |entry| Some(entry.value));
520
521        assert!(matches!(claim, Claim::Retained(7)));
522        assert_eq!(map.with_entry("k", |e| e.value), Some(7));
523        assert_eq!(
524            map.current_size(),
525            40,
526            "a retained slot must not be charged"
527        );
528    }
529
530    #[test]
531    fn test_claim_or_replaces_when_decision_declines_to_keep() {
532        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024);
533        map.insert_weighted("k".to_string(), 7, 40);
534
535        let claim = map.claim_or("k".to_string(), 9, 10, |_| None::<()>);
536
537        assert!(matches!(claim, Claim::Claimed));
538        assert_eq!(map.with_entry("k", |e| e.value), Some(9));
539        assert_eq!(
540            map.current_size(),
541            10,
542            "replacement must subtract the old size and add the new one"
543        );
544    }
545
546    #[test]
547    fn test_claim_or_on_disabled_map_stores_nothing() {
548        let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(0);
549
550        let claim = map.claim_or("k".to_string(), 7, 40, |_| Some("occupied"));
551
552        assert!(matches!(claim, Claim::Claimed));
553        assert!(map.is_empty());
554        assert_eq!(map.current_size(), 0);
555    }
556
557    #[test]
558    fn test_claim_or_admits_exactly_one_of_many_concurrent_claimants() {
559        use std::sync::Barrier;
560
561        const THREADS: usize = 8;
562        const ROUNDS: usize = 64;
563
564        for round in 0..ROUNDS {
565            let map: SizeBoundedMap<String, u32> = SizeBoundedMap::new(1024 * 1024);
566            let key = format!("race-{round}");
567            let barrier = Barrier::new(THREADS);
568            let claims = AtomicUsize::new(0);
569
570            std::thread::scope(|scope| {
571                for _ in 0..THREADS {
572                    scope.spawn(|| {
573                        barrier.wait();
574                        if let Claim::Claimed = map.claim_or(key.clone(), 1, 0, |_| Some(())) {
575                            claims.fetch_add(1, Ordering::Relaxed);
576                        }
577                    });
578                }
579            });
580
581            assert_eq!(
582                claims.load(Ordering::Relaxed),
583                1,
584                "exactly one claimant may win a slot"
585            );
586        }
587    }
588
589    #[test]
590    fn test_entry_weigh_includes_key_and_overhead() {
591        let size = Entry::<Vec<u8>>::weigh(100, 7);
592        assert_eq!(size, 100 + 7 + std::mem::size_of::<Entry<Vec<u8>>>());
593    }
594}