lruttl/
lib.rs

1use crate::metrics::*;
2use dashmap::DashMap;
3use kumo_prometheus::prometheus::{Histogram, IntCounter, IntGauge};
4use kumo_server_memory::subscribe_to_memory_status_changes_async;
5pub use linkme;
6use parking_lot::Mutex;
7pub use pastey as paste;
8use scopeguard::defer;
9use std::borrow::Borrow;
10use std::collections::HashMap;
11use std::fmt::Debug;
12use std::future::Future;
13use std::hash::Hash;
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::sync::{Arc, LazyLock, Weak};
16use tokio::sync::{OwnedSemaphorePermit, Semaphore};
17use tokio::time::{timeout, timeout_at, Duration, Instant};
18
19mod metrics;
20
21static CACHES: LazyLock<Mutex<Vec<Weak<dyn CachePurger + Send + Sync>>>> =
22    LazyLock::new(Mutex::default);
23
24struct Inner<K: Clone + Hash + Eq + Debug, V: Clone + Send + Sync + Debug> {
25    name: String,
26    tick: AtomicUsize,
27    capacity: AtomicUsize,
28    allow_stale_reads: AtomicBool,
29    cache: DashMap<K, Item<V>>,
30    lru_samples: AtomicUsize,
31    sema_timeout_milliseconds: AtomicUsize,
32    lookup_counter: IntCounter,
33    evict_counter: IntCounter,
34    expire_counter: IntCounter,
35    hit_counter: IntCounter,
36    miss_counter: IntCounter,
37    populate_counter: IntCounter,
38    populate_histogram: Histogram,
39    insert_counter: IntCounter,
40    stale_counter: IntCounter,
41    error_counter: IntCounter,
42    wait_gauge: IntGauge,
43    size_gauge: IntGauge,
44}
45
46impl<
47        K: Clone + Debug + Send + Sync + Hash + Eq + 'static,
48        V: Clone + Debug + Send + Sync + 'static,
49    > Inner<K, V>
50{
51    pub fn clear(&self) -> usize {
52        let num_entries = self.cache.len();
53
54        // We don't simply clear all elements here, as any pending
55        // items will be trying to wait to coordinate; we need
56        // to aggressively close the semaphore and wake them all up
57        // before we remove those entries.
58        self.cache.retain(|_k, item| {
59            if let ItemState::Pending(sema) = &item.item {
60                // Force everyone to wakeup and error out
61                sema.close();
62            }
63            false
64        });
65
66        self.size_gauge.set(self.cache.len() as i64);
67        num_entries
68    }
69
70    /// Evict up to target entries.
71    ///
72    /// We use a probablistic approach to the LRU, because
73    /// it is challenging to safely thread the classic doubly-linked-list
74    /// through dashmap.
75    ///
76    /// target is bounded to half of number of selected samples, in
77    /// order to ensure that we don't randomly pick the newest element
78    /// from the set when under pressure.
79    ///
80    /// Redis uses a similar technique for its LRU as described
81    /// in <https://redis.io/docs/latest/develop/reference/eviction/#apx-lru>
82    /// which suggests that sampling 10 keys at random to them compare
83    /// their recency yields a reasonably close approximation to the
84    /// 100% precise LRU.
85    ///
86    /// Since we also support TTLs, we'll just go ahead and remove
87    /// any expired keys that show up in the sampled set.
88    pub fn evict_some(&self, target: usize) -> usize {
89        let now = Instant::now();
90
91        // Approximate (since it could change immediately after reading)
92        // cache size
93        let cache_size = self.cache.len();
94        // How many keys to sample
95        let num_samples = self.lru_samples.load(Ordering::Relaxed).min(cache_size);
96
97        // a list of keys which have expired
98        let mut expired_keys = vec![];
99        // a random selection of up to num_samples (key, tick) tuples
100        let mut samples = vec![];
101
102        // Pick some random keys.
103        // The rand crate has some helpers for working with iterators,
104        // but they appear to copy many elements into an internal buffer
105        // in order to make a selection, and we want to avoid directly
106        // considering every possible element because some users have
107        // very large capacity caches.
108        //
109        // The approach taken here is to produce a random list of iterator
110        // offsets so that we can skim across the iterator in a single
111        // pass and pull out a random selection of elements.
112        // The sample function provides a randomized list of indices that
113        // we can use for this; we need to sort it first, but the cost
114        // should be reasonably low as num_samples should be ~10 or so
115        // in the most common configuration.
116        {
117            let mut rng = rand::thread_rng();
118            let mut indices =
119                rand::seq::index::sample(&mut rng, cache_size, num_samples).into_vec();
120            indices.sort();
121            let mut iter = self.cache.iter();
122            let mut current_idx = 0;
123
124            /// Advance an iterator by skip_amount.
125            /// Ideally we'd use Iterator::advance_by for this, but at the
126            /// time of writing that method is nightly only.
127            /// Note that it also uses next() internally anyway
128            fn advance_by(iter: &mut impl Iterator, skip_amount: usize) {
129                for _ in 0..skip_amount {
130                    if iter.next().is_none() {
131                        return;
132                    }
133                }
134            }
135
136            for idx in indices {
137                // idx is the index we want to be on; we'll need to skip ahead
138                // by some number of slots based on the current one. skip_amount
139                // is that number.
140                let skip_amount = idx - current_idx;
141                advance_by(&mut iter, skip_amount);
142
143                match iter.next() {
144                    Some(map_entry) => {
145                        current_idx = idx + 1;
146                        let item = map_entry.value();
147                        match &item.item {
148                            ItemState::Pending(_) | ItemState::Refreshing { .. } => {
149                                // Cannot evict a pending lookup
150                            }
151                            ItemState::Present(_) | ItemState::Failed(_) => {
152                                if now >= item.expiration {
153                                    expired_keys.push(map_entry.key().clone());
154                                } else {
155                                    let last_tick = item.last_tick.load(Ordering::Relaxed);
156                                    samples.push((map_entry.key().clone(), last_tick));
157                                }
158                            }
159                        }
160                    }
161                    None => {
162                        break;
163                    }
164                }
165            }
166        }
167
168        let mut num_removed = 0;
169        for key in expired_keys {
170            // Sanity check that it is still expired before removing it,
171            // because it would be a shame to remove it if another actor
172            // has just updated it
173            let removed = self
174                .cache
175                .remove_if(&key, |_k, entry| now >= entry.expiration)
176                .is_some();
177            if removed {
178                tracing::trace!("{} expired {key:?}", self.name);
179                num_removed += 1;
180                self.expire_counter.inc();
181            }
182        }
183
184        // Since we're picking random elements, we want to ensure that
185        // we never pick the newest element from the set to evict because
186        // that is likely the wrong choice. We need enough samples to
187        // know that the lowest number we picked is representative
188        // of the eldest element in the map overall.
189        // We limit ourselves to half of the number of selected samples.
190        let target = target.min(samples.len() / 2).max(1);
191
192        // If we met our target, skip the extra work below
193        if num_removed >= target {
194            self.size_gauge.set(self.cache.len() as i64);
195            tracing::trace!("{} expired {num_removed} of target {target}", self.name);
196            return num_removed;
197        }
198
199        // Sort by ascending tick, which is equivalent to having the
200        // LRU within that set towards the front of the vec
201        samples.sort_by(|(_ka, tick_a), (_kb, tick_b)| tick_a.cmp(tick_b));
202
203        for (key, tick) in samples {
204            // Sanity check that the tick value is the same as we expect.
205            // If it has changed since we sampled it, then that element
206            // is no longer a good candidate for LRU eviction.
207            if self
208                .cache
209                .remove_if(&key, |_k, item| {
210                    item.last_tick.load(Ordering::Relaxed) == tick
211                })
212                .is_some()
213            {
214                tracing::debug!("{} evicted {key:?}", self.name);
215                num_removed += 1;
216                self.evict_counter.inc();
217                self.size_gauge.set(self.cache.len() as i64);
218                if num_removed >= target {
219                    return num_removed;
220                }
221            }
222        }
223
224        if num_removed == 0 {
225            tracing::debug!(
226                "{} did not find anything to evict, target was {target}",
227                self.name
228            );
229        }
230
231        tracing::trace!("{} removed {num_removed} of target {target}", self.name);
232
233        num_removed
234    }
235
236    /// Potentially make some progress to get back under
237    /// budget on the cache capacity
238    pub fn maybe_evict(&self) -> usize {
239        let cache_size = self.cache.len();
240        let capacity = self.capacity.load(Ordering::Relaxed);
241        if cache_size > capacity {
242            self.evict_some(cache_size - capacity)
243        } else {
244            0
245        }
246    }
247}
248
249trait CachePurger {
250    fn name(&self) -> &str;
251    fn purge(&self) -> usize;
252    fn process_expirations(&self) -> usize;
253    fn update_capacity(&self, capacity: usize);
254}
255
256impl<
257        K: Clone + Debug + Send + Sync + Hash + Eq + 'static,
258        V: Clone + Debug + Send + Sync + 'static,
259    > CachePurger for Inner<K, V>
260{
261    fn name(&self) -> &str {
262        &self.name
263    }
264    fn purge(&self) -> usize {
265        self.clear()
266    }
267    fn process_expirations(&self) -> usize {
268        let now = Instant::now();
269        let mut expired_keys = vec![];
270        for map_entry in self.cache.iter() {
271            let item = map_entry.value();
272            match &item.item {
273                ItemState::Pending(_) | ItemState::Refreshing { .. } => {
274                    // Cannot evict a pending lookup
275                }
276                ItemState::Present(_) | ItemState::Failed(_) => {
277                    if now >= item.expiration {
278                        expired_keys.push(map_entry.key().clone());
279                    }
280                }
281            }
282        }
283
284        let mut num_removed = 0;
285        for key in expired_keys {
286            // Sanity check that it is still expired before removing it,
287            // because it would be a shame to remove it if another actor
288            // has just updated it
289            let removed = self
290                .cache
291                .remove_if(&key, |_k, entry| now >= entry.expiration)
292                .is_some();
293            if removed {
294                num_removed += 1;
295                self.expire_counter.inc();
296                self.size_gauge.set(self.cache.len() as i64);
297            }
298        }
299
300        num_removed + self.maybe_evict()
301    }
302
303    fn update_capacity(&self, capacity: usize) {
304        self.capacity.store(capacity, Ordering::Relaxed);
305        // Bring it within capacity.
306        // At the time of writing this is a bit half-hearted,
307        // but we'll eventually trim down via ongoing process_expirations()
308        // calls
309        self.process_expirations();
310    }
311}
312
313fn all_caches() -> Vec<Arc<dyn CachePurger + Send + Sync>> {
314    let mut result = vec![];
315    let mut caches = CACHES.lock();
316    caches.retain(|entry| match entry.upgrade() {
317        Some(purger) => {
318            result.push(purger);
319            true
320        }
321        None => false,
322    });
323    result
324}
325
326pub fn purge_all_caches() {
327    let purgers = all_caches();
328
329    tracing::error!("purging {} caches", purgers.len());
330    for purger in purgers {
331        let name = purger.name();
332        let num_entries = purger.purge();
333        tracing::error!("cleared {num_entries} entries from cache {name}");
334    }
335}
336
337async fn prune_expired_caches() {
338    loop {
339        tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
340        let purgers = all_caches();
341
342        for p in purgers {
343            let n = p.process_expirations();
344            if n > 0 {
345                tracing::debug!("expired {n} entries from cache {}", p.name());
346            }
347        }
348    }
349}
350
351#[linkme::distributed_slice]
352pub static LRUTTL_VIVIFY: [fn() -> CacheDefinition];
353
354#[macro_export]
355macro_rules! optional_doc {
356    ($doc:expr) => {
357        Some($doc.trim())
358    };
359    ($($doc:expr)+) => {
360        Some(concat!($($doc,)+).trim())
361    };
362    () => {
363        None
364    };
365}
366
367/// Declare a cache as a static, and link it into the list of possible
368/// pre-defined caches.
369///
370/// Due to a limitation in implementation details, you must also add
371/// `linkme.workspace = true` to the manifest of the crate where you
372/// use this macro.
373#[macro_export]
374macro_rules! declare_cache {
375    (
376     $(#[doc = $doc:expr])*
377     $vis:vis
378        static $sym:ident:
379        LruCacheWithTtl<$key:ty, $value:ty>::new($name:expr, $capacity:expr);
380    ) => {
381        $(#[doc = $doc])*
382        $vis static $sym: ::std::sync::LazyLock<$crate::LruCacheWithTtl<$key, $value>> =
383            ::std::sync::LazyLock::new(
384                || $crate::LruCacheWithTtl::new($name, $capacity));
385
386        // Link into LRUTTL_VIVIFY
387        $crate::paste::paste! {
388            #[linkme::distributed_slice($crate::LRUTTL_VIVIFY)]
389            static [<VIVIFY_ $sym>]: fn() -> $crate::CacheDefinition = || {
390                ::std::sync::LazyLock::force(&$sym);
391                $crate::CacheDefinition {
392                    name: $name,
393                    capacity: $capacity,
394                    doc: $crate::optional_doc!($($doc)*),
395                }
396            };
397        }
398    };
399}
400
401/// Ensure that all caches declared via declare_cache!
402/// have been instantiated and returns the set of names.
403fn vivify() {
404    LazyLock::force(&PREDEFINED_CACHES);
405}
406
407fn vivify_impl() -> HashMap<&'static str, CacheDefinition> {
408    let mut map = HashMap::new();
409
410    for vivify_func in LRUTTL_VIVIFY {
411        let definition = vivify_func();
412        assert!(
413            !map.contains_key(definition.name),
414            "duplicate cache name {}",
415            definition.name
416        );
417        map.insert(definition.name, definition);
418    }
419
420    map
421}
422
423#[derive(serde::Serialize)]
424pub struct CacheDefinition {
425    pub name: &'static str,
426    pub capacity: usize,
427    pub doc: Option<&'static str>,
428}
429
430static PREDEFINED_CACHES: LazyLock<HashMap<&'static str, CacheDefinition>> =
431    LazyLock::new(vivify_impl);
432
433pub fn get_definitions() -> Vec<&'static CacheDefinition> {
434    let mut defs = PREDEFINED_CACHES.values().collect::<Vec<_>>();
435    defs.sort_by(|a, b| a.name.cmp(&b.name));
436    defs
437}
438
439pub fn is_name_available(name: &str) -> bool {
440    !PREDEFINED_CACHES.contains_key(name)
441}
442
443/// Update the capacity value for a pre-defined cache
444pub fn set_cache_capacity(name: &str, capacity: usize) -> bool {
445    if !PREDEFINED_CACHES.contains_key(name) {
446        return false;
447    }
448    let caches = all_caches();
449    match caches.iter().find(|p| p.name() == name) {
450        Some(p) => {
451            p.update_capacity(capacity);
452            true
453        }
454        None => false,
455    }
456}
457
458pub fn spawn_memory_monitor() {
459    vivify();
460    tokio::spawn(purge_caches_on_memory_shortage());
461    tokio::spawn(prune_expired_caches());
462}
463
464async fn purge_caches_on_memory_shortage() {
465    tracing::debug!("starting memory monitor");
466    let mut memory_status = subscribe_to_memory_status_changes_async().await;
467    while let Ok(()) = memory_status.changed().await {
468        if kumo_server_memory::get_headroom() == 0 {
469            purge_all_caches();
470
471            // Wait a little bit so that we can debounce
472            // in the case where we're riding the cusp of
473            // the limit and would thrash the caches
474            tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
475        }
476    }
477}
478
479#[derive(Debug, Clone)]
480enum ItemState<V>
481where
482    V: Send,
483    V: Sync,
484{
485    Present(V),
486    Pending(Arc<Semaphore>),
487    Failed(Arc<anyhow::Error>),
488    Refreshing {
489        stale_value: V,
490        pending: Arc<Semaphore>,
491    },
492}
493
494#[derive(Debug)]
495struct Item<V>
496where
497    V: Send,
498    V: Sync,
499{
500    item: ItemState<V>,
501    expiration: Instant,
502    last_tick: AtomicUsize,
503}
504
505impl<V: Clone + Send + Sync> Clone for Item<V> {
506    fn clone(&self) -> Self {
507        Self {
508            item: self.item.clone(),
509            expiration: self.expiration,
510            last_tick: self.last_tick.load(Ordering::Relaxed).into(),
511        }
512    }
513}
514
515#[derive(Debug)]
516pub struct ItemLookup<V: Debug> {
517    /// A copy of the item
518    pub item: V,
519    /// If true, the get_or_try_insert operation populated the entry;
520    /// the operation was a cache miss
521    pub is_fresh: bool,
522    /// The instant at which this entry will expire
523    pub expiration: Instant,
524}
525
526pub struct LruCacheWithTtl<K: Clone + Debug + Hash + Eq, V: Clone + Debug + Send + Sync> {
527    inner: Arc<Inner<K, V>>,
528}
529
530impl<K: Clone + Debug + Hash + Eq, V: Clone + Debug + Send + Sync> Clone for LruCacheWithTtl<K, V> {
531    fn clone(&self) -> Self {
532        Self {
533            inner: Arc::clone(&self.inner),
534        }
535    }
536}
537
538/// The outcome of contending for the right to populate an absent entry.
539enum Acquisition<V: Debug> {
540    /// Another caller already satisfied the lookup, or it timed out or failed:
541    /// the value to hand straight back to the caller.
542    Resolved(Result<ItemLookup<V>, Arc<anyhow::Error>>),
543    /// This caller won the race and must run the populate. It holds the
544    /// single-flight permit and is responsible for closing its semaphore once
545    /// the entry is inserted.
546    Owner(OwnedSemaphorePermit),
547}
548
549/// A detached populate call that exited without recording a result
550/// for its entry.
551#[derive(Debug)]
552pub struct PopulateAbandoned {
553    pub cache: String,
554    pub key: String,
555}
556
557impl std::fmt::Display for PopulateAbandoned {
558    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
559        write!(
560            fmt,
561            "{} populate for {} exited without recording a result",
562            self.cache, self.key
563        )
564    }
565}
566
567impl std::error::Error for PopulateAbandoned {}
568
569impl<
570        K: Clone + Debug + Hash + Eq + Send + Sync + std::fmt::Debug + 'static,
571        V: Clone + Debug + Send + Sync + 'static,
572    > LruCacheWithTtl<K, V>
573{
574    pub fn new<S: Into<String>>(name: S, capacity: usize) -> Self {
575        let name = name.into();
576        let cache = DashMap::new();
577
578        let lookup_counter = CACHE_LOOKUP
579            .get_metric_with_label_values(&[&name])
580            .expect("failed to get counter");
581        let hit_counter = CACHE_HIT
582            .get_metric_with_label_values(&[&name])
583            .expect("failed to get counter");
584        let stale_counter = CACHE_STALE
585            .get_metric_with_label_values(&[&name])
586            .expect("failed to get counter");
587        let evict_counter = CACHE_EVICT
588            .get_metric_with_label_values(&[&name])
589            .expect("failed to get counter");
590        let expire_counter = CACHE_EXPIRE
591            .get_metric_with_label_values(&[&name])
592            .expect("failed to get counter");
593        let miss_counter = CACHE_MISS
594            .get_metric_with_label_values(&[&name])
595            .expect("failed to get counter");
596        let populate_counter = CACHE_POPULATED
597            .get_metric_with_label_values(&[&name])
598            .expect("failed to get counter");
599        let populate_histogram = CACHE_POPULATE_DURATION
600            .get_metric_with_label_values(&[&name])
601            .expect("failed to get histogram");
602        let insert_counter = CACHE_INSERT
603            .get_metric_with_label_values(&[&name])
604            .expect("failed to get counter");
605        let error_counter = CACHE_ERROR
606            .get_metric_with_label_values(&[&name])
607            .expect("failed to get counter");
608        let wait_gauge = CACHE_WAIT
609            .get_metric_with_label_values(&[&name])
610            .expect("failed to get counter");
611        let size_gauge = CACHE_SIZE
612            .get_metric_with_label_values(&[&name])
613            .expect("failed to get counter");
614
615        let inner = Arc::new(Inner {
616            name,
617            cache,
618            tick: AtomicUsize::new(0),
619            allow_stale_reads: AtomicBool::new(false),
620            capacity: AtomicUsize::new(capacity),
621            lru_samples: AtomicUsize::new(10),
622            sema_timeout_milliseconds: AtomicUsize::new(120_000),
623            lookup_counter,
624            evict_counter,
625            expire_counter,
626            hit_counter,
627            miss_counter,
628            populate_counter,
629            populate_histogram,
630            error_counter,
631            wait_gauge,
632            insert_counter,
633            stale_counter,
634            size_gauge,
635        });
636
637        // Register with the global list of caches using a weak reference.
638        // We need to "erase" the K/V types in order to do that, so we
639        // use the CachePurger trait for this purpose.
640        {
641            let generic: Arc<dyn CachePurger + Send + Sync> = inner.clone();
642            CACHES.lock().push(Arc::downgrade(&generic));
643            tracing::debug!(
644                "registered cache {} with capacity {capacity}",
645                generic.name()
646            );
647        }
648
649        Self { inner }
650    }
651
652    fn allow_stale_reads(&self) -> bool {
653        self.inner.allow_stale_reads.load(Ordering::Relaxed)
654    }
655
656    pub fn set_allow_stale_reads(&self, value: bool) {
657        self.inner.allow_stale_reads.store(value, Ordering::Relaxed);
658    }
659
660    pub fn set_sema_timeout(&self, duration: Duration) {
661        self.inner
662            .sema_timeout_milliseconds
663            .store(duration.as_millis() as usize, Ordering::Relaxed);
664    }
665
666    pub fn get_sema_timeout(&self) -> Duration {
667        Duration::from_millis(self.inner.sema_timeout_milliseconds.load(Ordering::Relaxed) as u64)
668    }
669
670    pub fn clear(&self) -> usize {
671        self.inner.clear()
672    }
673
674    fn inc_tick(&self) -> usize {
675        self.inner.tick.fetch_add(1, Ordering::Relaxed) + 1
676    }
677
678    fn update_tick(&self, item: &Item<V>) {
679        let v = self.inc_tick();
680        item.last_tick.store(v, Ordering::Relaxed);
681    }
682
683    pub fn lookup<Q: ?Sized>(&self, name: &Q) -> Option<ItemLookup<V>>
684    where
685        K: Borrow<Q>,
686        Q: Hash + Eq,
687    {
688        self.inner.lookup_counter.inc();
689        match self.inner.cache.get_mut(name) {
690            None => {
691                self.inner.miss_counter.inc();
692                None
693            }
694            Some(entry) => {
695                match &entry.item {
696                    ItemState::Present(item) => {
697                        let now = Instant::now();
698                        if now >= entry.expiration {
699                            // Expired
700                            if self.allow_stale_reads() {
701                                // We don't furnish a result directly, but we
702                                // also do not want to remove it from the map
703                                // at this stage.
704                                // We're assuming that lookup() is called only
705                                // via get_or_try_insert when allow_stale_reads
706                                // is enabled.
707                                self.inner.miss_counter.inc();
708                                return None;
709                            }
710
711                            // otherwise: remove it from the map.
712                            // Take care to drop our ref first so that we don't
713                            // self-deadlock
714                            drop(entry);
715                            if self
716                                .inner
717                                .cache
718                                .remove_if(name, |_k, entry| now >= entry.expiration)
719                                .is_some()
720                            {
721                                self.inner.expire_counter.inc();
722                                self.inner.size_gauge.set(self.inner.cache.len() as i64);
723                            }
724                            self.inner.miss_counter.inc();
725                            return None;
726                        }
727                        self.inner.hit_counter.inc();
728                        self.update_tick(&entry);
729                        Some(ItemLookup {
730                            item: item.clone(),
731                            expiration: entry.expiration,
732                            is_fresh: false,
733                        })
734                    }
735                    ItemState::Refreshing { .. } | ItemState::Pending(_) | ItemState::Failed(_) => {
736                        self.inner.miss_counter.inc();
737                        None
738                    }
739                }
740            }
741        }
742    }
743
744    pub fn get<Q: ?Sized>(&self, name: &Q) -> Option<V>
745    where
746        K: Borrow<Q>,
747        Q: Hash + Eq,
748    {
749        self.lookup(name).map(|lookup| lookup.item)
750    }
751
752    pub async fn insert(&self, name: K, item: V, expiration: Instant) -> V {
753        self.inner.cache.insert(
754            name,
755            Item {
756                item: ItemState::Present(item.clone()),
757                expiration,
758                last_tick: self.inc_tick().into(),
759            },
760        );
761
762        self.inner.insert_counter.inc();
763        self.inner.size_gauge.set(self.inner.cache.len() as i64);
764        self.inner.maybe_evict();
765
766        item
767    }
768
769    fn clone_item_state(
770        &self,
771        name: &K,
772        deadline: Instant,
773        timeout_duration: Duration,
774    ) -> (ItemState<V>, Instant) {
775        let mut is_new = false;
776        let mut entry = self.inner.cache.entry(name.clone()).or_insert_with(|| {
777            is_new = true;
778            Item {
779                item: ItemState::Pending(Arc::new(Semaphore::new(1))),
780                expiration: deadline,
781                last_tick: self.inc_tick().into(),
782            }
783        });
784
785        match &entry.value().item {
786            ItemState::Pending(sema) => {
787                if sema.is_closed() {
788                    entry.value_mut().item = ItemState::Pending(Arc::new(Semaphore::new(1)));
789                } else {
790                    let now = Instant::now();
791                    if now >= entry.expiration {
792                        // Exceeded deadline without closing the semaphore.
793                        // Perhaps there is some blocking issue preventing progress?
794                        // Force it into an error state to help unclog the rest of
795                        // the system
796                        tracing::warn!(
797                            "{} semaphore for {name:?} remains open, \
798                            but the lookup state has not been satisfied within the \
799                            populate deadline. Assuming that something is stuck and \
800                            making the now-active caller responsible for populating \
801                            this entry",
802                            self.inner.name
803                        );
804                        entry.value_mut().item = ItemState::Pending(Arc::new(Semaphore::new(1)));
805                        entry.value_mut().expiration = now + timeout_duration;
806                    }
807                }
808            }
809            ItemState::Refreshing {
810                stale_value,
811                pending,
812            } => {
813                if pending.is_closed() {
814                    entry.value_mut().item = ItemState::Refreshing {
815                        stale_value: stale_value.clone(),
816                        pending: Arc::new(Semaphore::new(1)),
817                    };
818                }
819            }
820            ItemState::Present(item) => {
821                let now = Instant::now();
822                if now >= entry.expiration {
823                    // Expired; we will need to fetch it
824                    let pending = Arc::new(Semaphore::new(1));
825                    if self.allow_stale_reads() {
826                        entry.value_mut().item = ItemState::Refreshing {
827                            stale_value: item.clone(),
828                            pending,
829                        };
830                    } else {
831                        entry.value_mut().item = ItemState::Pending(pending);
832                    }
833                }
834            }
835            ItemState::Failed(_) => {
836                let now = Instant::now();
837                if now >= entry.expiration {
838                    // Expired; we will need to fetch it
839                    entry.value_mut().item = ItemState::Pending(Arc::new(Semaphore::new(1)));
840                    entry.value_mut().expiration = now + timeout_duration;
841                }
842            }
843        }
844
845        self.update_tick(&entry);
846        let item = entry.value();
847        let result = (item.item.clone(), entry.expiration);
848        drop(entry);
849
850        if is_new {
851            self.inner.size_gauge.set(self.inner.cache.len() as i64);
852            self.inner.maybe_evict();
853        }
854
855        result
856    }
857
858    /// Contend for the right to populate `name`, returning either the resolved
859    /// lookup (someone else satisfied it, or the wait timed out or failed) or
860    /// the single-flight permit this caller must populate under.
861    async fn acquire(
862        &self,
863        name: &K,
864        deadline: Instant,
865        timeout_duration: Duration,
866    ) -> Acquisition<V> {
867        // Retry without an attempt cap: the caller's deadline bounds the wait,
868        // enforced by the timeout_at below.
869        loop {
870            let (stale_value, sema) = match self.clone_item_state(name, deadline, timeout_duration)
871            {
872                (ItemState::Present(item), expiration) => {
873                    return Acquisition::Resolved(Ok(ItemLookup {
874                        item,
875                        expiration,
876                        is_fresh: false,
877                    }));
878                }
879                (ItemState::Failed(error), _) => {
880                    return Acquisition::Resolved(Err(error));
881                }
882                (
883                    ItemState::Refreshing {
884                        stale_value,
885                        pending,
886                    },
887                    expiration,
888                ) => (Some((stale_value, expiration)), pending),
889                (ItemState::Pending(sema), _) => (None, sema),
890            };
891
892            let wait_result = {
893                self.inner.wait_gauge.inc();
894                defer! {
895                    self.inner.wait_gauge.dec();
896                }
897
898                // This unbounded loop does not busy-spin: acquire_owned blocks
899                // each iteration until the semaphore is released or closed, and
900                // only resolves when one of those happens.
901                match timeout_at(deadline, sema.acquire_owned()).await {
902                    Err(_) => {
903                        if let Some((item, expiration)) = stale_value {
904                            tracing::debug!(
905                                "{} semaphore acquire for {name:?} timed out after \
906                                {timeout_duration:?}, allowing stale value to satisfy the lookup",
907                                self.inner.name
908                            );
909                            self.inner.stale_counter.inc();
910                            return Acquisition::Resolved(Ok(ItemLookup {
911                                item,
912                                expiration,
913                                is_fresh: false,
914                            }));
915                        }
916                        tracing::debug!(
917                            "{} semaphore acquire for {name:?} timed out after \
918                                {timeout_duration:?}, returning error",
919                            self.inner.name
920                        );
921
922                        self.inner.error_counter.inc();
923                        return Acquisition::Resolved(Err(Arc::new(anyhow::anyhow!(
924                            "{} lookup for {name:?} \
925                            timed out after {timeout_duration:?} \
926                            on semaphore acquire while waiting for cache to populate",
927                            self.inner.name
928                        ))));
929                    }
930                    Ok(r) => r,
931                }
932            };
933
934            // While we slept, someone else may have satisfied
935            // the lookup; check it
936            let current_sema = match self.clone_item_state(name, deadline, timeout_duration) {
937                (ItemState::Present(item), expiration) => {
938                    return Acquisition::Resolved(Ok(ItemLookup {
939                        item,
940                        expiration,
941                        is_fresh: false,
942                    }));
943                }
944                (ItemState::Failed(error), _) => {
945                    self.inner.hit_counter.inc();
946                    return Acquisition::Resolved(Err(error));
947                }
948                (
949                    ItemState::Refreshing {
950                        stale_value: _,
951                        pending,
952                    },
953                    _,
954                ) => pending,
955                (ItemState::Pending(current_sema), _) => current_sema,
956            };
957
958            // It's still outstanding
959            match wait_result {
960                Ok(permit) => {
961                    if !Arc::ptr_eq(&current_sema, permit.semaphore()) {
962                        self.inner.error_counter.inc();
963                        tracing::warn!(
964                            "{} mismatched semaphores for {name:?}, \
965                                    will restart cache resolve.",
966                            self.inner.name
967                        );
968                        // This permit is for a semaphore the entry no longer
969                        // references. Close it to release any callers still
970                        // waiting on it.
971                        permit.semaphore().close();
972                        // Restart against the current semaphore of the entry.
973                        continue;
974                    }
975
976                    // The caller closes this permit's semaphore, once it has
977                    // inserted a value (see the defer! in get_or_try_insert).
978                    return Acquisition::Owner(permit);
979                }
980                Err(_) => {
981                    self.inner.error_counter.inc();
982
983                    // semaphore was closed, but the status is
984                    // still somehow pending
985                    tracing::debug!(
986                        "{} lookup for {name:?} woke up semaphores \
987                                but is still marked pending, \
988                                will restart cache lookup",
989                        self.inner.name
990                    );
991                    continue;
992                }
993            }
994        }
995    }
996
997    /// Returns the cached item for `name`, or runs `fut` to produce one and
998    /// insert it, then returns it. `ttl_func` computes the TTL to store the
999    /// result under from the produced value, or a caller that wants a fixed TTL
1000    /// can ignore its argument and return a constant.
1001    ///
1002    /// Concurrent calls for the same `name` run `fut` at most once: all of
1003    /// them receive its outcome, success or failure.
1004    pub async fn get_or_try_insert<E: Into<anyhow::Error>, TTL: FnOnce(&V) -> Duration>(
1005        &self,
1006        name: &K,
1007        ttl_func: TTL,
1008        fut: impl Future<Output = Result<V, E>>,
1009    ) -> Result<ItemLookup<V>, Arc<anyhow::Error>> {
1010        // Fast path avoids cloning the key
1011        if let Some(entry) = self.lookup(name) {
1012            return Ok(entry);
1013        }
1014
1015        let timeout_duration = Duration::from_millis(
1016            self.inner.sema_timeout_milliseconds.load(Ordering::Relaxed) as u64,
1017        );
1018        let start = Instant::now();
1019        let deadline = start + timeout_duration;
1020
1021        let permit = match self.acquire(name, deadline, timeout_duration).await {
1022            Acquisition::Resolved(result) => return result,
1023            Acquisition::Owner(permit) => permit,
1024        };
1025
1026        // defer! closes the semaphore when this function returns by any path,
1027        // including a cancellation while fut is still running.
1028        defer! {
1029            permit.semaphore().close();
1030        }
1031
1032        self.inner.populate_counter.inc();
1033        let mut ttl = Duration::from_secs(60);
1034        let populate_timer = self.inner.populate_histogram.start_timer();
1035        let future_result = fut.await;
1036        populate_timer.observe_duration();
1037        let now = Instant::now();
1038
1039        let (item_result, return_value) = match future_result {
1040            Ok(item) => {
1041                ttl = ttl_func(&item);
1042                (
1043                    ItemState::Present(item.clone()),
1044                    Ok(ItemLookup {
1045                        item,
1046                        expiration: now + ttl,
1047                        is_fresh: true,
1048                    }),
1049                )
1050            }
1051            Err(err) => {
1052                self.inner.error_counter.inc();
1053                let err = Arc::new(err.into());
1054                (ItemState::Failed(err.clone()), Err(err))
1055            }
1056        };
1057
1058        // This insert must complete before the deferred close above runs.
1059        self.inner.cache.insert(
1060            name.clone(),
1061            Item {
1062                item: item_result,
1063                expiration: Instant::now() + ttl,
1064                last_tick: self.inc_tick().into(),
1065            },
1066        );
1067        self.inner.maybe_evict();
1068        return_value
1069    }
1070
1071    /// Get an existing item, or populate and insert one. If this call is
1072    /// cancelled while the entry is being populated, the populate keeps running
1073    /// on its own task and the entry still ends up populated.
1074    ///
1075    /// This call reports `is_fresh` as false.
1076    ///
1077    /// `make_fut` is invoked at most once. If the spawned populate exits
1078    /// without recording a result, this returns a [`PopulateAbandoned`].
1079    /// The caller decides whether to retry.
1080    pub async fn get_or_try_insert_detached<E, TTL, MK, F>(
1081        &self,
1082        name: &K,
1083        ttl_func: TTL,
1084        make_fut: MK,
1085        populate_timeout: Duration,
1086    ) -> Result<ItemLookup<V>, Arc<anyhow::Error>>
1087    where
1088        E: Into<anyhow::Error> + Send + 'static,
1089        TTL: Send + 'static + FnOnce(&V) -> Duration,
1090        MK: FnOnce() -> F,
1091        F: Future<Output = Result<V, E>> + Send + 'static,
1092    {
1093        if let Some(entry) = self.lookup(name) {
1094            return Ok(entry);
1095        }
1096
1097        let timeout_duration = Duration::from_millis(
1098            self.inner.sema_timeout_milliseconds.load(Ordering::Relaxed) as u64,
1099        );
1100        let deadline = Instant::now() + timeout_duration;
1101
1102        {
1103            let permit = match self.acquire(name, deadline, timeout_duration).await {
1104                Acquisition::Resolved(result) => return result,
1105                Acquisition::Owner(permit) => permit,
1106            };
1107
1108            let this = self.clone();
1109            let key = name.clone();
1110            let fut = make_fut();
1111            // permit moves into the spawned task below. This clone is the only
1112            // handle left to close the semaphore if the spawn itself fails.
1113            let sema = permit.semaphore().clone();
1114
1115            // Extend the entry's expiration to populate_timeout. Without this,
1116            // it keeps the wait deadline of the calling task, which can be
1117            // shorter.
1118            self.set_pending_expiration(name, Instant::now() + populate_timeout);
1119
1120            // Explicitly move processing to the `main` runtime to avoid
1121            // performance-impacting contention with the calling runtime.
1122            kumo_server_runtime::spawn_on_main("lruttl-populate", async move {
1123                // defer! closes the semaphore on every exit path, including a
1124                // panic in the populate, waking any caller blocked on it.
1125                defer! {
1126                    permit.semaphore().close();
1127                }
1128
1129                // The semaphore this populate was spawned for, compared below
1130                // against the current semaphore of the entry to detect a
1131                // stuck-entry swap (see is_current).
1132                let our_sema = permit.semaphore();
1133
1134                this.inner.populate_counter.inc();
1135                let populate_timer = this.inner.populate_histogram.start_timer();
1136                let populate_result = timeout(populate_timeout, fut).await;
1137                populate_timer.observe_duration();
1138                let (item, ttl) = match populate_result {
1139                    Ok(Ok(value)) => {
1140                        let ttl = ttl_func(&value);
1141                        (ItemState::Present(value), ttl)
1142                    }
1143                    Ok(Err(err)) => {
1144                        this.inner.error_counter.inc();
1145                        // A failed populate bypasses ttl_func. This uses the
1146                        // same 60-second fallback TTL that the failure case of
1147                        // get_or_try_insert uses.
1148                        (
1149                            ItemState::Failed(Arc::new(err.into())),
1150                            Duration::from_secs(60),
1151                        )
1152                    }
1153                    Err(_) => {
1154                        this.inner.error_counter.inc();
1155                        (
1156                            ItemState::Failed(Arc::new(anyhow::anyhow!(
1157                                "{} populate for {key:?} timed out after {populate_timeout:?}",
1158                                this.inner.name
1159                            ))),
1160                            Duration::from_secs(60),
1161                        )
1162                    }
1163                };
1164
1165                // Insert the result into the entry before our_sema closes:
1166                // a waiter unblocked by that close reads the entry right away
1167                // and must not find it still pending.
1168                if let Some(mut entry) = this.inner.cache.get_mut(&key) {
1169                    let is_current = match &entry.item {
1170                        ItemState::Pending(current)
1171                        | ItemState::Refreshing {
1172                            pending: current, ..
1173                        } => Arc::ptr_eq(current, our_sema),
1174                        ItemState::Present(_) | ItemState::Failed(_) => false,
1175                    };
1176                    if is_current {
1177                        entry.item = item;
1178                        entry.expiration = Instant::now() + ttl;
1179                        entry.last_tick = this.inc_tick().into();
1180                    }
1181                }
1182                this.inner.maybe_evict();
1183            })
1184            .map_err(|err| {
1185                // The lruttl-populate task above never ran. Its defer! block,
1186                // which would otherwise close this semaphore, never ran
1187                // either.
1188                sema.close();
1189                Arc::new(anyhow::anyhow!(
1190                    "{} failed to spawn detached populate for {name:?}: {err:#}",
1191                    self.inner.name
1192                ))
1193            })?;
1194        }
1195
1196        match self.acquire(name, deadline, timeout_duration).await {
1197            Acquisition::Resolved(result) => result,
1198            // A second Owner here means the populate task spawned above exited
1199            // without ever writing a result for this key.
1200            Acquisition::Owner(permit) => {
1201                // This function does not retry internally. It reports the
1202                // abandonment and leaves retrying to the caller.
1203                permit.semaphore().close();
1204                self.inner.error_counter.inc();
1205                Err(Arc::new(anyhow::Error::new(PopulateAbandoned {
1206                    cache: self.inner.name.clone(),
1207                    key: format!("{name:?}"),
1208                })))
1209            }
1210        }
1211    }
1212
1213    /// Push the expiration of the in-flight entry out to `expiration`, but only
1214    /// while it is still being populated (Pending or Refreshing). A populate
1215    /// that already finished keeps its own expiration.
1216    fn set_pending_expiration(&self, name: &K, expiration: Instant) {
1217        if let Some(mut entry) = self.inner.cache.get_mut(name) {
1218            if matches!(
1219                entry.item,
1220                ItemState::Pending(_) | ItemState::Refreshing { .. }
1221            ) {
1222                entry.expiration = expiration;
1223            }
1224        }
1225    }
1226}
1227
1228#[cfg(test)]
1229mod test {
1230    use super::*;
1231    use test_log::test; // run with RUST_LOG=lruttl=trace to trace
1232
1233    #[test(tokio::test)]
1234    async fn test_capacity() {
1235        let cache = LruCacheWithTtl::new("test_capacity", 40);
1236
1237        let expiration = Instant::now() + Duration::from_secs(60);
1238        for i in 0..100 {
1239            cache.insert(i, i, expiration).await;
1240        }
1241
1242        assert_eq!(cache.inner.cache.len(), 40, "capacity is respected");
1243    }
1244
1245    #[test(tokio::test)]
1246    async fn test_expiration() {
1247        let cache = LruCacheWithTtl::new("test_expiration", 1);
1248
1249        tokio::time::pause();
1250        let expiration = Instant::now() + Duration::from_secs(1);
1251        cache.insert(0, 0, expiration).await;
1252
1253        cache.get(&0).expect("still in cache");
1254        tokio::time::advance(Duration::from_secs(2)).await;
1255        assert!(cache.get(&0).is_none(), "evicted due to ttl");
1256    }
1257
1258    #[test(tokio::test)]
1259    async fn test_zero_ttl_requeries() {
1260        // A zero TTL makes the inserted entry expire at the instant it is stored.
1261        let cache = LruCacheWithTtl::<u32, u32>::new("test_zero_ttl_requeries", 1);
1262
1263        let first = cache
1264            .get_or_try_insert(&0, |_| Duration::ZERO, async { Ok::<_, anyhow::Error>(1) })
1265            .await
1266            .unwrap();
1267        assert_eq!(first.item, 1);
1268        assert!(first.is_fresh);
1269
1270        let second = cache
1271            .get_or_try_insert(&0, |_| Duration::ZERO, async { Ok::<_, anyhow::Error>(2) })
1272            .await
1273            .unwrap();
1274        assert_eq!(second.item, 2, "the zero-ttl entry was not reused");
1275        assert!(second.is_fresh);
1276    }
1277
1278    #[test(tokio::test)]
1279    async fn test_over_capacity_slow_resolve() {
1280        let cache = Arc::new(LruCacheWithTtl::<String, u64>::new(
1281            "test_over_capacity_slow_resolve",
1282            1,
1283        ));
1284
1285        let mut foos = vec![];
1286        for idx in 0..2 {
1287            let cache = cache.clone();
1288            foos.push(tokio::spawn(async move {
1289                eprintln!("spawned task {idx} is running");
1290                cache
1291                    .get_or_try_insert(&"foo".to_string(), |_| Duration::from_secs(86400), async {
1292                        if idx == 0 {
1293                            eprintln!("foo {idx} getter sleeping");
1294                            tokio::time::sleep(Duration::from_secs(300)).await;
1295                        }
1296                        eprintln!("foo {idx} getter done");
1297                        Ok::<_, anyhow::Error>(idx)
1298                    })
1299                    .await
1300            }));
1301        }
1302
1303        tokio::task::yield_now().await;
1304
1305        eprintln!("calling again with immediate getter");
1306        let result = cache
1307            .get_or_try_insert(&"bar".to_string(), |_| Duration::from_secs(60), async {
1308                eprintln!("bar immediate getter running");
1309                Ok::<_, anyhow::Error>(42)
1310            })
1311            .await
1312            .unwrap();
1313
1314        assert_eq!(result.item, 42);
1315        assert_eq!(cache.inner.cache.len(), 1);
1316
1317        eprintln!("aborting first one");
1318        foos.remove(0).abort();
1319
1320        eprintln!("try new key");
1321        let result = cache
1322            .get_or_try_insert(&"baz".to_string(), |_| Duration::from_secs(60), async {
1323                eprintln!("baz immediate getter running");
1324                Ok::<_, anyhow::Error>(32)
1325            })
1326            .await
1327            .unwrap();
1328        assert_eq!(result.item, 32);
1329        assert_eq!(cache.inner.cache.len(), 1);
1330
1331        eprintln!("waiting second one");
1332        assert_eq!(1, foos.pop().unwrap().await.unwrap().unwrap().item);
1333
1334        assert_eq!(cache.inner.cache.len(), 1);
1335    }
1336
1337    #[test(tokio::test)]
1338    async fn detached_populate_survives_caller_cancellation() {
1339        use tokio::sync::Notify;
1340
1341        let cache = Arc::new(LruCacheWithTtl::<String, u64>::new(
1342            "detached_survives_cancel",
1343            16,
1344        ));
1345        let key = "k".to_string();
1346        let started = Arc::new(Notify::new());
1347        let gate = Arc::new(Notify::new());
1348        let runs = Arc::new(AtomicUsize::new(0));
1349
1350        let caller = {
1351            let cache = cache.clone();
1352            let key = key.clone();
1353            let started = started.clone();
1354            let gate = gate.clone();
1355            let runs = runs.clone();
1356            tokio::spawn(async move {
1357                cache
1358                    .get_or_try_insert_detached(
1359                        &key,
1360                        |_| Duration::from_secs(86400),
1361                        move || {
1362                            let started = started.clone();
1363                            let gate = gate.clone();
1364                            let runs = runs.clone();
1365                            async move {
1366                                runs.fetch_add(1, Ordering::SeqCst);
1367                                started.notify_one();
1368                                gate.notified().await;
1369                                Ok::<_, anyhow::Error>(42u64)
1370                            }
1371                        },
1372                        Duration::from_secs(300),
1373                    )
1374                    .await
1375            })
1376        };
1377
1378        // Wait until the detached populate is actually running, then cancel the
1379        // triggering caller before it can complete.
1380        started.notified().await;
1381        caller.abort();
1382        // The detached populate must still finish and insert the value.
1383        gate.notify_one();
1384
1385        loop {
1386            if let Some(value) = cache.get(&key) {
1387                assert_eq!(value, 42);
1388                break;
1389            }
1390            tokio::task::yield_now().await;
1391        }
1392        assert_eq!(runs.load(Ordering::SeqCst), 1);
1393    }
1394
1395    #[test(tokio::test)]
1396    async fn detached_second_caller_shares_populate() {
1397        use tokio::sync::Notify;
1398
1399        let cache = Arc::new(LruCacheWithTtl::<String, u64>::new(
1400            "detached_shared_populate",
1401            16,
1402        ));
1403        let key = "k".to_string();
1404        let gate = Arc::new(Notify::new());
1405        let runs = Arc::new(AtomicUsize::new(0));
1406
1407        let make_caller = || {
1408            let cache = cache.clone();
1409            let key = key.clone();
1410            let gate = gate.clone();
1411            let runs = runs.clone();
1412            tokio::spawn(async move {
1413                cache
1414                    .get_or_try_insert_detached(
1415                        &key,
1416                        |_| Duration::from_secs(86400),
1417                        move || {
1418                            let gate = gate.clone();
1419                            let runs = runs.clone();
1420                            async move {
1421                                runs.fetch_add(1, Ordering::SeqCst);
1422                                gate.notified().await;
1423                                Ok::<_, anyhow::Error>(7u64)
1424                            }
1425                        },
1426                        Duration::from_secs(300),
1427                    )
1428                    .await
1429            })
1430        };
1431
1432        let first = make_caller();
1433        let second = make_caller();
1434
1435        // Wait until populate is running, then release it.
1436        while runs.load(Ordering::SeqCst) == 0 {
1437            tokio::task::yield_now().await;
1438        }
1439        gate.notify_one();
1440
1441        let first = first.await.unwrap().unwrap();
1442        let second = second.await.unwrap().unwrap();
1443        assert_eq!(first.item, 7);
1444        assert_eq!(second.item, 7);
1445        assert_eq!(
1446            runs.load(Ordering::SeqCst),
1447            1,
1448            "the two callers must share one populate"
1449        );
1450    }
1451
1452    #[test(tokio::test)]
1453    async fn detached_populate_timeout_yields_one_error() {
1454        let cache = Arc::new(LruCacheWithTtl::<String, u64>::new(
1455            "detached_populate_timeout",
1456            16,
1457        ));
1458        let key = "k".to_string();
1459        let runs = Arc::new(AtomicUsize::new(0));
1460
1461        let runs_in_fut = runs.clone();
1462        let result = cache
1463            .get_or_try_insert_detached(
1464                &key,
1465                |_| Duration::from_secs(86400),
1466                move || {
1467                    let runs = runs_in_fut.clone();
1468                    async move {
1469                        runs.fetch_add(1, Ordering::SeqCst);
1470                        // Never completes. The test relies on
1471                        // populate_timeout to fire.
1472                        std::future::pending::<()>().await;
1473                        Ok::<_, anyhow::Error>(0u64)
1474                    }
1475                },
1476                Duration::from_millis(50),
1477            )
1478            .await;
1479
1480        let err = result.unwrap_err();
1481        assert!(
1482            err.to_string().contains("timed out after"),
1483            "unexpected error: {err}"
1484        );
1485        assert_eq!(
1486            runs.load(Ordering::SeqCst),
1487            1,
1488            "a timed-out populate must leave a Failed entry, not spin up repeated populates"
1489        );
1490    }
1491}