Skip to main content

cachekit/l1/
mod.rs

1use bytes::Bytes;
2use quick_cache::sync::{Cache, DefaultLifecycle};
3use quick_cache::{OptionsBuilder, UnitWeighter};
4use std::hash::RandomState;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8#[derive(Clone)]
9struct L1Entry {
10    // Bytes, not Vec: quick_cache's `get` clones the entry, and a refcount
11    // bump is cheaper than a deep copy of a payload of up to the 5 MiB cap.
12    data: Bytes,
13    ttl: Duration,
14    created_at: Instant,
15    /// `created_at + ttl`, so a read compares one `Instant` instead of
16    /// subtracting two. `None` when the sum overflows `Instant`: that entry
17    /// never expires.
18    expires_at: Option<Instant>,
19    freshness_jitter: f64,
20}
21
22impl L1Entry {
23    fn is_live(&self, now: Instant) -> bool {
24        self.expires_at.is_none_or(|deadline| now < deadline)
25    }
26}
27
28/// SipHash-1-3 keyed from the OS RNG (std's `RandomState`), pinned as the
29/// hasher type parameter. quick_cache's default hasher is foldhash, which
30/// makes no claim to resist hash flooding. Its `custom-hasher` feature only
31/// changes `DefaultHashBuilder`, which this type never names, so no feature
32/// enabled anywhere in a build can swap the hasher.
33type Store = Cache<String, L1Entry, UnitWeighter, RandomState, DefaultLifecycle<String, L1Entry>>;
34
35/// Fewest entries a shard gets before L1 adds another. A full shard evicts
36/// even while other shards have room, so small shards lose hits on a working
37/// set near capacity. At 128, filling a 1,000-entry L1 with distinct keys
38/// loses about 2% of them to that, where quick_cache's own default of up to
39/// 32 shards loses about 7%, and the 4 shards still beat a single lock.
40const MIN_SHARD_ENTRIES: usize = 128;
41
42/// Most shards L1 uses: past this, another lock buys little.
43const MAX_SHARDS: usize = 64;
44
45/// The largest power of two at or below `capacity / MIN_SHARD_ENTRIES`,
46/// within `1..=MAX_SHARDS`. It depends on capacity alone, so eviction is the
47/// same on every host; quick_cache's own default scales with the core count.
48fn shard_count(capacity: usize) -> usize {
49    1 << (capacity / MIN_SHARD_ENTRIES).clamp(1, MAX_SHARDS).ilog2()
50}
51
52/// Outcome of an SWR-aware L1 read — see [`L1Cache::get_with_swr`].
53#[derive(Debug, Clone, PartialEq)]
54pub enum L1SwrRead {
55    /// Entry present and within its freshness window: serve as-is.
56    Fresh(Vec<u8>),
57    /// Entry present but past the freshness threshold (and before hard
58    /// expiry): serve it, but the caller should schedule a background
59    /// refresh.
60    Stale(Vec<u8>),
61    /// Entry absent or hard-expired: a normal (blocking) miss.
62    Miss,
63}
64
65/// [`L1SwrRead`] over a shared buffer: the client's copy-free read path.
66pub(crate) enum SharedSwrRead {
67    Fresh(Bytes),
68    Stale(Bytes),
69    Miss,
70}
71
72/// In-process cache with per-entry TTL, backed by [`quick_cache`].
73///
74/// Used as the L1 layer in the dual-layer cache architecture. `Clone` is
75/// cheap and shares the underlying store.
76///
77/// It never holds more than `capacity` entries. quick_cache evicts with a
78/// scan-resistant policy (a CLOCK-Pro variant close to S3-FIFO): a run of
79/// keys written once and never read does not flush the entries being read,
80/// as it would under LRU. Capacity is split into shards, `capacity / 128`
81/// rounded down to a power of two, from 1 to 64 (4 at the default 1,000),
82/// and a full shard evicts even while others have room.
83///
84/// No read returns an expired entry. An expired entry leaves memory when a
85/// read finds it, when eviction reaches it, or when [`Self::entry_count`] or
86/// [`Self::run_pending_tasks`] sweeps; until then it counts against capacity.
87#[derive(Clone)]
88pub struct L1Cache {
89    store: Arc<Store>,
90}
91
92impl L1Cache {
93    /// Create a new L1 cache that holds at most `capacity` entries.
94    pub fn new(capacity: usize) -> Self {
95        let shards = shard_count(capacity);
96        // Floor per shard, where quick_cache would round up, so the shards
97        // never hold more than `capacity` between them.
98        let weight = u64::try_from(capacity / shards * shards).unwrap_or(u64::MAX);
99        // `build` fails only when a capacity is unset, and both are set here.
100        // No fallback: quick_cache's own constructors pick shards by core
101        // count and round per-shard capacity up, which would break the bound.
102        #[allow(clippy::expect_used)]
103        let options = OptionsBuilder::new()
104            .shards(shards)
105            .estimated_items_capacity(capacity)
106            .weight_capacity(weight)
107            .build()
108            .expect("L1 options set both capacities");
109        Self {
110            store: Arc::new(Store::with_options(
111                options,
112                UnitWeighter,
113                RandomState::new(),
114                DefaultLifecycle::default(),
115            )),
116        }
117    }
118
119    /// Retrieve cached bytes by key, or `None` if absent or expired.
120    pub fn get(&self, key: &str) -> Option<Vec<u8>> {
121        self.get_shared(key).map(|data| data.to_vec())
122    }
123
124    /// [`Self::get`] without the copy: the stored buffer, by refcount.
125    pub(crate) fn get_shared(&self, key: &str) -> Option<Bytes> {
126        let entry = self.store.get(key)?;
127        let now = Instant::now();
128        if entry.is_live(now) {
129            return Some(entry.data);
130        }
131        self.remove_expired(key, now);
132        None
133    }
134
135    /// Whether a live (unexpired) entry exists, without reading its value.
136    ///
137    /// Unlike [`Self::get`], this does not count as an access for eviction.
138    pub(crate) fn contains(&self, key: &str) -> bool {
139        let Some(entry) = self.store.peek(key) else {
140            return false;
141        };
142        let now = Instant::now();
143        if entry.is_live(now) {
144            return true;
145        }
146        self.remove_expired(key, now);
147        false
148    }
149
150    /// Retrieve cached bytes with stale-while-revalidate classification.
151    ///
152    /// An entry is *fresh* until it has lived `threshold_ratio` of its own
153    /// TTL (±10% jitter, drawn once when the entry is inserted so a hot read
154    /// never touches the entropy source), *stale* from then until hard
155    /// expiry, and a *miss* after that. The freshness window
156    /// derives from the TTL the entry was **inserted** with: a direct write
157    /// carries the caller's full TTL, an L2 backfill carries the capped
158    /// backfill TTL — see `CacheKit`'s L1 documentation.
159    ///
160    /// Semantics mirror cachekit-py's `swr_threshold_ratio` (elapsed
161    /// lifetime > ratio × TTL ⇒ stale). Hard expiry is checked on every
162    /// read: an expired entry is never returned, so SWR can never serve past
163    /// it.
164    ///
165    /// This is a pure read — it does not track refresh state. Callers own
166    /// refresh scheduling and deduplication (the `#[cachekit]` macro uses
167    /// `CacheKit::single_flight`).
168    pub fn get_with_swr(&self, key: &str, threshold_ratio: f64) -> L1SwrRead {
169        match self.get_with_swr_shared(key, threshold_ratio) {
170            SharedSwrRead::Fresh(data) => L1SwrRead::Fresh(data.to_vec()),
171            SharedSwrRead::Stale(data) => L1SwrRead::Stale(data.to_vec()),
172            SharedSwrRead::Miss => L1SwrRead::Miss,
173        }
174    }
175
176    /// [`Self::get_with_swr`] without the copy: the stored buffer, by refcount.
177    pub(crate) fn get_with_swr_shared(&self, key: &str, threshold_ratio: f64) -> SharedSwrRead {
178        let Some(entry) = self.store.get(key) else {
179            return SharedSwrRead::Miss;
180        };
181        let now = Instant::now();
182        if !entry.is_live(now) {
183            self.remove_expired(key, now);
184            return SharedSwrRead::Miss;
185        }
186        let threshold = entry.ttl.as_secs_f64() * threshold_ratio * entry.freshness_jitter;
187        let age = now.saturating_duration_since(entry.created_at);
188        if age.as_secs_f64() > threshold {
189            SharedSwrRead::Stale(entry.data)
190        } else {
191            SharedSwrRead::Fresh(entry.data)
192        }
193    }
194
195    /// Insert or overwrite an entry with the given TTL.
196    ///
197    /// An overwrite is a new entry: its TTL and freshness window start now.
198    pub fn set(&self, key: &str, value: &[u8], ttl: Duration) {
199        self.set_shared(key, Bytes::copy_from_slice(value), ttl);
200    }
201
202    /// [`Self::set`] without the copy: L1 keeps `value`'s buffer.
203    pub(crate) fn set_shared(&self, key: &str, value: Bytes, ttl: Duration) {
204        let created_at = Instant::now();
205        self.store.insert(
206            key.to_owned(),
207            L1Entry {
208                data: value,
209                ttl,
210                created_at,
211                expires_at: created_at.checked_add(ttl),
212                freshness_jitter: 0.9 + crate::random_unit() * 0.2,
213            },
214        );
215    }
216
217    /// Remove an entry by key.
218    pub fn delete(&self, key: &str) {
219        self.store.remove(key);
220    }
221
222    /// Number of entries currently held.
223    ///
224    /// Sweeps expired entries first (see [`Self::run_pending_tasks`]), so the
225    /// figure is exact at the time of the call. The sweep visits every entry:
226    /// poll this, don't put it on a hot path.
227    pub fn entry_count(&self) -> u64 {
228        self.run_pending_tasks();
229        u64::try_from(self.store.len()).unwrap_or(u64::MAX)
230    }
231
232    /// Remove every expired entry now, one shard at a time.
233    ///
234    /// Reads never return an expired entry, so this only frees the memory
235    /// and the capacity they hold. It visits every entry, holding each
236    /// shard's write lock while it walks that shard.
237    pub fn run_pending_tasks(&self) {
238        let now = Instant::now();
239        self.store.retain(|_, entry| entry.is_live(now));
240    }
241
242    /// Remove the entry under `key` if it has expired by `now`, the instant a
243    /// read just found it expired. A write may have replaced that entry in
244    /// the meantime, so the check runs on whichever entry is current, under
245    /// the shard's write lock: a live replacement stays. Comparing the two
246    /// entries instead would not do, since two writes can share a clock
247    /// reading and an empty value's buffer.
248    fn remove_expired(&self, key: &str, now: Instant) {
249        self.store.remove_if(key, |current| !current.is_live(now));
250    }
251}
252
253#[cfg(test)]
254#[allow(clippy::expect_used)] // test-only: a failed thread or fixture should panic loudly
255mod tests {
256    use super::{shard_count, L1Cache, L1Entry, L1SwrRead};
257    use bytes::Bytes;
258    use std::time::{Duration, Instant};
259
260    const LONG: Duration = Duration::from_secs(60);
261
262    /// `contains` backs `CacheKit::exists`; it must honour per-entry TTL the
263    /// way `get` does, or a warm-key check would report an expired entry.
264    #[test]
265    fn contains_is_false_after_ttl_expiry() {
266        let cache = L1Cache::new(16);
267        cache.set("k", b"v", Duration::from_millis(50));
268        assert!(cache.contains("k"));
269        std::thread::sleep(Duration::from_millis(100));
270        assert!(!cache.contains("k"));
271    }
272
273    #[test]
274    fn shard_count_follows_capacity_alone() {
275        for (capacity, shards) in [
276            (0, 1),
277            (1, 1),
278            (255, 1),
279            (256, 2),
280            (1000, 4),
281            (1023, 4),
282            (1024, 8),
283            (8191, 32),
284            (8192, 64),
285            (usize::MAX, 64),
286        ] {
287            assert_eq!(shard_count(capacity), shards, "capacity {capacity}");
288        }
289        assert_eq!(L1Cache::new(1000).store.num_shards(), 4);
290    }
291
292    /// Per-shard capacity rounds down, so no capacity ever lets L1 hold more
293    /// entries than it was given, under any amount of churn.
294    #[test]
295    fn never_holds_more_than_its_capacity() {
296        for capacity in [0usize, 1, 2, 16, 127, 128, 255, 256, 1000, 1001, 4100] {
297            let cache = L1Cache::new(capacity);
298            assert!(
299                cache.store.capacity() <= capacity as u64,
300                "capacity {capacity}"
301            );
302            for i in 0..capacity * 3 + 10 {
303                cache.set(&format!("key-{i}"), b"v", LONG);
304                assert!(
305                    cache.store.len() <= capacity,
306                    "capacity {capacity} after {i}"
307                );
308            }
309        }
310    }
311
312    /// Capacity is a ceiling, not a reservation: quick_cache allocates its
313    /// tables as entries arrive, so a large capacity costs nothing up front.
314    #[test]
315    fn a_large_capacity_reserves_nothing_up_front() {
316        let cache = L1Cache::new(5_000_000);
317        assert_eq!(cache.store.memory_used().total(), 0);
318        cache.set("k", b"v", LONG);
319        assert!(cache.store.memory_used().total() > 0);
320    }
321
322    #[test]
323    fn zero_capacity_holds_nothing() {
324        let cache = L1Cache::new(0);
325        cache.set("k", b"v", LONG);
326        assert_eq!(cache.get("k"), None);
327        assert_eq!(cache.entry_count(), 0);
328    }
329
330    /// Every read that finds an expired entry removes it, so its memory is
331    /// freed without waiting for a sweep.
332    #[test]
333    fn a_read_that_finds_an_expired_entry_frees_it() {
334        let cache = L1Cache::new(16);
335        for key in ["get", "swr", "exists"] {
336            cache.set(key, b"v", Duration::from_millis(1));
337        }
338        std::thread::sleep(Duration::from_millis(20));
339        assert_eq!(cache.store.len(), 3);
340        assert_eq!(cache.get("get"), None);
341        assert_eq!(cache.get_with_swr("swr", 0.5), L1SwrRead::Miss);
342        assert!(!cache.contains("exists"));
343        assert_eq!(cache.store.len(), 0);
344    }
345
346    /// The removal after an expired read must not take a newer value that a
347    /// concurrent write put under the same key in the meantime.
348    #[test]
349    fn expired_removal_spares_a_newer_write() {
350        let cache = L1Cache::new(16);
351        cache.set("k", b"old", Duration::from_millis(1));
352        std::thread::sleep(Duration::from_millis(20));
353        let read_at = Instant::now();
354        cache.set("k", b"new", LONG);
355        cache.remove_expired("k", read_at);
356        assert_eq!(cache.get("k").as_deref(), Some(&b"new"[..]));
357    }
358
359    /// Two writes can share a clock reading, and every empty value shares one
360    /// buffer, so nothing in the expired entry a read saw tells it apart from
361    /// a live replacement. The removal has to judge the current entry's own
362    /// expiry instead.
363    #[test]
364    fn expired_removal_spares_a_replacement_with_the_same_stamp_and_buffer() {
365        let cache = L1Cache::new(16);
366        let stamp = Instant::now();
367        let written = |ttl| L1Entry {
368            data: Bytes::new(),
369            ttl,
370            created_at: stamp,
371            expires_at: stamp.checked_add(ttl),
372            freshness_jitter: 1.0,
373        };
374        cache
375            .store
376            .insert("k".to_owned(), written(Duration::from_nanos(1)));
377        let read_at = stamp + Duration::from_millis(1);
378        cache.store.insert("k".to_owned(), written(LONG));
379        cache.remove_expired("k", read_at);
380        assert!(
381            cache.store.peek("k").is_some(),
382            "the live replacement was removed"
383        );
384    }
385
386    #[test]
387    fn entry_count_sweeps_expired_entries() {
388        let cache = L1Cache::new(64);
389        for i in 0..5 {
390            cache.set(&format!("short-{i}"), b"v", Duration::from_millis(1));
391        }
392        for i in 0..3 {
393            cache.set(&format!("long-{i}"), b"v", LONG);
394        }
395        std::thread::sleep(Duration::from_millis(20));
396        assert_eq!(cache.store.len(), 8, "nothing has read or swept yet");
397        assert_eq!(cache.entry_count(), 3);
398        assert_eq!(cache.store.len(), 3);
399    }
400
401    /// Entries written in the past, so no sleep decides the outcome. TTL 10 s
402    /// at ratio 0.5 with no jitter: stale after 5 s, a miss from 10 s on.
403    #[test]
404    fn swr_goes_fresh_then_stale_then_miss() {
405        let ttl = Duration::from_secs(10);
406        let written = |ago: Duration| {
407            let created_at = Instant::now()
408                .checked_sub(ago)
409                .expect("the monotonic clock has run for more than 11 s");
410            L1Entry {
411                data: Bytes::from_static(b"v"),
412                ttl,
413                created_at,
414                expires_at: created_at.checked_add(ttl),
415                freshness_jitter: 1.0,
416            }
417        };
418        let cache = L1Cache::new(16);
419        for (key, ago) in [("fresh", 1), ("stale", 7), ("expired", 11)] {
420            cache
421                .store
422                .insert(key.to_owned(), written(Duration::from_secs(ago)));
423        }
424        let v = b"v".to_vec();
425        assert_eq!(
426            cache.get_with_swr("fresh", 0.5),
427            L1SwrRead::Fresh(v.clone())
428        );
429        assert_eq!(cache.get_with_swr("stale", 0.5), L1SwrRead::Stale(v));
430        assert_eq!(cache.get_with_swr("expired", 0.5), L1SwrRead::Miss);
431    }
432
433    /// `exists` must not protect an entry from eviction the way a read does.
434    /// At capacity 2 the second insert goes to the probationary queue, and
435    /// the next insert evicts it unless it was read in between.
436    #[test]
437    fn exists_does_not_count_as_a_read() {
438        let peeked = L1Cache::new(2);
439        peeked.set("a", b"1", LONG);
440        peeked.set("b", b"2", LONG);
441        assert!(peeked.contains("b"));
442        peeked.set("c", b"3", LONG);
443        assert!(peeked.store.peek("b").is_none(), "exists kept b alive");
444
445        let read = L1Cache::new(2);
446        read.set("a", b"1", LONG);
447        read.set("b", b"2", LONG);
448        assert!(read.get("b").is_some());
449        read.set("c", b"3", LONG);
450        assert!(
451            read.store.peek("b").is_some(),
452            "a read did not keep b alive"
453        );
454    }
455
456    /// One pass over keys that are never read again must not evict entries
457    /// that were read, which an LRU would do. L1 is first filled with keys
458    /// nobody reads, so the later keys cannot go straight into quick_cache's
459    /// main queue the way they would in an empty cache; the same run without
460    /// the reads is the control, and it keeps none of them.
461    #[test]
462    fn a_scan_does_not_flush_entries_that_were_read() {
463        fn kept_after_scan(read: bool) -> usize {
464            let cache = L1Cache::new(100);
465            for i in 0..100 {
466                cache.set(&format!("filler-{i}"), b"v", LONG);
467            }
468            for i in 0..50 {
469                let key = format!("hot-{i}");
470                cache.set(&key, b"v", LONG);
471                if read {
472                    assert!(cache.get(&key).is_some());
473                    assert!(cache.get(&key).is_some());
474                }
475            }
476            for i in 0..1000 {
477                cache.set(&format!("scan-{i}"), b"v", LONG);
478            }
479            (0..50)
480                .filter(|i| cache.store.peek(&format!("hot-{i}")).is_some())
481                .count()
482        }
483        assert_eq!(kept_after_scan(true), 50);
484        assert_eq!(kept_after_scan(false), 0);
485    }
486
487    /// Many threads mixing every operation on shared keys, with TTLs short
488    /// enough to expire mid-run and more keys than capacity: no read returns
489    /// another key's value, occupancy never passes capacity, and once every
490    /// TTL has passed the count is exact at zero.
491    #[test]
492    fn concurrent_mixed_operations_keep_the_invariants() {
493        const THREADS: u64 = 8;
494        const OPS: u64 = 20_000;
495        const KEYS: u64 = 64;
496        const CAPACITY: usize = 32;
497
498        fn next(state: &mut u64) -> u64 {
499            *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
500            let mut z = *state;
501            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
502            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
503            z ^ (z >> 31)
504        }
505
506        let cache = L1Cache::new(CAPACITY);
507        let workers: Vec<_> = (0..THREADS)
508            .map(|t| {
509                let cache = cache.clone();
510                std::thread::spawn(move || {
511                    let mut rng = t;
512                    for i in 0..OPS {
513                        let key = format!("k{}", next(&mut rng) % KEYS);
514                        let owned_by_key = |value: &[u8]| {
515                            assert!(
516                                value.starts_with(format!("{key}:").as_bytes()),
517                                "{key} returned {}",
518                                String::from_utf8_lossy(value)
519                            );
520                        };
521                        match next(&mut rng) % 6 {
522                            0 | 1 => {
523                                let ttl = Duration::from_millis(1 + next(&mut rng) % 20);
524                                cache.set(&key, format!("{key}:{t}:{i}").as_bytes(), ttl);
525                            }
526                            2 => {
527                                if let Some(value) = cache.get(&key) {
528                                    owned_by_key(&value);
529                                }
530                            }
531                            3 => match cache.get_with_swr(&key, 0.5) {
532                                L1SwrRead::Fresh(value) | L1SwrRead::Stale(value) => {
533                                    owned_by_key(&value);
534                                }
535                                L1SwrRead::Miss => {}
536                            },
537                            4 => {
538                                cache.contains(&key);
539                            }
540                            _ => cache.delete(&key),
541                        }
542                        assert!(cache.store.len() <= CAPACITY);
543                    }
544                })
545            })
546            .collect();
547        for worker in workers {
548            worker.join().expect("a worker thread panicked");
549        }
550        assert!(cache.entry_count() <= CAPACITY as u64);
551        std::thread::sleep(Duration::from_millis(30));
552        assert_eq!(cache.entry_count(), 0);
553    }
554}