Skip to main content

whatsapp_rust/
portable_cache.rs

1//! Portable in-process cache: the client's sole cache backend, on every target
2//! including wasm32.
3//!
4//! TTL/TTI use the monotonic [`wacore::time::Instant`] (not the wall clock),
5//! so expiry is immune to system-clock jumps. Provides capacity + TTL/TTI
6//! eviction and an async, single-flight `get_with`.
7//!
8//! `get_with` / `get_with_by_ref` are single-flight: concurrent inits for the
9//! same missing key run the initializer once.
10
11use async_lock::{Mutex as AsyncMutex, RwLock};
12use std::borrow::Borrow;
13use std::collections::{BTreeMap, HashMap};
14use std::hash::{BuildHasher, Hash, RandomState};
15use std::sync::Arc;
16use std::time::Duration;
17use wacore::runtime::BoxFuture;
18use wacore::sync_marker::MaybeSend;
19use wacore::time::Instant;
20
21struct CacheEntry<V> {
22    value: V,
23    // Monotonic instants (not wall-clock) so TTL/TTI are immune to clock jumps,
24    // matching moka's timer semantics.
25    inserted_at: Instant,
26    last_accessed_at: Instant,
27    /// FIFO sequence number; the key for this entry in `CacheInner::order`.
28    seq: u64,
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
32pub(crate) struct CapacityStats {
33    pub entries: u64,
34    pub evictions: u64,
35    pub eviction_blocks: u64,
36}
37
38/// Portable, runtime-agnostic in-process cache.
39///
40/// - Max capacity with FIFO eviction
41/// - TTL (time-to-live) and TTI (time-to-idle)
42/// - Single-flight `get_with` / `get_with_by_ref`
43pub struct PortableCache<K, V> {
44    inner: Arc<RwLock<CacheInner<K, V>>>,
45    /// Per-key init locks for single-flight `get_with`.
46    init_locks: Arc<InitLocks>,
47    max_capacity: Option<u64>,
48    ttl: Option<Duration>,
49    tti: Option<Duration>,
50    /// Optional predicate gating capacity eviction: returns `true` if a value may
51    /// be evicted. Used by coordination-lock caches to protect an entry a live task
52    /// still holds (e.g. an `Arc<Mutex>` mid-lock), which would otherwise be
53    /// FIFO-evicted and re-minted, letting two writers race the guarded resource.
54    evict_guard: Option<fn(&V) -> bool>,
55}
56
57struct CacheInner<K, V> {
58    map: HashMap<K, CacheEntry<V>>,
59    /// FIFO eviction order keyed by monotonic sequence. `seq -> key`, so eviction
60    /// is `pop_first()` (O(log n)) and a targeted `remove_key` is O(log n) via the
61    /// entry's stored `seq` — instead of an O(n) scan over an insertion list.
62    order: BTreeMap<u64, K>,
63    /// Next FIFO sequence to assign.
64    next_seq: u64,
65    capacity_evictions: u64,
66    capacity_eviction_blocks: u64,
67}
68
69impl<K, V> CacheInner<K, V>
70where
71    K: Hash + Eq + Clone,
72{
73    fn new() -> Self {
74        Self {
75            map: HashMap::new(),
76            order: BTreeMap::new(),
77            next_seq: 0,
78            capacity_evictions: 0,
79            capacity_eviction_blocks: 0,
80        }
81    }
82
83    fn remove_key(&mut self, key: &K) -> Option<CacheEntry<V>> {
84        let entry = self.map.remove(key)?;
85        self.order.remove(&entry.seq);
86        Some(entry)
87    }
88
89    /// Evict entries until under `cap`. Outlined and never-inlined so the guarded
90    /// scan is compiled once per `<K, V>` (not duplicated into every `insert_new`
91    /// inline site), keeping the binary-size cost of the guard path small.
92    #[inline(never)]
93    fn evict_to_capacity(&mut self, cap: u64, evict_guard: Option<fn(&V) -> bool>) {
94        while self.map.len() as u64 >= cap {
95            match evict_guard {
96                // Unguarded caches keep the single-pass pop_first() fast path.
97                None => match self.order.pop_first() {
98                    Some((_, oldest_key)) => {
99                        if self.map.remove(&oldest_key).is_some() {
100                            self.capacity_evictions = self.capacity_evictions.saturating_add(1);
101                        }
102                    }
103                    None => break,
104                },
105                // Guarded: skip entries a live task still holds so a later lookup
106                // can't mint a duplicate; if every entry is held, allow temporary
107                // over-capacity rather than dropping a live entry. Remove by seq so
108                // the key isn't cloned.
109                Some(is_evictable) => {
110                    let mut victim_seq = None;
111                    for (seq, k) in self.order.iter() {
112                        if self.map.get(k).is_some_and(|e| is_evictable(&e.value)) {
113                            victim_seq = Some(*seq);
114                            break;
115                        }
116                    }
117                    match victim_seq {
118                        Some(seq) => {
119                            if let Some(oldest_key) = self.order.remove(&seq)
120                                && self.map.remove(&oldest_key).is_some()
121                            {
122                                self.capacity_evictions = self.capacity_evictions.saturating_add(1);
123                            }
124                        }
125                        None => {
126                            self.capacity_eviction_blocks =
127                                self.capacity_eviction_blocks.saturating_add(1);
128                            break;
129                        }
130                    }
131                }
132            }
133        }
134    }
135
136    /// Insert a brand-new entry (the caller has already confirmed the key is
137    /// absent), evicting the oldest entries first if at capacity. Assigns and
138    /// records the FIFO sequence.
139    fn insert_new(
140        &mut self,
141        key: K,
142        value: V,
143        now: Instant,
144        max_capacity: Option<u64>,
145        evict_guard: Option<fn(&V) -> bool>,
146    ) {
147        if let Some(cap) = max_capacity {
148            self.evict_to_capacity(cap, evict_guard);
149        }
150
151        let seq = self.next_seq;
152        self.next_seq += 1;
153        self.order.insert(seq, key.clone());
154        self.map.insert(
155            key,
156            CacheEntry {
157                value,
158                inserted_at: now,
159                last_accessed_at: now,
160                seq,
161            },
162        );
163    }
164}
165
166/// Single-flight init-lock registry, keyed by key hash instead of the key
167/// itself so it is compiled once for every `<K, V>` cache in the binary. A
168/// hash collision only makes two distinct keys share one init lock — they
169/// serialize their initializers, and the double-checked `get` inside
170/// `get_with_slow` keeps the result correct — so the key never needs to be
171/// stored or cloned here.
172struct InitLocks {
173    /// Shared across cache clones so a key hashes identically everywhere.
174    hasher: RandomState,
175    map: AsyncMutex<HashMap<u64, Arc<AsyncMutex<()>>>>,
176}
177
178impl InitLocks {
179    fn new() -> Self {
180        Self {
181            hasher: RandomState::new(),
182            map: AsyncMutex::new(HashMap::new()),
183        }
184    }
185
186    fn hash_of<Q: Hash + ?Sized>(&self, key: &Q) -> u64 {
187        self.hasher.hash_one(key)
188    }
189
190    async fn acquire(&self, hash: u64) -> Arc<AsyncMutex<()>> {
191        let mut locks = self.map.lock().await;
192        locks
193            .entry(hash)
194            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
195            .clone()
196    }
197
198    /// Drop a single-flight init lock once no other caller is using it, so the
199    /// registry can't grow without bound across distinct keys (it is otherwise
200    /// only reclaimed by `run_pending_tasks`, which several hot `get_with`
201    /// caches never call). `strong_count <= 2` means only this caller's clone
202    /// and the map entry remain; the `ptr_eq` guard avoids dropping a newer
203    /// lock a racing caller may have inserted.
204    async fn reclaim(&self, hash: u64, init_mutex: &Arc<AsyncMutex<()>>) {
205        let mut locks = self.map.lock().await;
206        if Arc::strong_count(init_mutex) <= 2
207            && let Some(existing) = locks.get(&hash)
208            && Arc::ptr_eq(existing, init_mutex)
209        {
210            locks.remove(&hash);
211        }
212    }
213
214    /// Best-effort synchronous reclaim for cancellation paths: `try_lock` so it
215    /// can run inside `Drop`. Contention here only defers cleanup to the next
216    /// reclaim on this hash or to `run_pending_tasks`.
217    fn reclaim_now(&self, hash: u64, init_mutex: &Arc<AsyncMutex<()>>) {
218        if let Some(mut locks) = self.map.try_lock()
219            && Arc::strong_count(init_mutex) <= 2
220            && let Some(existing) = locks.get(&hash)
221            && Arc::ptr_eq(existing, init_mutex)
222        {
223            locks.remove(&hash);
224        }
225    }
226
227    async fn retain_active(&self) {
228        let mut locks = self.map.lock().await;
229        locks.retain(|_, v| Arc::strong_count(v) > 1);
230    }
231}
232
233/// Reclaims a single-flight init lock if `get_with_slow` is cancelled mid-init
234/// (caller timeout/abort), so cancelled fills can't grow the registry until
235/// `run_pending_tasks`. The success path disarms it and runs the awaited
236/// (guaranteed) reclaim instead.
237struct InitLockCleanup<'a> {
238    registry: &'a InitLocks,
239    hash: u64,
240    lock: Option<Arc<AsyncMutex<()>>>,
241}
242
243impl InitLockCleanup<'_> {
244    fn disarm(&mut self) -> Arc<AsyncMutex<()>> {
245        self.lock.take().expect("init-lock cleanup disarmed twice")
246    }
247}
248
249impl Drop for InitLockCleanup<'_> {
250    fn drop(&mut self) {
251        if let Some(lock) = self.lock.take() {
252            self.registry.reclaim_now(self.hash, &lock);
253        }
254    }
255}
256
257// -- Builder --
258
259pub struct PortableCacheBuilder<K, V> {
260    max_capacity: Option<u64>,
261    ttl: Option<Duration>,
262    tti: Option<Duration>,
263    evict_guard: Option<fn(&V) -> bool>,
264    _marker: std::marker::PhantomData<fn(K, V)>,
265}
266
267impl<K, V> PortableCacheBuilder<K, V>
268where
269    K: Hash + Eq + Clone + Send + Sync + 'static,
270    V: Clone + Send + Sync + 'static,
271{
272    fn new() -> Self {
273        Self {
274            max_capacity: None,
275            ttl: None,
276            tti: None,
277            evict_guard: None,
278            _marker: std::marker::PhantomData,
279        }
280    }
281
282    /// Protect entries a live task still holds from capacity eviction: `guard`
283    /// returns `true` when a value is safe to evict. For an `Arc<Mutex>` lock cache,
284    /// pass `|v| Arc::strong_count(v) <= 1`, so an entry held elsewhere is never
285    /// FIFO-evicted and re-minted (which would let two writers race the resource).
286    pub fn evict_guard(mut self, guard: fn(&V) -> bool) -> Self {
287        self.evict_guard = Some(guard);
288        self
289    }
290
291    pub fn max_capacity(mut self, cap: u64) -> Self {
292        self.max_capacity = Some(cap);
293        self
294    }
295
296    pub fn time_to_live(mut self, ttl: Duration) -> Self {
297        self.ttl = Some(ttl);
298        self
299    }
300
301    pub fn time_to_idle(mut self, tti: Duration) -> Self {
302        self.tti = Some(tti);
303        self
304    }
305
306    pub fn build(self) -> PortableCache<K, V> {
307        PortableCache {
308            inner: Arc::new(RwLock::new(CacheInner::new())),
309            init_locks: Arc::new(InitLocks::new()),
310            max_capacity: self.max_capacity,
311            ttl: self.ttl,
312            tti: self.tti,
313            evict_guard: self.evict_guard,
314        }
315    }
316}
317
318// -- PortableCache impl --
319
320impl<K, V> PortableCache<K, V>
321where
322    K: Hash + Eq + Clone + Send + Sync + 'static,
323    V: Clone + Send + Sync + 'static,
324{
325    pub fn builder() -> PortableCacheBuilder<K, V> {
326        PortableCacheBuilder::new()
327    }
328
329    /// Read the monotonic clock only for caches that can expire entries.
330    /// Non-expiring caches use a stable sentinel because their timestamps are
331    /// never observed, avoiding unnecessary clock reads on every operation.
332    #[inline]
333    fn entry_time(&self) -> Instant {
334        if self.ttl.is_some() || self.tti.is_some() {
335            Instant::now()
336        } else {
337            Instant::ZERO
338        }
339    }
340
341    fn is_expired(&self, entry: &CacheEntry<V>, now: Instant) -> bool {
342        if let Some(ttl) = self.ttl
343            && now.saturating_duration_since(entry.inserted_at) >= ttl
344        {
345            return true;
346        }
347        if let Some(tti) = self.tti
348            && now.saturating_duration_since(entry.last_accessed_at) >= tti
349        {
350            return true;
351        }
352        false
353    }
354
355    fn find_key<Q>(inner: &CacheInner<K, V>, key: &Q) -> Option<K>
356    where
357        K: Borrow<Q>,
358        Q: Hash + Eq + ?Sized,
359    {
360        inner.map.get_key_value(key).map(|(k, _)| k.clone())
361    }
362
363    pub async fn get<Q>(&self, key: &Q) -> Option<V>
364    where
365        K: Borrow<Q>,
366        Q: Hash + Eq + ?Sized,
367    {
368        // Fast path (no TTI): read lock only, no write needed.
369        if self.tti.is_none() {
370            let guard = self.inner.read().await;
371            let entry = guard.map.get(key)?;
372            // Read the clock after the lookup: a miss has no timestamp to
373            // compare, and lookups that miss are a large share of the calls
374            // (every negative registry probe, every warm-up).
375            let now = self.entry_time();
376            if self.is_expired(entry, now) {
377                let owned_key = Self::find_key(&guard, key)?;
378                drop(guard);
379                let mut wguard = self.inner.write().await;
380                if let Some(e) = wguard.map.get(key)
381                    && self.is_expired(e, now)
382                {
383                    wguard.remove_key(&owned_key);
384                }
385                return None;
386            }
387            return Some(entry.value.clone());
388        }
389
390        // TTI path: write lock to update last_accessed_at.
391        let mut guard = self.inner.write().await;
392        let entry = guard.map.get_mut(key)?;
393        let now = self.entry_time();
394        if self.is_expired(entry, now) {
395            let owned_key = Self::find_key(&guard, key)?;
396            guard.remove_key(&owned_key);
397            return None;
398        }
399        entry.last_accessed_at = now;
400        Some(entry.value.clone())
401    }
402
403    pub async fn insert(&self, key: K, value: V) {
404        let now = self.entry_time();
405        let mut guard = self.inner.write().await;
406
407        if let Some(entry) = guard.map.get_mut(&key) {
408            entry.value = value;
409            entry.inserted_at = now;
410            entry.last_accessed_at = now;
411            return;
412        }
413
414        if self.max_capacity == Some(0) {
415            return;
416        }
417
418        guard.insert_new(key, value, now, self.max_capacity, self.evict_guard);
419    }
420
421    /// Atomically derive and optionally store a value from the current entry.
422    ///
423    /// The closure runs synchronously under the cache's existing write lock.
424    /// Returning `None` leaves an existing value unchanged and keeps a missing
425    /// key absent. The key is cloned only when the operation inserts a new
426    /// entry.
427    pub async fn upsert_with_by_ref<Q, R>(
428        &self,
429        key: &Q,
430        update: impl FnOnce(Option<&V>) -> (Option<V>, R),
431    ) -> R
432    where
433        K: Borrow<Q>,
434        Q: ToOwned<Owned = K> + Hash + Eq + ?Sized,
435    {
436        let now = self.entry_time();
437        let mut guard = self.inner.write().await;
438
439        if guard
440            .map
441            .get(key)
442            .is_some_and(|entry| self.is_expired(entry, now))
443            && let Some(owned_key) = Self::find_key(&guard, key)
444        {
445            guard.remove_key(&owned_key);
446        }
447
448        let (next, result) = update(guard.map.get(key).map(|entry| &entry.value));
449        let Some(next) = next else {
450            return result;
451        };
452
453        if let Some(entry) = guard.map.get_mut(key) {
454            entry.value = next;
455            entry.inserted_at = now;
456            entry.last_accessed_at = now;
457        } else if self.max_capacity != Some(0) {
458            guard.insert_new(
459                key.to_owned(),
460                next,
461                now,
462                self.max_capacity,
463                self.evict_guard,
464            );
465        }
466
467        result
468    }
469
470    /// Insert and return a clone of the value in one write lock.
471    async fn insert_and_return(&self, key: K, value: V) -> V {
472        let now = self.entry_time();
473        let mut guard = self.inner.write().await;
474
475        if let Some(entry) = guard.map.get_mut(&key) {
476            let ret = value.clone();
477            entry.value = value;
478            entry.inserted_at = now;
479            entry.last_accessed_at = now;
480            return ret;
481        }
482
483        if self.max_capacity == Some(0) {
484            return value;
485        }
486
487        let ret = value.clone();
488        guard.insert_new(key, value, now, self.max_capacity, self.evict_guard);
489        ret
490    }
491
492    pub async fn remove<Q>(&self, key: &Q) -> Option<V>
493    where
494        K: Borrow<Q>,
495        Q: Hash + Eq + ?Sized,
496    {
497        let mut guard = self.inner.write().await;
498        let owned_key = Self::find_key(&guard, key)?;
499        let entry = guard.remove_key(&owned_key)?;
500        // Nothing to date until an entry is actually in hand.
501        let now = self.entry_time();
502        if self.is_expired(&entry, now) {
503            None
504        } else {
505            Some(entry.value)
506        }
507    }
508
509    pub async fn invalidate<Q>(&self, key: &Q)
510    where
511        K: Borrow<Q>,
512        Q: Hash + Eq + ?Sized,
513    {
514        let mut guard = self.inner.write().await;
515        if let Some(owned_key) = Self::find_key(&guard, key) {
516            guard.remove_key(&owned_key);
517        }
518    }
519
520    /// Reliably remove all entries, awaiting the write lock. Prefer this in
521    /// async contexts over [`invalidate_all`](Self::invalidate_all), whose
522    /// best-effort sync spin can skip the clear under sustained write
523    /// contention.
524    pub async fn clear(&self) {
525        let mut guard = self.inner.write().await;
526        guard.map.clear();
527        guard.order.clear();
528    }
529
530    /// Sync invalidate. Spins briefly if the lock is held; kept for moka API
531    /// parity. In async contexts prefer [`clear`](Self::clear), which can't
532    /// silently skip the clear.
533    pub fn invalidate_all(&self) {
534        for _ in 0..64 {
535            if let Some(mut guard) = self.inner.try_write() {
536                guard.map.clear();
537                guard.order.clear();
538                return;
539            }
540            std::hint::spin_loop();
541        }
542        log::warn!("PortableCache::invalidate_all: could not acquire write lock after retries");
543    }
544
545    pub fn entry_count(&self) -> u64 {
546        self.inner
547            .try_read()
548            .map(|g| g.map.len() as u64)
549            .unwrap_or(0)
550    }
551
552    pub(crate) async fn capacity_stats(&self) -> CapacityStats {
553        let guard = self.inner.read().await;
554        CapacityStats {
555            entries: guard.map.len() as u64,
556            evictions: guard.capacity_evictions,
557            eviction_blocks: guard.capacity_eviction_blocks,
558        }
559    }
560
561    /// Reliable awaited snapshot of `(Arc<K>, V)` pairs. Prefer this over
562    /// [`iter`](Self::iter) in async contexts: `iter` is best-effort (a
563    /// `try_read` spin that yields an empty snapshot under write contention),
564    /// which would silently skip entries an invalidation pass must see.
565    pub async fn snapshot_entries(&self) -> Vec<(Arc<K>, V)> {
566        let guard = self.inner.read().await;
567        Self::snapshot(&guard)
568    }
569
570    /// Reliable awaited fold over `(&K, &V)`. Unlike the snapshot walks this
571    /// clones nothing — memory reports must not themselves allocate in
572    /// proportion to the cache — and unlike [`iter`](Self::iter) it cannot
573    /// degrade to an empty walk under write contention.
574    pub async fn fold_entries<A>(&self, init: A, mut f: impl FnMut(A, &K, &V) -> A) -> A {
575        let guard = self.inner.read().await;
576        guard
577            .map
578            .iter()
579            .fold(init, |acc, (k, e)| f(acc, k, &e.value))
580    }
581
582    /// Entry count plus estimated retained bytes, summing `per_entry` under a
583    /// single awaited read guard so the pair is mutually consistent (and never
584    /// the empty best-effort snapshot [`iter`](Self::iter) can degrade to).
585    pub async fn memory_stats(
586        &self,
587        mut per_entry: impl FnMut(&K, &V) -> usize,
588    ) -> wacore::stats::CollectionStats {
589        let guard = self.inner.read().await;
590        let bytes: usize = guard.map.iter().map(|(k, e)| per_entry(k, &e.value)).sum();
591        wacore::stats::CollectionStats::new(guard.map.len() as u64, bytes as u64)
592    }
593
594    /// Eager snapshot iterator over `(Arc<K>, V)`: snapshot, not lazy. Includes
595    /// expired-but-not-yet-evicted entries (consistent with `entry_count`).
596    /// Best-effort (`try_read` spin); use [`snapshot_entries`](Self::snapshot_entries)
597    /// when missing an entry would be a correctness bug. Caller must not `.await`
598    /// with the writer guard held from the same task — would deadlock on
599    /// single-threaded runtimes.
600    pub fn iter(&self) -> std::vec::IntoIter<(Arc<K>, V)> {
601        for _ in 0..1024 {
602            if let Some(guard) = self.inner.try_read() {
603                return Self::snapshot(&guard).into_iter();
604            }
605            std::hint::spin_loop();
606        }
607        log::warn!(
608            "PortableCache::iter: could not acquire read lock after retries; \
609             returning empty snapshot"
610        );
611        Vec::new().into_iter()
612    }
613
614    fn snapshot(guard: &CacheInner<K, V>) -> Vec<(Arc<K>, V)> {
615        guard
616            .map
617            .iter()
618            .map(|(k, e)| (Arc::new(k.clone()), e.value.clone()))
619            .collect()
620    }
621
622    /// Get or insert (single-flight). Takes key by value.
623    ///
624    /// The initializer is boxed only on cache miss — a hit returns without
625    /// allocating. The boxing keeps the slow path monomorphic per `<K, V>`
626    /// instead of per call-site future type. A racer that loses the
627    /// double-check inside `get_with_slow` pays one
628    /// spare box; deferring the box past the double-check would drag the
629    /// future type parameter back into the slow path, re-stamping it per
630    /// call site.
631    #[inline]
632    pub async fn get_with<F>(&self, key: K, init: F) -> V
633    where
634        F: Future<Output = V> + MaybeSend,
635    {
636        if let Some(v) = self.get(&key).await {
637            return v;
638        }
639        self.get_with_slow(key, Box::pin(init)).await
640    }
641
642    /// Get or insert (single-flight). Takes key by reference — only allocates
643    /// the owned key (and the boxed initializer) on cache miss.
644    #[inline]
645    pub async fn get_with_by_ref<Q, F>(&self, key: &Q, init: F) -> V
646    where
647        K: Borrow<Q>,
648        Q: ToOwned<Owned = K> + Hash + Eq + ?Sized,
649        F: Future<Output = V> + MaybeSend,
650    {
651        if let Some(v) = self.get(key).await {
652            return v;
653        }
654        self.get_with_slow(key.to_owned(), Box::pin(init)).await
655    }
656
657    /// Miss path shared by [`get_with`](Self::get_with) and
658    /// [`get_with_by_ref`](Self::get_with_by_ref): single-flight init under the
659    /// per-key lock, with a double-checked `get` so a collided or racing key
660    /// still resolves to the first inserted value.
661    async fn get_with_slow(&self, key: K, init: BoxFuture<'_, V>) -> V {
662        let hash = self.init_locks.hash_of(&key);
663        // The cleanup guard holds the sole long-lived Arc so its Drop sees an
664        // exact strong count if this future is cancelled at any await below.
665        let mut cleanup = InitLockCleanup {
666            registry: &self.init_locks,
667            hash,
668            lock: Some(self.init_locks.acquire(hash).await),
669        };
670
671        let value = {
672            let _init_guard = cleanup
673                .lock
674                .as_ref()
675                .expect("init-lock cleanup still armed")
676                .lock()
677                .await;
678            // Double-check after acquiring the per-key lock.
679            if let Some(v) = self.get(&key).await {
680                v
681            } else {
682                let value = init.await;
683                self.insert_and_return(key, value).await
684            }
685        };
686
687        let init_mutex = cleanup.disarm();
688        drop(cleanup);
689        self.init_locks.reclaim(hash, &init_mutex).await;
690        value
691    }
692
693    /// Evict expired entries and clean up unused init locks.
694    pub async fn run_pending_tasks(&self) {
695        let now = self.entry_time();
696        let mut guard = self.inner.write().await;
697
698        guard.map.retain(|_, entry| !self.is_expired(entry, now));
699
700        // Drop order entries whose keys were just expired out of the map.
701        // Borrow fields separately to satisfy the borrow checker.
702        let CacheInner { map, order, .. } = &mut *guard;
703        order.retain(|_, k| map.contains_key(k));
704
705        drop(guard);
706
707        // Clean up init locks not actively held.
708        self.init_locks.retain_active().await;
709    }
710}
711
712impl<K, V> Clone for PortableCache<K, V> {
713    fn clone(&self) -> Self {
714        Self {
715            inner: Arc::clone(&self.inner),
716            init_locks: Arc::clone(&self.init_locks),
717            max_capacity: self.max_capacity,
718            ttl: self.ttl,
719            tti: self.tti,
720            evict_guard: self.evict_guard,
721        }
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use std::sync::atomic::{AtomicUsize, Ordering};
729
730    fn build_cache<K, V>() -> PortableCache<K, V>
731    where
732        K: Hash + Eq + Clone + Send + Sync + 'static,
733        V: Clone + Send + Sync + 'static,
734    {
735        PortableCache::builder().max_capacity(100).build()
736    }
737
738    #[tokio::test]
739    async fn test_basic_insert_and_get() {
740        let cache = build_cache::<String, String>();
741
742        assert!(cache.get("key1").await.is_none());
743
744        cache.insert("key1".to_string(), "value1".to_string()).await;
745        assert_eq!(cache.get("key1").await, Some("value1".to_string()));
746    }
747
748    #[tokio::test]
749    async fn capacity_only_cache_uses_clock_free_timestamps() {
750        let cache = build_cache::<String, String>();
751        assert_eq!(cache.entry_time(), Instant::ZERO);
752
753        cache.insert("key".into(), "value".into()).await;
754        assert_eq!(cache.get("key").await.as_deref(), Some("value"));
755
756        let guard = cache.inner.read().await;
757        let entry = guard.map.get("key").expect("inserted cache entry");
758        assert_eq!(entry.inserted_at, Instant::ZERO);
759        assert_eq!(entry.last_accessed_at, Instant::ZERO);
760    }
761
762    #[tokio::test]
763    async fn test_update_existing_key() {
764        let cache = build_cache::<String, String>();
765
766        cache.insert("key1".to_string(), "v1".to_string()).await;
767        cache.insert("key1".to_string(), "v2".to_string()).await;
768        assert_eq!(cache.get("key1").await, Some("v2".to_string()));
769        assert_eq!(cache.entry_count(), 1);
770    }
771
772    #[tokio::test]
773    async fn upsert_with_by_ref_serializes_read_modify_write() {
774        let cache = Arc::new(build_cache::<String, u32>());
775        let mut tasks = Vec::new();
776        for _ in 0..32 {
777            let cache = Arc::clone(&cache);
778            tasks.push(tokio::spawn(async move {
779                cache
780                    .upsert_with_by_ref("counter", |current| {
781                        let next = current.copied().unwrap_or_default() + 1;
782                        (Some(next), next)
783                    })
784                    .await
785            }));
786        }
787
788        let mut results = Vec::with_capacity(tasks.len());
789        for task in tasks {
790            results.push(task.await.unwrap());
791        }
792        results.sort_unstable();
793
794        assert_eq!(results, (1..=32).collect::<Vec<_>>());
795        assert_eq!(cache.get("counter").await, Some(32));
796
797        let unchanged = cache
798            .upsert_with_by_ref("counter", |current| (None, current.copied()))
799            .await;
800        assert_eq!(unchanged, Some(32));
801        assert_eq!(cache.get("counter").await, Some(32));
802    }
803
804    #[tokio::test]
805    async fn test_capacity_eviction() {
806        let cache: PortableCache<String, u32> = PortableCache::builder().max_capacity(3).build();
807
808        cache.insert("a".into(), 1).await;
809        cache.insert("b".into(), 2).await;
810        cache.insert("c".into(), 3).await;
811        assert_eq!(cache.entry_count(), 3);
812
813        cache.insert("d".into(), 4).await;
814        assert_eq!(cache.entry_count(), 3);
815        assert!(cache.get("a").await.is_none());
816        assert_eq!(cache.get("b").await, Some(2));
817        assert_eq!(cache.get("d").await, Some(4));
818        assert_eq!(
819            cache.capacity_stats().await,
820            CapacityStats {
821                entries: 3,
822                evictions: 1,
823                eviction_blocks: 0,
824            }
825        );
826    }
827
828    #[tokio::test]
829    async fn evict_guard_protects_held_entries() {
830        type Lock = Arc<AsyncMutex<()>>;
831        let guarded: PortableCache<String, Lock> = PortableCache::builder()
832            .max_capacity(2)
833            .evict_guard(|m| Arc::strong_count(m) <= 1)
834            .build();
835
836        // Insert an entry and keep an external clone — a live task holding the lock.
837        let held: Lock = Arc::new(AsyncMutex::new(()));
838        guarded.insert("held".into(), held.clone()).await;
839
840        // Churn far past capacity with fresh, unheld entries.
841        for i in 0..5 {
842            guarded
843                .insert(format!("k{i}"), Arc::new(AsyncMutex::new(())))
844                .await;
845        }
846
847        // The held entry survives (protected) and is the SAME mutex instance, so a
848        // later lookup can't mint a duplicate that two writers would race.
849        let again = guarded
850            .get("held")
851            .await
852            .expect("held entry must not be FIFO-evicted");
853        assert!(Arc::ptr_eq(&held, &again), "same mutex instance preserved");
854
855        // Contrast: with no guard the identical churn FIFO-evicts the held entry.
856        let unguarded: PortableCache<String, Lock> =
857            PortableCache::builder().max_capacity(2).build();
858        unguarded.insert("held".into(), held.clone()).await;
859        for i in 0..5 {
860            unguarded
861                .insert(format!("k{i}"), Arc::new(AsyncMutex::new(())))
862                .await;
863        }
864        assert!(
865            unguarded.get("held").await.is_none(),
866            "an unguarded cache FIFO-evicts the held entry (the bug this guards)"
867        );
868    }
869
870    #[tokio::test]
871    async fn evict_guard_allows_temporary_over_capacity_when_all_held() {
872        type Lock = Arc<AsyncMutex<()>>;
873        let cache: PortableCache<String, Lock> = PortableCache::builder()
874            .max_capacity(2)
875            .evict_guard(|m| Arc::strong_count(m) <= 1)
876            .build();
877
878        // Hold every entry, then insert one more: with nothing evictable the cache
879        // grows past capacity rather than dropping a live lock.
880        let mut held = Vec::new();
881        for i in 0..3 {
882            let lock: Lock = Arc::new(AsyncMutex::new(()));
883            held.push(lock.clone());
884            cache.insert(format!("k{i}"), lock).await;
885        }
886        assert_eq!(
887            cache.entry_count(),
888            3,
889            "all entries held -> cache exceeds capacity instead of evicting a live lock"
890        );
891        assert_eq!(
892            cache.capacity_stats().await,
893            CapacityStats {
894                entries: 3,
895                evictions: 0,
896                eviction_blocks: 1,
897            }
898        );
899
900        // Drop the external refs; the next insert now evicts back down to capacity.
901        drop(held);
902        cache
903            .insert("fresh".into(), Arc::new(AsyncMutex::new(())))
904            .await;
905        assert_eq!(
906            cache.entry_count(),
907            2,
908            "once entries are released, eviction resumes down to capacity"
909        );
910        assert_eq!(
911            cache.capacity_stats().await,
912            CapacityStats {
913                entries: 2,
914                evictions: 2,
915                eviction_blocks: 1,
916            }
917        );
918    }
919
920    #[tokio::test]
921    async fn test_remove_then_eviction_preserves_fifo_order() {
922        // A removed key must leave the FIFO `order` consistent: eviction must skip
923        // it (no stale order entry) and still evict the genuinely-oldest survivor.
924        let cache: PortableCache<String, u32> = PortableCache::builder().max_capacity(3).build();
925        cache.insert("a".into(), 1).await;
926        cache.insert("b".into(), 2).await;
927        cache.insert("c".into(), 3).await;
928
929        // Remove the oldest, then fill back to capacity.
930        assert_eq!(cache.remove("a").await, Some(1));
931        cache.insert("d".into(), 4).await; // count = 3 (b, c, d), no eviction
932        assert_eq!(cache.entry_count(), 3);
933
934        // Next insert evicts the now-oldest survivor (b), not the removed "a".
935        cache.insert("e".into(), 5).await;
936        assert_eq!(cache.entry_count(), 3);
937        assert!(cache.get("b").await.is_none(), "b was the oldest survivor");
938        assert_eq!(cache.get("c").await, Some(3));
939        assert_eq!(cache.get("d").await, Some(4));
940        assert_eq!(cache.get("e").await, Some(5));
941    }
942
943    #[tokio::test]
944    async fn test_zero_capacity_disables_caching() {
945        let cache: PortableCache<String, u32> = PortableCache::builder().max_capacity(0).build();
946
947        cache.insert("a".into(), 1).await;
948        assert!(cache.get("a").await.is_none());
949        assert_eq!(cache.entry_count(), 0);
950    }
951
952    /// Expiry decided against a supplied instant, so the boundary (exactly at
953    /// the deadline, which counts as expired) is pinned without depending on
954    /// wall-clock timing.
955    #[test]
956    fn expiry_boundary_is_exact_under_a_controlled_clock() {
957        let ttl = Duration::from_secs(60);
958        let tti = Duration::from_secs(10);
959        let cache: PortableCache<String, u32> = PortableCache::builder()
960            .max_capacity(100)
961            .time_to_live(ttl)
962            .time_to_idle(tti)
963            .build();
964
965        let inserted = Instant::ZERO + Duration::from_secs(1_000);
966        let entry = CacheEntry {
967            value: 1,
968            inserted_at: inserted,
969            last_accessed_at: inserted,
970            seq: 0,
971        };
972
973        assert!(!cache.is_expired(&entry, inserted + tti - Duration::from_nanos(1)));
974        assert!(cache.is_expired(&entry, inserted + tti), "TTI is inclusive");
975
976        let idle_free: PortableCache<String, u32> = PortableCache::builder()
977            .max_capacity(100)
978            .time_to_live(ttl)
979            .build();
980        assert!(!idle_free.is_expired(&entry, inserted + ttl - Duration::from_nanos(1)));
981        assert!(
982            idle_free.is_expired(&entry, inserted + ttl),
983            "TTL is inclusive"
984        );
985    }
986
987    /// A lookup that finds nothing has no timestamp to compare, so it must not
988    /// pay for one. Every negative registry probe goes through here.
989    #[tokio::test]
990    async fn a_miss_does_not_read_the_clock() {
991        use wacore::time::clock_reads;
992
993        for cache in [
994            PortableCache::<String, u32>::builder()
995                .max_capacity(100)
996                .time_to_live(Duration::from_secs(60))
997                .build(),
998            PortableCache::<String, u32>::builder()
999                .max_capacity(100)
1000                .time_to_idle(Duration::from_secs(60))
1001                .build(),
1002        ] {
1003            cache.insert("present".into(), 1).await;
1004
1005            let base = clock_reads::snapshot();
1006            assert!(cache.get("absent").await.is_none());
1007            assert!(cache.remove("absent").await.is_none());
1008            assert_eq!(
1009                clock_reads::since(base).monotonic,
1010                0,
1011                "a miss must not read the monotonic clock"
1012            );
1013
1014            let hit = clock_reads::snapshot();
1015            assert_eq!(cache.get("present").await, Some(1));
1016            assert_eq!(
1017                clock_reads::since(hit).monotonic,
1018                1,
1019                "a hit reads once, to decide expiry"
1020            );
1021        }
1022    }
1023
1024    #[tokio::test]
1025    async fn test_ttl_expiry() {
1026        let cache: PortableCache<String, String> = PortableCache::builder()
1027            .max_capacity(100)
1028            .time_to_live(Duration::from_millis(50))
1029            .build();
1030
1031        cache.insert("key1".to_string(), "value1".to_string()).await;
1032        assert_eq!(cache.get("key1").await, Some("value1".to_string()));
1033
1034        tokio::time::sleep(Duration::from_millis(60)).await;
1035        assert!(cache.get("key1").await.is_none());
1036    }
1037
1038    #[tokio::test]
1039    async fn test_invalidate() {
1040        let cache = build_cache::<String, String>();
1041
1042        cache.insert("key1".to_string(), "value1".to_string()).await;
1043        cache.invalidate("key1").await;
1044        assert!(cache.get("key1").await.is_none());
1045    }
1046
1047    #[tokio::test]
1048    async fn test_invalidate_all() {
1049        let cache = build_cache::<String, u32>();
1050
1051        cache.insert("a".into(), 1).await;
1052        cache.insert("b".into(), 2).await;
1053        cache.invalidate_all();
1054        assert_eq!(cache.entry_count(), 0);
1055        assert!(cache.get("a").await.is_none());
1056    }
1057
1058    #[tokio::test]
1059    async fn test_remove() {
1060        let cache = build_cache::<String, String>();
1061
1062        cache.insert("key1".to_string(), "v1".to_string()).await;
1063        let removed = cache.remove("key1").await;
1064        assert_eq!(removed, Some("v1".to_string()));
1065        assert!(cache.get("key1").await.is_none());
1066    }
1067
1068    #[tokio::test]
1069    async fn test_iter_snapshot_includes_expired() {
1070        // Snapshot semantics: iter returns all map entries, including ones
1071        // past TTL that haven't been evicted yet. Pin this so the call site
1072        // (invalidate_entries_for_device) keeps idempotent invalidation.
1073        let cache: PortableCache<String, u32> = PortableCache::builder()
1074            .max_capacity(100)
1075            .time_to_live(Duration::from_millis(10))
1076            .build();
1077        cache.insert("a".to_string(), 1).await;
1078        cache.insert("b".to_string(), 2).await;
1079        tokio::time::sleep(Duration::from_millis(20)).await;
1080
1081        let mut keys: Vec<String> = cache.iter().map(|(k, _)| k.as_ref().clone()).collect();
1082        keys.sort();
1083        assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
1084    }
1085
1086    #[tokio::test]
1087    async fn test_get_with_basic() {
1088        let cache = build_cache::<String, u32>();
1089
1090        let v = cache.get_with("key1".to_string(), async { 42 }).await;
1091        assert_eq!(v, 42);
1092
1093        let v = cache.get_with("key1".to_string(), async { 99 }).await;
1094        assert_eq!(v, 42);
1095    }
1096
1097    #[tokio::test]
1098    async fn test_get_with_by_ref_basic() {
1099        let cache = build_cache::<String, u32>();
1100        let key = "key1".to_string();
1101
1102        let v = cache.get_with_by_ref(&key, async { 42 }).await;
1103        assert_eq!(v, 42);
1104
1105        let v = cache.get_with_by_ref(&key, async { 99 }).await;
1106        assert_eq!(v, 42);
1107    }
1108
1109    #[tokio::test]
1110    async fn test_get_with_single_flight() {
1111        let cache: PortableCache<String, Arc<AtomicUsize>> =
1112            PortableCache::builder().max_capacity(100).build();
1113
1114        let init_count = Arc::new(AtomicUsize::new(0));
1115        let num_tasks = 20;
1116        let barrier = Arc::new(tokio::sync::Barrier::new(num_tasks));
1117
1118        let mut handles = Vec::new();
1119        for _ in 0..num_tasks {
1120            let cache = cache.clone();
1121            let init_count = init_count.clone();
1122            let barrier = barrier.clone();
1123            handles.push(tokio::spawn(async move {
1124                barrier.wait().await;
1125                cache
1126                    .get_with("shared_key".to_string(), async {
1127                        init_count.fetch_add(1, Ordering::SeqCst);
1128                        tokio::task::yield_now().await;
1129                        Arc::new(AtomicUsize::new(0))
1130                    })
1131                    .await
1132            }));
1133        }
1134
1135        let mut results = Vec::new();
1136        for h in handles {
1137            results.push(h.await.unwrap());
1138        }
1139
1140        assert_eq!(init_count.load(Ordering::SeqCst), 1);
1141        let first = &results[0];
1142        for r in &results[1..] {
1143            assert!(Arc::ptr_eq(first, r));
1144        }
1145    }
1146
1147    #[tokio::test]
1148    async fn test_get_with_by_ref_single_flight() {
1149        let cache: PortableCache<String, Arc<AtomicUsize>> =
1150            PortableCache::builder().max_capacity(100).build();
1151
1152        let init_count = Arc::new(AtomicUsize::new(0));
1153        let num_tasks = 20;
1154        let barrier = Arc::new(tokio::sync::Barrier::new(num_tasks));
1155
1156        let mut handles = Vec::new();
1157        for _ in 0..num_tasks {
1158            let cache = cache.clone();
1159            let init_count = init_count.clone();
1160            let barrier = barrier.clone();
1161            handles.push(tokio::spawn(async move {
1162                barrier.wait().await;
1163                let key = "shared_key".to_string();
1164                cache
1165                    .get_with_by_ref(&key, async {
1166                        init_count.fetch_add(1, Ordering::SeqCst);
1167                        tokio::task::yield_now().await;
1168                        Arc::new(AtomicUsize::new(0))
1169                    })
1170                    .await
1171            }));
1172        }
1173
1174        let mut results = Vec::new();
1175        for h in handles {
1176            results.push(h.await.unwrap());
1177        }
1178
1179        assert_eq!(init_count.load(Ordering::SeqCst), 1);
1180        let first = &results[0];
1181        for r in &results[1..] {
1182            assert!(Arc::ptr_eq(first, r));
1183        }
1184    }
1185
1186    #[tokio::test]
1187    async fn test_get_with_different_keys_parallel() {
1188        let cache = build_cache::<String, u32>();
1189
1190        let init_count = Arc::new(AtomicUsize::new(0));
1191        let mut handles = Vec::new();
1192        for i in 0..10 {
1193            let cache = cache.clone();
1194            let init_count = init_count.clone();
1195            handles.push(tokio::spawn(async move {
1196                cache
1197                    .get_with(format!("key_{i}"), async {
1198                        init_count.fetch_add(1, Ordering::SeqCst);
1199                        i as u32
1200                    })
1201                    .await
1202            }));
1203        }
1204
1205        for (i, h) in handles.into_iter().enumerate() {
1206            assert_eq!(h.await.unwrap(), i as u32);
1207        }
1208        assert_eq!(init_count.load(Ordering::SeqCst), 10);
1209    }
1210
1211    #[tokio::test]
1212    async fn test_session_lock_pattern() {
1213        let cache: PortableCache<String, Arc<async_lock::Mutex<()>>> =
1214            PortableCache::builder().max_capacity(100).build();
1215
1216        let counter = Arc::new(AtomicUsize::new(0));
1217        let num_tasks = 50;
1218        let barrier = Arc::new(tokio::sync::Barrier::new(num_tasks));
1219
1220        let mut handles = Vec::new();
1221        for _ in 0..num_tasks {
1222            let cache = cache.clone();
1223            let counter = counter.clone();
1224            let barrier = barrier.clone();
1225            handles.push(tokio::spawn(async move {
1226                barrier.wait().await;
1227                let mutex = cache
1228                    .get_with("sender_123".to_string(), async {
1229                        Arc::new(async_lock::Mutex::new(()))
1230                    })
1231                    .await;
1232                let _guard = mutex.lock().await;
1233                let val = counter.load(Ordering::SeqCst);
1234                tokio::task::yield_now().await;
1235                counter.store(val + 1, Ordering::SeqCst);
1236            }));
1237        }
1238
1239        for h in handles {
1240            h.await.unwrap();
1241        }
1242
1243        assert_eq!(counter.load(Ordering::SeqCst), num_tasks);
1244    }
1245
1246    #[tokio::test]
1247    async fn test_run_pending_tasks_cleans_expired() {
1248        let cache: PortableCache<String, u32> = PortableCache::builder()
1249            .max_capacity(100)
1250            .time_to_live(Duration::from_millis(50))
1251            .build();
1252
1253        cache.insert("a".into(), 1).await;
1254        cache.insert("b".into(), 2).await;
1255        assert_eq!(cache.entry_count(), 2);
1256
1257        tokio::time::sleep(Duration::from_millis(60)).await;
1258        cache.run_pending_tasks().await;
1259        assert_eq!(cache.entry_count(), 0);
1260    }
1261
1262    #[tokio::test]
1263    async fn test_get_with_reclaims_init_lock_eagerly() {
1264        // A completed single-flight `get_with` must not leave its per-key init
1265        // lock behind — otherwise high-cardinality caches (session locks, chat
1266        // lanes, dedup) that never call run_pending_tasks leak one lock per key.
1267        let cache: PortableCache<String, u32> = PortableCache::builder().max_capacity(100).build();
1268
1269        let _ = cache.get_with("key1".to_string(), async { 1 }).await;
1270        let _ = cache.get_with_by_ref("key2", async { 2 }).await;
1271
1272        let locks = cache.init_locks.map.lock().await;
1273        assert!(
1274            locks.is_empty(),
1275            "init locks must be reclaimed after get_with"
1276        );
1277    }
1278
1279    #[tokio::test]
1280    async fn cancelled_get_with_reclaims_init_lock() {
1281        // A get_with whose caller is aborted mid-init must not leave its
1282        // per-key init lock behind: hot caches never call run_pending_tasks.
1283        let cache = build_cache::<String, u32>();
1284        let task = tokio::spawn({
1285            let cache = cache.clone();
1286            async move {
1287                cache
1288                    .get_with("stuck".to_string(), std::future::pending::<u32>())
1289                    .await
1290            }
1291        });
1292
1293        // Poll (bounded) until the in-flight init registers its lock.
1294        let mut registered = false;
1295        for _ in 0..400 {
1296            if !cache.init_locks.map.lock().await.is_empty() {
1297                registered = true;
1298                break;
1299            }
1300            tokio::time::sleep(Duration::from_millis(5)).await;
1301        }
1302        assert!(registered, "in-flight get_with never registered its lock");
1303
1304        task.abort();
1305        let _ = task.await;
1306
1307        // Poll (bounded): the cleanup guard reclaims on cancellation, without
1308        // any run_pending_tasks call.
1309        let mut reclaimed = false;
1310        for _ in 0..400 {
1311            if cache.init_locks.map.lock().await.is_empty() {
1312                reclaimed = true;
1313                break;
1314            }
1315            tokio::time::sleep(Duration::from_millis(5)).await;
1316        }
1317        assert!(reclaimed, "cancelled get_with leaked its init lock");
1318    }
1319
1320    #[tokio::test]
1321    async fn init_locks_collision_shares_one_lock() {
1322        // Two keys that hash to the same slot must share the lock (they
1323        // serialize) and both resolve correctly through the double-checked get.
1324        let registry = InitLocks::new();
1325        let first = registry.acquire(42).await;
1326        let second = registry.acquire(42).await;
1327        assert!(
1328            Arc::ptr_eq(&first, &second),
1329            "same hash must yield the same init lock"
1330        );
1331
1332        // While another caller still holds a clone, reclaim must keep the entry.
1333        registry.reclaim(42, &first).await;
1334        assert!(
1335            registry.map.lock().await.contains_key(&42),
1336            "reclaim must not drop a lock another caller still holds"
1337        );
1338
1339        // Once the other caller is done, the entry is removed.
1340        drop(second);
1341        registry.reclaim(42, &first).await;
1342        assert!(
1343            registry.map.lock().await.is_empty(),
1344            "last reclaim must drop the registry entry"
1345        );
1346    }
1347
1348    /// Key whose hash is a constant, so any two instances collide in the
1349    /// hash-keyed init-lock registry while remaining distinct map keys.
1350    #[derive(Clone, PartialEq, Eq, Debug)]
1351    struct CollidingKey(&'static str);
1352
1353    impl Hash for CollidingKey {
1354        fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1355            state.write_u64(0);
1356        }
1357    }
1358
1359    #[tokio::test]
1360    async fn colliding_keys_keep_distinct_values() {
1361        let cache: PortableCache<CollidingKey, u32> =
1362            PortableCache::builder().max_capacity(16).build();
1363        let (a, b) = (CollidingKey("a"), CollidingKey("b"));
1364        assert_eq!(
1365            cache.init_locks.hash_of(&a),
1366            cache.init_locks.hash_of(&b),
1367            "test premise: both keys must share one init-lock slot"
1368        );
1369
1370        // Rendezvous BEFORE get_with: colliding keys share one init lock, so
1371        // their initializers serialize and must never wait on each other.
1372        let barrier = Arc::new(tokio::sync::Barrier::new(2));
1373        let mut tasks = Vec::new();
1374        for (key, value) in [(a.clone(), 1u32), (b.clone(), 2u32)] {
1375            let cache = cache.clone();
1376            let barrier = barrier.clone();
1377            tasks.push(tokio::spawn(async move {
1378                barrier.wait().await;
1379                cache
1380                    .get_with(key, async {
1381                        tokio::task::yield_now().await;
1382                        value
1383                    })
1384                    .await
1385            }));
1386        }
1387        let mut results = Vec::new();
1388        for task in tasks {
1389            results.push(task.await.unwrap());
1390        }
1391        assert_eq!(results, vec![1, 2], "each key must get its own init value");
1392        assert_eq!(cache.get(&a).await, Some(1));
1393        assert_eq!(cache.get(&b).await, Some(2));
1394    }
1395
1396    #[tokio::test]
1397    async fn get_with_distinct_keys_share_registry_correctly() {
1398        // Same value type, distinct keys: each key keeps its own value even
1399        // though the init-lock registry is keyed by hash rather than by key.
1400        let cache = build_cache::<String, u32>();
1401        let a = cache.get_with("a".to_string(), async { 1 }).await;
1402        let b = cache.get_with("b".to_string(), async { 2 }).await;
1403        assert_eq!((a, b), (1, 2));
1404        assert_eq!(cache.get("a").await, Some(1));
1405        assert_eq!(cache.get("b").await, Some(2));
1406    }
1407}