Skip to main content

linera_cache/
value_cache.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! A concurrent cache with efficient eviction and single-allocation guarantees.
5//!
6//! Values are stored internally as `Arc<V>`, so cache hits return a cheap
7//! `Arc` clone instead of cloning the underlying data. A secondary weak
8//! index (`papaya::HashMap<K, Weak<V>>`) ensures that at most one allocation
9//! exists per key: if the bounded cache evicts an entry while a consumer
10//! still holds an `Arc`, re-requesting the same key returns the same
11//! allocation instead of creating a duplicate.
12
13#[cfg(with_metrics)]
14use std::any::type_name;
15use std::{
16    borrow::Cow,
17    hash::Hash,
18    sync::{Arc, Weak},
19};
20
21use linera_base::{crypto::CryptoHash, hashed::Hashed};
22use papaya::{Compute, Operation};
23use quick_cache::sync::Cache;
24
25/// Default interval between dead-entry cleanup sweeps of the weak index.
26pub const DEFAULT_CLEANUP_INTERVAL_SECS: u64 = 30;
27
28/// A concurrent cache with efficient eviction and single-allocation guarantees.
29///
30/// Backed by `quick_cache` (S3-FIFO eviction) for bounded hot-path caching, plus
31/// a lock-free `papaya::HashMap` weak index for deduplication. Together they
32/// guarantee that at most one `Arc<V>` allocation exists per key at any time.
33///
34/// A background task periodically sweeps dead `Weak` entries from the index
35/// to prevent unbounded memory growth.
36pub struct ValueCache<K, V> {
37    cache: Cache<K, Arc<V>>,
38    weak_index: Arc<papaya::HashMap<K, Weak<V>>>,
39}
40
41impl<K, V> ValueCache<K, V>
42where
43    K: Hash + Eq + Clone + Send + Sync + 'static,
44    V: Send + Sync + 'static,
45{
46    /// Creates a new `ValueCache` with the given bounded-cache capacity
47    /// and cleanup interval for the weak-reference index.
48    #[cfg(not(web))]
49    pub fn new(size: usize, cleanup_interval_secs: u64) -> Self {
50        let weak_index = Arc::new(papaya::HashMap::new());
51        Self::spawn_cleanup_task(
52            Arc::clone(&weak_index),
53            std::time::Duration::from_secs(cleanup_interval_secs),
54        );
55        ValueCache {
56            cache: Cache::new(size),
57            weak_index,
58        }
59    }
60
61    /// Creates a new `ValueCache` (web variant, no background cleanup task).
62    #[cfg(web)]
63    pub fn new(size: usize, _cleanup_interval_secs: u64) -> Self {
64        ValueCache {
65            cache: Cache::new(size),
66            weak_index: Arc::new(papaya::HashMap::new()),
67        }
68    }
69
70    /// Inserts a value into the cache, returning the canonical `Arc`.
71    ///
72    /// The value is wrapped in `Arc` internally. If a live `Arc` for this key
73    /// already exists (held by another consumer), the existing allocation is
74    /// reused and the new value is dropped.
75    pub fn insert(&self, key: &K, value: V) -> Arc<V> {
76        self.dedup_insert(key, Arc::new(value))
77    }
78
79    /// Inserts a pre-wrapped `Arc<V>` into the cache, returning the canonical `Arc`.
80    #[cfg(with_testing)]
81    pub fn insert_arc(&self, key: &K, value: Arc<V>) -> Arc<V> {
82        self.dedup_insert(key, value)
83    }
84
85    /// Removes a value from the bounded cache.
86    ///
87    /// The weak index entry is intentionally kept — another consumer may
88    /// still hold an `Arc` to this value, and the weak index must be able
89    /// to deduplicate against it. Dead weak entries are cleaned up by the
90    /// background task.
91    pub fn remove(&self, key: &K) -> Option<Arc<V>> {
92        let value = self.cache.peek(key);
93        if value.is_some() {
94            self.cache.remove(key);
95        }
96        Self::track_cache_usage(value)
97    }
98
99    /// Returns an `Arc` reference to the value, checking both the bounded
100    /// cache and the weak index.
101    pub fn get(&self, key: &K) -> Option<Arc<V>> {
102        // Tier 1: bounded cache (hot path)
103        if let Some(arc) = self.cache.get(key) {
104            return Self::track_cache_usage(Some(arc));
105        }
106
107        // Tier 2: weak index (catches evicted-but-still-held entries)
108        let guard = self.weak_index.guard();
109        if let Some(weak) = self.weak_index.get(key, &guard) {
110            if let Some(arc) = weak.upgrade() {
111                // Re-insert into bounded cache for future fast lookups
112                self.cache.insert(key.clone(), arc.clone());
113                return Self::track_cache_usage(Some(arc));
114            }
115        }
116
117        Self::track_cache_usage(None)
118    }
119
120    /// Returns `true` if the value exists in either the bounded cache or
121    /// the weak index (with a live allocation).
122    pub fn contains(&self, key: &K) -> bool {
123        if self.cache.peek(key).is_some() {
124            return true;
125        }
126        let guard = self.weak_index.guard();
127        self.weak_index
128            .get(key, &guard)
129            .is_some_and(|weak| weak.strong_count() > 0)
130    }
131
132    /// Removes all dead `Weak` entries from the weak index.
133    #[cfg(with_testing)]
134    pub fn cleanup_dead_entries(&self) {
135        let guard = self.weak_index.guard();
136        self.weak_index
137            .retain(|_, weak| weak.strong_count() > 0, &guard);
138    }
139
140    /// Spawns a background task that periodically sweeps dead weak entries.
141    ///
142    /// No-op if no tokio runtime is available (e.g. in unit tests).
143    #[cfg(not(web))]
144    fn spawn_cleanup_task(
145        weak_index: Arc<papaya::HashMap<K, Weak<V>>>,
146        cleanup_interval: std::time::Duration,
147    ) {
148        if tokio::runtime::Handle::try_current().is_err() {
149            return;
150        }
151        tokio::spawn(async move {
152            let mut interval = tokio::time::interval(cleanup_interval);
153            loop {
154                interval.tick().await;
155                let guard = weak_index.guard();
156                weak_index.retain(|_, weak| weak.strong_count() > 0, &guard);
157            }
158        });
159    }
160
161    /// Core dedup logic: atomically checks the weak index for an existing
162    /// live allocation. If found, reuses it. Otherwise inserts the new Arc.
163    /// Returns the canonical `Arc`.
164    fn dedup_insert(&self, key: &K, new_arc: Arc<V>) -> Arc<V> {
165        let guard = self.weak_index.guard();
166        let weak = Arc::downgrade(&new_arc);
167
168        let result = self.weak_index.compute(
169            key.clone(),
170            |entry| match entry {
171                Some((_k, existing_weak)) => match existing_weak.upgrade() {
172                    Some(existing_arc) => Operation::Abort(existing_arc),
173                    None => Operation::Insert(weak.clone()),
174                },
175                None => Operation::Insert(weak.clone()),
176            },
177            &guard,
178        );
179
180        let canonical_arc = match result {
181            Compute::Inserted(..) | Compute::Updated { .. } => new_arc,
182            Compute::Aborted(existing_arc) => existing_arc,
183            _ => unreachable!(),
184        };
185
186        self.cache.insert(key.clone(), canonical_arc.clone());
187        canonical_arc
188    }
189
190    fn track_cache_usage(maybe_value: Option<Arc<V>>) -> Option<Arc<V>> {
191        #[cfg(with_metrics)]
192        {
193            let metric = if maybe_value.is_some() {
194                &metrics::CACHE_HIT_COUNT
195            } else {
196                &metrics::CACHE_MISS_COUNT
197            };
198
199            metric
200                .with_label_values(&[type_name::<K>(), type_name::<V>()])
201                .inc();
202        }
203        maybe_value
204    }
205}
206
207impl<T: Send + Sync + 'static> ValueCache<CryptoHash, Hashed<T>> {
208    /// Inserts a [`Hashed<T>`] into the cache, returning the canonical `Arc`.
209    ///
210    /// The `value` is wrapped in a [`Cow`] so that it is only cloned if it
211    /// needs to be inserted in the cache.
212    pub fn insert_hashed(&self, value: Cow<Hashed<T>>) -> Arc<Hashed<T>>
213    where
214        T: Clone,
215    {
216        let hash = (*value).hash();
217        // Fast path: already in bounded cache
218        if let Some(arc) = self.cache.peek(&hash) {
219            return arc;
220        }
221        // Check weak index before cloning from Cow
222        let guard = self.weak_index.guard();
223        if let Some(weak) = self.weak_index.get(&hash, &guard) {
224            if let Some(arc) = weak.upgrade() {
225                self.cache.insert(hash, arc.clone());
226                return arc;
227            }
228        }
229        drop(guard);
230        self.dedup_insert(&hash, Arc::new(value.into_owned()))
231    }
232
233    /// Inserts multiple [`Hashed<T>`]s into the cache.
234    #[cfg(with_testing)]
235    pub fn insert_all_hashed<'a>(&self, values: impl IntoIterator<Item = Cow<'a, Hashed<T>>>)
236    where
237        T: Clone + 'a,
238    {
239        for value in values {
240            self.insert_hashed(value);
241        }
242    }
243}
244
245#[cfg(with_metrics)]
246mod metrics {
247    use std::sync::LazyLock;
248
249    use linera_base::prometheus_util::register_int_counter_vec;
250    use prometheus::IntCounterVec;
251
252    pub static CACHE_HIT_COUNT: LazyLock<IntCounterVec> = LazyLock::new(|| {
253        register_int_counter_vec(
254            "value_cache_hit",
255            "Cache hits in `ValueCache`",
256            &["key_type", "value_type"],
257        )
258    });
259
260    pub static CACHE_MISS_COUNT: LazyLock<IntCounterVec> = LazyLock::new(|| {
261        register_int_counter_vec(
262            "value_cache_miss",
263            "Cache misses in `ValueCache`",
264            &["key_type", "value_type"],
265        )
266    });
267}
268
269#[cfg(test)]
270mod tests {
271    use std::{borrow::Cow, sync::Arc};
272
273    use linera_base::{crypto::CryptoHash, hashed::Hashed};
274    use serde::{Deserialize, Serialize};
275
276    use super::{ValueCache, DEFAULT_CLEANUP_INTERVAL_SECS};
277
278    /// Test cache size for unit tests.
279    const TEST_CACHE_SIZE: usize = 10;
280
281    /// A minimal hashable value for testing `ValueCache<CryptoHash, Hashed<T>>`.
282    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283    struct TestValue(u64);
284
285    impl linera_base::crypto::BcsHashable<'_> for TestValue {}
286
287    fn create_test_value(n: u64) -> Hashed<TestValue> {
288        Hashed::new(TestValue(n))
289    }
290
291    fn create_test_values(iter: impl IntoIterator<Item = u64>) -> Vec<Hashed<TestValue>> {
292        iter.into_iter().map(create_test_value).collect()
293    }
294
295    fn new_hashed_cache(size: usize) -> ValueCache<CryptoHash, Hashed<TestValue>> {
296        ValueCache::new(size, DEFAULT_CLEANUP_INTERVAL_SECS)
297    }
298
299    fn new_string_cache(size: usize) -> ValueCache<u64, String> {
300        ValueCache::new(size, DEFAULT_CLEANUP_INTERVAL_SECS)
301    }
302
303    #[test]
304    fn test_retrieve_missing_value() {
305        let cache = new_hashed_cache(TEST_CACHE_SIZE);
306        let hash = CryptoHash::test_hash("Missing value");
307
308        assert!(cache.get(&hash).is_none());
309        assert!(!cache.contains(&hash));
310    }
311
312    #[test]
313    fn test_insert_and_get() {
314        let cache = new_hashed_cache(TEST_CACHE_SIZE);
315        let value = create_test_value(0);
316        let hash = value.hash();
317
318        cache.insert_hashed(Cow::Borrowed(&value));
319        assert!(cache.contains(&hash));
320        assert_eq!(cache.get(&hash).as_deref(), Some(&value));
321    }
322
323    #[test]
324    fn test_insert_many_values() {
325        let cache = new_hashed_cache(TEST_CACHE_SIZE);
326        let values = create_test_values(0..TEST_CACHE_SIZE as u64);
327
328        for value in &values {
329            cache.insert_hashed(Cow::Borrowed(value));
330        }
331
332        for value in &values {
333            assert!(cache.contains(&value.hash()));
334            assert_eq!(cache.get(&value.hash()).as_deref(), Some(value));
335        }
336
337        // Batch insert
338        let cache2 = new_hashed_cache(TEST_CACHE_SIZE);
339        cache2.insert_all_hashed(values.iter().map(Cow::Borrowed));
340        for value in &values {
341            assert_eq!(cache2.get(&value.hash()).as_deref(), Some(value));
342        }
343    }
344
345    #[test]
346    fn test_reinsertion_dedup() {
347        let cache = new_hashed_cache(TEST_CACHE_SIZE);
348        let values = create_test_values(0..TEST_CACHE_SIZE as u64);
349
350        // First insert
351        let first_arcs: Vec<_> = values
352            .iter()
353            .map(|v| cache.insert_hashed(Cow::Borrowed(v)))
354            .collect();
355
356        // Re-inserting should return the same Arc (dedup)
357        for (value, first_arc) in values.iter().zip(&first_arcs) {
358            let second_arc = cache.insert_hashed(Cow::Borrowed(value));
359            assert!(Arc::ptr_eq(&second_arc, first_arc));
360        }
361    }
362
363    #[test]
364    fn test_eviction() {
365        let cache = new_hashed_cache(TEST_CACHE_SIZE);
366        let total = TEST_CACHE_SIZE * 3;
367        let values = create_test_values(0..total as u64);
368
369        for value in &values {
370            cache.insert_hashed(Cow::Borrowed(value));
371        }
372
373        let present_count = values.iter().filter(|v| cache.contains(&v.hash())).count();
374        assert!(
375            present_count <= TEST_CACHE_SIZE + 1,
376            "cache should not hold significantly more than its capacity, \
377             but has {present_count} entries for capacity {TEST_CACHE_SIZE}"
378        );
379        assert!(present_count > 0, "cache should still hold some entries");
380    }
381
382    #[test]
383    fn test_accessed_entry_survives_eviction() {
384        let cache = new_hashed_cache(TEST_CACHE_SIZE);
385        let promoted = create_test_value(0);
386        let promoted_hash = promoted.hash();
387
388        cache.insert_hashed(Cow::Borrowed(&promoted));
389        cache.get(&promoted_hash); // mark as hot
390
391        let extras = create_test_values(1..=TEST_CACHE_SIZE as u64 * 2);
392        for value in &extras {
393            cache.insert_hashed(Cow::Borrowed(value));
394        }
395
396        assert!(
397            cache.contains(&promoted_hash),
398            "recently accessed entry should survive eviction"
399        );
400    }
401
402    #[test]
403    fn test_promotion_of_reinsertion() {
404        let cache = new_hashed_cache(TEST_CACHE_SIZE);
405        let promoted = create_test_value(0);
406        let promoted_hash = promoted.hash();
407
408        let first = cache.insert_hashed(Cow::Borrowed(&promoted));
409        let second = cache.insert_hashed(Cow::Borrowed(&promoted));
410        assert!(Arc::ptr_eq(&first, &second));
411
412        let extras = create_test_values(1..=TEST_CACHE_SIZE as u64 * 2);
413        for value in &extras {
414            cache.insert_hashed(Cow::Borrowed(value));
415        }
416
417        assert!(
418            cache.contains(&promoted_hash),
419            "re-inserted entry should survive eviction"
420        );
421    }
422
423    #[test]
424    fn test_weak_index_dedup_after_eviction() {
425        let cache = new_string_cache(2);
426
427        // Insert and hold onto the Arc
428        let held = cache.insert(&1, "hello".to_string());
429
430        // Force eviction by filling the cache
431        cache.insert(&2, "world".to_string());
432        cache.insert(&3, "foo".to_string());
433        cache.insert(&4, "bar".to_string());
434
435        // Weak index should find it via the held Arc
436        let retrieved = cache
437            .get(&1)
438            .expect("held Arc should keep entry findable via weak index");
439        assert!(
440            Arc::ptr_eq(&retrieved, &held),
441            "must return same allocation, not a duplicate"
442        );
443
444        // Re-inserting should also return the same Arc
445        let reinserted = cache.insert(&1, "replacement".to_string());
446        assert!(Arc::ptr_eq(&reinserted, &held));
447        assert_eq!(&*reinserted, "hello");
448    }
449
450    #[test]
451    fn test_remove_preserves_weak_for_held_arcs() {
452        let cache = new_string_cache(TEST_CACHE_SIZE);
453
454        let held = cache.insert(&1, "hello".to_string());
455
456        // remove() evicts from bounded cache but NOT the weak index
457        cache.remove(&1);
458
459        // Still findable via weak index since we hold an Arc
460        let retrieved = cache.get(&1).expect("weak index should find held Arc");
461        assert!(Arc::ptr_eq(&retrieved, &held));
462    }
463
464    #[test]
465    fn test_remove_without_holder() {
466        let cache = new_string_cache(TEST_CACHE_SIZE);
467
468        cache.insert(&1, "hello".to_string());
469
470        // remove() without anyone holding an Arc — weak entry becomes dead
471        cache.remove(&1);
472        assert!(!cache.contains(&1));
473        assert!(cache.get(&1).is_none());
474    }
475
476    #[test]
477    fn test_cleanup_dead_entries() {
478        let cache = new_string_cache(2);
479
480        cache.insert(&1, "alive".to_string());
481        let _held = cache.get(&1).expect("just inserted"); // keep alive
482
483        cache.insert(&2, "dead".to_string());
484        // Don't hold key 2
485
486        // Force eviction of both by filling the cache
487        cache.insert(&3, "a".to_string());
488        cache.insert(&4, "b".to_string());
489        cache.insert(&5, "c".to_string());
490
491        cache.cleanup_dead_entries();
492
493        // Key 1 still findable (we hold an Arc)
494        assert!(cache.contains(&1));
495    }
496
497    #[test]
498    fn test_insert_arc_dedup() {
499        let cache = new_string_cache(TEST_CACHE_SIZE);
500        let value = Arc::new("hello".to_string());
501
502        let first = cache.insert_arc(&1, value.clone());
503        assert!(Arc::ptr_eq(&first, &value));
504
505        let second = cache.insert_arc(&1, Arc::new("other".to_string()));
506        assert!(Arc::ptr_eq(&second, &value));
507
508        assert_eq!(&*cache.get(&1).expect("just inserted"), "hello");
509    }
510}