cachekit/l1/mod.rs
1use moka::sync::Cache;
2use moka::Expiry;
3use std::time::{Duration, Instant};
4
5#[derive(Clone)]
6struct L1Entry {
7 data: Vec<u8>,
8 ttl: Duration,
9 created_at: Instant,
10 freshness_jitter: f64,
11}
12
13struct L1Expiry;
14
15impl Expiry<String, L1Entry> for L1Expiry {
16 fn expire_after_create(
17 &self,
18 _key: &String,
19 value: &L1Entry,
20 _created_at: std::time::Instant,
21 ) -> Option<Duration> {
22 Some(value.ttl)
23 }
24
25 fn expire_after_update(
26 &self,
27 _key: &String,
28 value: &L1Entry,
29 _updated_at: std::time::Instant,
30 _duration_until_expiry: Option<Duration>,
31 ) -> Option<Duration> {
32 // A successful SWR refresh is an update of the existing moka entry.
33 // Returning the replacement value's TTL renews hard expiry from the
34 // refresh commit instead of retaining the original create deadline.
35 Some(value.ttl)
36 }
37}
38
39/// Outcome of an SWR-aware L1 read — see [`L1Cache::get_with_swr`].
40#[derive(Debug, Clone, PartialEq)]
41pub enum L1SwrRead {
42 /// Entry present and within its freshness window: serve as-is.
43 Fresh(Vec<u8>),
44 /// Entry present but past the freshness threshold (and before hard
45 /// expiry): serve it, but the caller should schedule a background
46 /// refresh.
47 Stale(Vec<u8>),
48 /// Entry absent or hard-expired: a normal (blocking) miss.
49 Miss,
50}
51
52/// In-process LRU cache with per-entry TTL, backed by [`moka`].
53///
54/// Used as the L1 layer in the dual-layer cache architecture. `Clone` is
55/// cheap and shares the underlying store (moka is internally referenced).
56#[derive(Clone)]
57pub struct L1Cache {
58 store: Cache<String, L1Entry>,
59}
60
61impl L1Cache {
62 /// Create a new L1 cache with the given maximum entry capacity.
63 pub fn new(capacity: usize) -> Self {
64 Self {
65 store: Cache::builder()
66 .max_capacity(u64::try_from(capacity).unwrap_or(u64::MAX))
67 .expire_after(L1Expiry)
68 .build(),
69 }
70 }
71
72 /// Retrieve cached bytes by key, or `None` if absent or expired.
73 pub fn get(&self, key: &str) -> Option<Vec<u8>> {
74 self.store.get(key).map(|entry| entry.data.clone())
75 }
76
77 /// Retrieve cached bytes with stale-while-revalidate classification.
78 ///
79 /// An entry is *fresh* until it has lived `threshold_ratio` of its own
80 /// TTL (±10% jitter, drawn once when the entry is inserted so a hot read
81 /// never touches the entropy source), *stale* from then until hard
82 /// expiry, and a *miss* after that. The freshness window
83 /// derives from the TTL the entry was **inserted** with: a direct write
84 /// carries the caller's full TTL, an L2 backfill carries the capped
85 /// backfill TTL — see `CacheKit`'s L1 documentation.
86 ///
87 /// Semantics mirror cachekit-py's `swr_threshold_ratio` (elapsed
88 /// lifetime > ratio × TTL ⇒ stale). Hard expiry is enforced by moka:
89 /// an expired entry is never returned, so SWR can never serve past it.
90 ///
91 /// This is a pure read — it does not track refresh state. Callers own
92 /// refresh scheduling and deduplication (the `#[cachekit]` macro uses
93 /// `CacheKit::single_flight`).
94 pub fn get_with_swr(&self, key: &str, threshold_ratio: f64) -> L1SwrRead {
95 let Some(entry) = self.store.get(key) else {
96 return L1SwrRead::Miss;
97 };
98 let threshold = entry.ttl.as_secs_f64() * threshold_ratio * entry.freshness_jitter;
99 if entry.created_at.elapsed().as_secs_f64() > threshold {
100 L1SwrRead::Stale(entry.data.clone())
101 } else {
102 L1SwrRead::Fresh(entry.data.clone())
103 }
104 }
105
106 /// Insert or overwrite an entry with the given TTL.
107 pub fn set(&self, key: &str, value: &[u8], ttl: Duration) {
108 self.store.insert(
109 key.to_string(),
110 L1Entry {
111 data: value.to_vec(),
112 ttl,
113 created_at: Instant::now(),
114 freshness_jitter: 0.9 + crate::random_unit() * 0.2,
115 },
116 );
117 }
118
119 /// Remove an entry by key.
120 pub fn delete(&self, key: &str) {
121 self.store.invalidate(key);
122 }
123
124 /// Drive moka's internal eviction machinery. Useful in tests to force
125 /// pending invalidations and expiry checks to complete synchronously.
126 pub fn run_pending_tasks(&self) {
127 self.store.run_pending_tasks();
128 }
129}