LCOV - code coverage report
Current view: top level - antares-jsonld/src - minicache.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.2 % 246 244
Test Date: 2026-11-02 13:21:27 Functions: 90.7 % 54 49

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! wasm32 only: a bounded map with moka's call surface. moka's clock
       3              : //! calls `std::time::Instant::now()` unconditionally, which panics on
       4              : //! wasm32-unknown-unknown — so the browser build swaps in this FIFO-evicting
       5              : //! cache behind the same methods and builder the loader uses. Bounded is the
       6              : //! requirement, by the same budget as moka's: an entry count, or with a
       7              : //! weigher the sum of the entries' weights (the loader's byte and term
       8              : //! budgets). Recency-optimal eviction is not a requirement.
       9              : // FIFO eviction is deliberate; port moka's TinyLFU here only if a browser
      10              : // workload ever shows cache thrash.
      11              : 
      12              : use std::collections::{HashMap, VecDeque};
      13              : use std::hash::Hash;
      14              : use std::sync::Mutex;
      15              : 
      16              : type Weigher<K, V> = Box<dyn Fn(&K, &V) -> u32 + Send + Sync>;
      17              : 
      18              : /// Eviction policy, in full: entries leave in the order they were FIRST
      19              : /// inserted, until the new entry fits the budget. `get` is a pure read — it
      20              : /// does not refresh recency — and re-inserting a resident key overwrites its
      21              : /// value without moving it back in the queue. The held weight (one per entry
      22              : /// without a weigher) never exceeds the capacity the cache was built with,
      23              : /// including a capacity of zero; an entry heavier than the whole budget is
      24              : /// not held.
      25              : pub struct Cache<K, V> {
      26              :     cap: u64,
      27              :     weigher: Option<Weigher<K, V>>,
      28              :     inner: Mutex<Inner<K, V>>,
      29              : }
      30              : 
      31              : /// The entries with their weights, the insertion queue and the weight held.
      32              : type Inner<K, V> = (HashMap<K, (V, u64)>, VecDeque<K>, u64);
      33              : 
      34              : /// moka's `Cache::builder()` surface: a capacity and an optional weigher.
      35              : pub struct CacheBuilder<K, V> {
      36              :     cap: u64,
      37              :     weigher: Option<Weigher<K, V>>,
      38              : }
      39              : 
      40              : impl<K: Eq + Hash + Clone, V: Clone> CacheBuilder<K, V> {
      41           34 :     pub fn max_capacity(mut self, cap: u64) -> Self {
      42           34 :         self.cap = cap;
      43           34 :         self
      44           34 :     }
      45              : 
      46            6 :     pub fn weigher(mut self, weigh: impl Fn(&K, &V) -> u32 + Send + Sync + 'static) -> Self {
      47            6 :         self.weigher = Some(Box::new(weigh));
      48            6 :         self
      49            6 :     }
      50              : 
      51           34 :     pub fn build(self) -> Cache<K, V> {
      52           34 :         Cache {
      53           34 :             cap: self.cap,
      54           34 :             weigher: self.weigher,
      55           34 :             inner: Mutex::new((HashMap::new(), VecDeque::new(), 0)),
      56           34 :         }
      57           34 :     }
      58              : }
      59              : 
      60              : impl<K: Eq + Hash + Clone, V: Clone> Cache<K, V> {
      61           28 :     pub fn new(cap: u64) -> Self {
      62           28 :         Self::builder().max_capacity(cap).build()
      63           28 :     }
      64              : 
      65           34 :     pub fn builder() -> CacheBuilder<K, V> {
      66           34 :         CacheBuilder {
      67           34 :             cap: 0,
      68           34 :             weigher: None,
      69           34 :         }
      70           34 :     }
      71              : 
      72              :     /// A cache must not turn a panic elsewhere into a permanently poisoned
      73              :     /// broker: nothing under this lock can leave the map inconsistent, so a
      74              :     /// poisoned guard is taken as-is.
      75        36482 :     fn lock(&self) -> std::sync::MutexGuard<'_, Inner<K, V>> {
      76        36482 :         self.inner.lock().unwrap_or_else(|e| e.into_inner())
      77        36482 :     }
      78              : 
      79         8080 :     pub fn get<Q>(&self, key: &Q) -> Option<V>
      80         8080 :     where
      81         8080 :         K: std::borrow::Borrow<Q>,
      82         8080 :         Q: Eq + Hash + ?Sized,
      83              :     {
      84         8080 :         self.lock().0.get(key).map(|(v, _)| v.clone())
      85         8080 :     }
      86              : 
      87        18274 :     pub fn insert(&self, key: K, value: V) {
      88        18274 :         let weight = self
      89        18274 :             .weigher
      90        18274 :             .as_ref()
      91        18274 :             .map_or(1, |w| u64::from(w(&key, &value)));
      92        18274 :         let mut g = self.lock();
      93        18274 :         let (map, order, held) = &mut *g;
      94        18274 :         if let Some((_, old)) = map.remove(&key) {
      95            6 :             *held = held.saturating_sub(old);
      96        18268 :         }
      97        18274 :         if weight > self.cap {
      98          204 :             order.retain(|k| k != &key);
      99          204 :             return;
     100        18070 :         }
     101        35817 :         while held.saturating_add(weight) > self.cap {
     102        17747 :             let Some(at) = order.iter().position(|k| k != &key) else {
     103            0 :                 break;
     104              :             };
     105        17747 :             if let Some(old) = order.remove(at) {
     106        17747 :                 if let Some((_, w)) = map.remove(&old) {
     107        17747 :                     *held = held.saturating_sub(w);
     108        17747 :                 }
     109            0 :             }
     110              :         }
     111        18070 :         if !order.contains(&key) {
     112        18066 :             order.push_back(key.clone());
     113        18066 :         }
     114        18070 :         *held = held.saturating_add(weight);
     115        18070 :         map.insert(key, (value, weight));
     116        18274 :     }
     117              : 
     118           94 :     pub fn invalidate<Q>(&self, key: &Q)
     119           94 :     where
     120           94 :         K: std::borrow::Borrow<Q>,
     121           94 :         Q: Eq + Hash + ?Sized,
     122              :     {
     123           94 :         let mut g = self.lock();
     124           94 :         let (map, order, held) = &mut *g;
     125           94 :         if let Some((_, w)) = map.remove(key) {
     126           87 :             *held = held.saturating_sub(w);
     127           87 :         }
     128         1360 :         order.retain(|k| map.contains_key::<K>(k));
     129           94 :     }
     130              : 
     131              :     /// Snapshot of the resident entries, in moka's `iter()` item shape — the
     132              :     /// lock is released before the caller walks them, so a callback may touch
     133              :     /// this cache again.
     134            4 :     pub fn iter(&self) -> std::vec::IntoIter<(std::sync::Arc<K>, V)> {
     135            4 :         let g = self.lock();
     136            4 :         g.0.iter()
     137           14 :             .map(|(k, (v, _))| (std::sync::Arc::new(k.clone()), v.clone()))
     138            4 :             .collect::<Vec<_>>()
     139            4 :             .into_iter()
     140            4 :     }
     141              : 
     142            2 :     pub fn run_pending_tasks(&self) {}
     143              : 
     144         8036 :     pub fn entry_count(&self) -> u64 {
     145         8036 :         self.lock().0.len() as u64
     146         8036 :     }
     147              : 
     148              :     /// The weight held, moka's `weighted_size()`.
     149              :     #[cfg(test)]
     150         1994 :     pub fn weighted_size(&self) -> u64 {
     151         1994 :         self.lock().2
     152         1994 :     }
     153              : }
     154              : 
     155              : #[cfg(test)]
     156              : mod tests {
     157              :     use super::*;
     158              : 
     159           24 :     fn filled(cap: u64, n: u32) -> Cache<u32, u32> {
     160           24 :         let c = Cache::new(cap);
     161         8258 :         for i in 0..n {
     162         8258 :             c.insert(i, i);
     163         8258 :         }
     164           24 :         c
     165           24 :     }
     166              : 
     167              :     /// The entry count is a hard ceiling: inserting far past the capacity
     168              :     /// leaves exactly `cap` entries.
     169              :     #[test]
     170            2 :     fn capacity_is_a_hard_bound() {
     171            8 :         for cap in [1u64, 2, 7, 64] {
     172            8 :             let c = filled(cap, 1000);
     173            8 :             assert_eq!(c.entry_count(), cap, "cap {cap}");
     174              :         }
     175            2 :     }
     176              : 
     177              :     /// A zero capacity means "hold nothing" — it must not degrade into an
     178              :     /// unbounded (or one-entry) map.
     179              :     #[test]
     180            2 :     fn zero_capacity_holds_nothing() {
     181            2 :         let c = filled(0, 100);
     182            2 :         assert_eq!(c.entry_count(), 0);
     183            2 :         assert_eq!(c.get(&99), None);
     184            2 :     }
     185              : 
     186              :     /// Eviction is FIFO by first insertion and therefore deterministic: after
     187              :     /// filling past the bound exactly the last `cap` keys survive.
     188              :     #[test]
     189            2 :     fn eviction_is_deterministic_fifo() {
     190            2 :         let c = filled(4, 6);
     191            2 :         assert_eq!(c.entry_count(), 4);
     192            4 :         for gone in [0u32, 1] {
     193            4 :             assert_eq!(c.get(&gone), None, "key {gone} should have been evicted");
     194              :         }
     195            8 :         for kept in [2u32, 3, 4, 5] {
     196            8 :             assert_eq!(c.get(&kept), Some(kept), "key {kept} should be resident");
     197              :         }
     198            2 :     }
     199              : 
     200              :     /// Documented policy: `get` is a pure read — it does NOT refresh recency.
     201              :     /// A key read on every lookup is still evicted in insertion order.
     202              :     #[test]
     203            2 :     fn get_does_not_refresh_recency() {
     204            2 :         let c = filled(4, 4);
     205            2 :         for _ in 0..10 {
     206           20 :             assert_eq!(c.get(&0), Some(0));
     207              :         }
     208            2 :         c.insert(4, 4);
     209            2 :         assert_eq!(
     210            2 :             c.get(&0),
     211              :             None,
     212              :             "get must not protect an entry from FIFO eviction"
     213              :         );
     214            2 :         assert_eq!(c.get(&1), Some(1));
     215            2 :     }
     216              : 
     217              :     /// Re-inserting an existing key overwrites the value, keeps the count flat
     218              :     /// and does NOT move the key to the back of the eviction queue.
     219              :     #[test]
     220            2 :     fn reinsert_updates_value_without_reordering() {
     221            2 :         let c = filled(3, 3);
     222            2 :         c.insert(0, 99);
     223            2 :         assert_eq!(c.entry_count(), 3);
     224            2 :         assert_eq!(c.get(&0), Some(99));
     225            2 :         c.insert(3, 3);
     226            2 :         assert_eq!(c.get(&0), None, "re-insert must not refresh eviction order");
     227            2 :         assert_eq!(c.entry_count(), 3);
     228            2 :     }
     229              : 
     230              :     /// `invalidate` drops the entry and its queue slot, so the freed room is
     231              :     /// reused before anything else is evicted.
     232              :     #[test]
     233            2 :     fn invalidate_frees_a_slot() {
     234            2 :         let c = filled(2, 2);
     235            2 :         c.invalidate(&0);
     236            2 :         assert_eq!(c.entry_count(), 1);
     237            2 :         c.insert(2, 2);
     238            2 :         assert_eq!(c.entry_count(), 2);
     239            2 :         assert_eq!(
     240            2 :             c.get(&1),
     241              :             Some(1),
     242              :             "the surviving entry must not be evicted early"
     243              :         );
     244            2 :         c.insert(3, 3);
     245            2 :         assert_eq!(c.entry_count(), 2);
     246            2 :         assert_eq!(c.get(&1), None);
     247            2 :         assert_eq!(c.get(&3), Some(3));
     248            2 :     }
     249              : 
     250              :     /// Invalidating an absent key is a no-op, and the bound still holds after.
     251              :     #[test]
     252            2 :     fn invalidate_absent_key_is_a_noop() {
     253            2 :         let c = filled(3, 3);
     254            2 :         c.invalidate(&42);
     255            2 :         assert_eq!(c.entry_count(), 3);
     256            6 :         for i in 0..3 {
     257            6 :             assert_eq!(c.get(&i), Some(i));
     258              :         }
     259            2 :     }
     260              : 
     261              :     /// `iter` yields every resident entry exactly once — it is what the
     262              :     /// caller filters to invalidate a subset, so a missed entry would leave a
     263              :     /// stale one behind. Evicted keys must not appear.
     264              :     #[test]
     265            2 :     fn iter_yields_every_resident_entry() {
     266            2 :         let c = filled(4, 6);
     267            2 :         let mut seen: Vec<u32> = c
     268            2 :             .iter()
     269            8 :             .map(|(k, v)| {
     270            8 :                 assert_eq!(*k, v, "key and value must belong to the same entry");
     271            8 :                 *k
     272            8 :             })
     273            2 :             .collect();
     274            2 :         seen.sort_unstable();
     275            2 :         assert_eq!(
     276              :             seen,
     277            2 :             vec![2, 3, 4, 5],
     278              :             "the evicted keys must not be listed"
     279              :         );
     280            2 :         c.invalidate(&3);
     281            6 :         let mut seen: Vec<u32> = c.iter().map(|(k, _)| *k).collect();
     282            2 :         seen.sort_unstable();
     283            2 :         assert_eq!(seen, vec![2, 4, 5]);
     284            2 :     }
     285              : 
     286            6 :     fn weighed(cap: u64) -> Cache<u32, u32> {
     287            6 :         Cache::builder()
     288            6 :             .max_capacity(cap)
     289            6 :             .weigher(|_k: &u32, v: &u32| *v)
     290            6 :             .build()
     291            6 :     }
     292              : 
     293              :     /// With a weigher the budget is the sum of the weights, as moka's is:
     294              :     /// a heavy entry evicts as many of the oldest as it needs, never more,
     295              :     /// and the held weight never passes the capacity.
     296              :     #[test]
     297            2 :     fn a_weigher_bounds_the_held_weight() {
     298            2 :         let c = weighed(10);
     299            6 :         for (k, w) in [(0, 3), (1, 3), (2, 3)] {
     300            6 :             c.insert(k, w);
     301            6 :         }
     302            2 :         assert_eq!((c.entry_count(), c.weighted_size()), (3, 9));
     303            2 :         c.insert(3, 5);
     304            2 :         assert_eq!(c.get(&0), None, "the oldest made room");
     305            2 :         assert_eq!(c.get(&1), None, "and the next, since 3 + 3 + 5 > 10");
     306            2 :         assert_eq!((c.get(&2), c.get(&3)), (Some(3), Some(5)));
     307            2 :         assert_eq!(c.weighted_size(), 8);
     308         1980 :         for k in 10..1000 {
     309         1980 :             c.insert(k, k % 7 + 1);
     310         1980 :             assert!(c.weighted_size() <= 10, "bound breached at {k}");
     311              :         }
     312            2 :     }
     313              : 
     314              :     /// An entry heavier than the whole budget is not held, and it evicts
     315              :     /// nothing: a hostile 5 MiB document cannot flush the warm ones.
     316              :     #[test]
     317            2 :     fn an_entry_over_the_budget_is_not_held() {
     318            2 :         let c = weighed(10);
     319            2 :         c.insert(0, 4);
     320            2 :         c.insert(1, 11);
     321            2 :         assert_eq!(c.get(&1), None);
     322            2 :         assert_eq!((c.get(&0), c.weighted_size()), (Some(4), 4));
     323              :         // a resident key re-inserted over the budget leaves too
     324            2 :         c.insert(0, 11);
     325            2 :         assert_eq!(
     326            2 :             (c.get(&0), c.entry_count(), c.weighted_size()),
     327              :             (None, 0, 0)
     328              :         );
     329            2 :         c.insert(2, 10);
     330            2 :         assert_eq!(c.get(&2), Some(10), "the queue holds no stale key");
     331            2 :     }
     332              : 
     333              :     /// Re-inserting a key with a new weight charges the new weight, and
     334              :     /// invalidating returns it to the budget.
     335              :     #[test]
     336            2 :     fn reinsert_and_invalidate_keep_the_weight_exact() {
     337            2 :         let c = weighed(10);
     338            2 :         c.insert(0, 2);
     339            2 :         c.insert(1, 2);
     340            2 :         c.insert(0, 7);
     341            2 :         assert_eq!((c.weighted_size(), c.entry_count()), (9, 2));
     342            2 :         c.insert(2, 3);
     343            2 :         assert_eq!(
     344            2 :             (c.get(&0), c.get(&1)),
     345              :             (None, Some(2)),
     346              :             "FIFO: the re-inserted 0 kept its place at the head of the queue"
     347              :         );
     348            2 :         assert_eq!(c.weighted_size(), 5);
     349            2 :         c.invalidate(&1);
     350            2 :         assert_eq!(c.weighted_size(), 3);
     351            2 :     }
     352              : 
     353              :     /// `run_pending_tasks` exists only to match moka's call surface; it must
     354              :     /// be a no-op that leaves the occupancy unchanged.
     355              :     #[test]
     356            2 :     fn run_pending_tasks_is_a_noop() {
     357            2 :         let c = filled(3, 5);
     358            2 :         c.run_pending_tasks();
     359            2 :         assert_eq!(c.entry_count(), 3);
     360            2 :         assert_eq!(c.get(&4), Some(4));
     361            2 :     }
     362              : 
     363              :     /// Borrowed lookups: a `String`-keyed cache is queried with `&str`, which
     364              :     /// is how the loader uses it.
     365              :     #[test]
     366            2 :     fn borrowed_key_lookup() {
     367            2 :         let c: Cache<String, u32> = Cache::new(2);
     368            2 :         c.insert("a".to_owned(), 1);
     369            2 :         assert_eq!(c.get("a"), Some(1));
     370            2 :         c.invalidate("a");
     371            2 :         assert_eq!(c.get("a"), None);
     372            2 :         assert_eq!(c.entry_count(), 0);
     373            2 :     }
     374              : 
     375              :     /// The bound is enforced under the lock, so it holds no matter how the
     376              :     /// inserts interleave. (Native-only: wasm32 has no threads — the browser
     377              :     /// build reaches the cache from a single thread.)
     378              :     #[cfg(not(target_arch = "wasm32"))]
     379              :     #[test]
     380            2 :     fn bound_holds_under_concurrent_inserts() {
     381              :         use std::sync::Arc;
     382            2 :         let c: Arc<Cache<u32, u32>> = Arc::new(Cache::new(16));
     383            2 :         let mut handles = Vec::new();
     384           16 :         for t in 0..8u32 {
     385           16 :             let c = Arc::clone(&c);
     386           16 :             handles.push(std::thread::spawn(move || {
     387         8000 :                 for i in 0..500u32 {
     388         8000 :                     let k = t * 500 + i;
     389         8000 :                     c.insert(k, k);
     390         8000 :                     let _ = c.get(&k);
     391         8000 :                     if k % 97 == 0 {
     392           84 :                         c.invalidate(&k);
     393         7916 :                     }
     394         8000 :                     assert!(c.entry_count() <= 16, "bound breached: {}", c.entry_count());
     395              :                 }
     396           16 :             }));
     397              :         }
     398           16 :         for h in handles {
     399           16 :             h.join().expect("worker thread");
     400           16 :         }
     401            2 :         assert!(c.entry_count() <= 16);
     402            2 :     }
     403              : }
        

Generated by: LCOV version 2.0-1