cachekit/l1/mod.rs
1use bytes::Bytes;
2use moka::sync::Cache;
3use moka::Expiry;
4use std::time::{Duration, Instant};
5
6#[derive(Clone)]
7struct L1Entry {
8 // Bytes, not Vec: moka's `get` clones the entry, and a refcount bump is
9 // cheaper than a deep copy of a payload of up to the 5 MiB cap.
10 data: Bytes,
11 ttl: Duration,
12 created_at: Instant,
13 freshness_jitter: f64,
14}
15
16struct L1Expiry;
17
18impl Expiry<String, L1Entry> for L1Expiry {
19 fn expire_after_create(
20 &self,
21 _key: &String,
22 value: &L1Entry,
23 _created_at: std::time::Instant,
24 ) -> Option<Duration> {
25 Some(value.ttl)
26 }
27
28 fn expire_after_update(
29 &self,
30 _key: &String,
31 value: &L1Entry,
32 _updated_at: std::time::Instant,
33 _duration_until_expiry: Option<Duration>,
34 ) -> Option<Duration> {
35 // A successful SWR refresh is an update of the existing moka entry.
36 // Returning the replacement value's TTL renews hard expiry from the
37 // refresh commit instead of retaining the original create deadline.
38 Some(value.ttl)
39 }
40}
41
42/// Outcome of an SWR-aware L1 read — see [`L1Cache::get_with_swr`].
43#[derive(Debug, Clone, PartialEq)]
44pub enum L1SwrRead {
45 /// Entry present and within its freshness window: serve as-is.
46 Fresh(Vec<u8>),
47 /// Entry present but past the freshness threshold (and before hard
48 /// expiry): serve it, but the caller should schedule a background
49 /// refresh.
50 Stale(Vec<u8>),
51 /// Entry absent or hard-expired: a normal (blocking) miss.
52 Miss,
53}
54
55/// [`L1SwrRead`] over a shared buffer: the client's copy-free read path.
56pub(crate) enum SharedSwrRead {
57 Fresh(Bytes),
58 Stale(Bytes),
59 Miss,
60}
61
62/// In-process LRU cache with per-entry TTL, backed by [`moka`].
63///
64/// Used as the L1 layer in the dual-layer cache architecture. `Clone` is
65/// cheap and shares the underlying store (moka is internally referenced).
66#[derive(Clone)]
67pub struct L1Cache {
68 store: Cache<String, L1Entry>,
69}
70
71impl L1Cache {
72 /// Create a new L1 cache with the given maximum entry capacity.
73 pub fn new(capacity: usize) -> Self {
74 Self {
75 store: Cache::builder()
76 .max_capacity(u64::try_from(capacity).unwrap_or(u64::MAX))
77 .expire_after(L1Expiry)
78 .build(),
79 }
80 }
81
82 /// Retrieve cached bytes by key, or `None` if absent or expired.
83 pub fn get(&self, key: &str) -> Option<Vec<u8>> {
84 self.get_shared(key).map(|data| data.to_vec())
85 }
86
87 /// [`Self::get`] without the copy: the stored buffer, by refcount.
88 pub(crate) fn get_shared(&self, key: &str) -> Option<Bytes> {
89 self.store.get(key).map(|entry| entry.data)
90 }
91
92 /// Whether a live (unexpired) entry exists, without reading its value.
93 ///
94 /// Unlike [`Self::get`], this does not count as an access for moka's
95 /// eviction policy.
96 pub(crate) fn contains(&self, key: &str) -> bool {
97 self.store.contains_key(key)
98 }
99
100 /// Retrieve cached bytes with stale-while-revalidate classification.
101 ///
102 /// An entry is *fresh* until it has lived `threshold_ratio` of its own
103 /// TTL (±10% jitter, drawn once when the entry is inserted so a hot read
104 /// never touches the entropy source), *stale* from then until hard
105 /// expiry, and a *miss* after that. The freshness window
106 /// derives from the TTL the entry was **inserted** with: a direct write
107 /// carries the caller's full TTL, an L2 backfill carries the capped
108 /// backfill TTL — see `CacheKit`'s L1 documentation.
109 ///
110 /// Semantics mirror cachekit-py's `swr_threshold_ratio` (elapsed
111 /// lifetime > ratio × TTL ⇒ stale). Hard expiry is enforced by moka:
112 /// an expired entry is never returned, so SWR can never serve past it.
113 ///
114 /// This is a pure read — it does not track refresh state. Callers own
115 /// refresh scheduling and deduplication (the `#[cachekit]` macro uses
116 /// `CacheKit::single_flight`).
117 pub fn get_with_swr(&self, key: &str, threshold_ratio: f64) -> L1SwrRead {
118 match self.get_with_swr_shared(key, threshold_ratio) {
119 SharedSwrRead::Fresh(data) => L1SwrRead::Fresh(data.to_vec()),
120 SharedSwrRead::Stale(data) => L1SwrRead::Stale(data.to_vec()),
121 SharedSwrRead::Miss => L1SwrRead::Miss,
122 }
123 }
124
125 /// [`Self::get_with_swr`] without the copy: the stored buffer, by refcount.
126 pub(crate) fn get_with_swr_shared(&self, key: &str, threshold_ratio: f64) -> SharedSwrRead {
127 let Some(entry) = self.store.get(key) else {
128 return SharedSwrRead::Miss;
129 };
130 let threshold = entry.ttl.as_secs_f64() * threshold_ratio * entry.freshness_jitter;
131 if entry.created_at.elapsed().as_secs_f64() > threshold {
132 SharedSwrRead::Stale(entry.data)
133 } else {
134 SharedSwrRead::Fresh(entry.data)
135 }
136 }
137
138 /// Insert or overwrite an entry with the given TTL.
139 pub fn set(&self, key: &str, value: &[u8], ttl: Duration) {
140 self.set_shared(key, Bytes::copy_from_slice(value), ttl);
141 }
142
143 /// [`Self::set`] without the copy: L1 keeps `value`'s buffer.
144 pub(crate) fn set_shared(&self, key: &str, value: Bytes, ttl: Duration) {
145 self.store.insert(
146 key.to_string(),
147 L1Entry {
148 data: value,
149 ttl,
150 created_at: Instant::now(),
151 freshness_jitter: 0.9 + crate::random_unit() * 0.2,
152 },
153 );
154 }
155
156 /// Remove an entry by key.
157 pub fn delete(&self, key: &str) {
158 self.store.invalidate(key);
159 }
160
161 /// Number of entries currently held.
162 ///
163 /// Runs moka's pending housekeeping first, so expired and evicted entries
164 /// are already gone from the count — the exact figure at the time of the
165 /// call, not moka's eventually-consistent estimate.
166 pub fn entry_count(&self) -> u64 {
167 self.store.run_pending_tasks();
168 self.store.entry_count()
169 }
170
171 /// Drive moka's internal eviction machinery. Useful in tests to force
172 /// pending invalidations and expiry checks to complete synchronously.
173 pub fn run_pending_tasks(&self) {
174 self.store.run_pending_tasks();
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::L1Cache;
181 use std::time::Duration;
182
183 /// `contains` backs `CacheKit::exists`; it must honour per-entry TTL the
184 /// way `get` does, or a warm-key check would report an expired entry.
185 #[test]
186 fn contains_is_false_after_ttl_expiry() {
187 let cache = L1Cache::new(16);
188 cache.set("k", b"v", Duration::from_millis(50));
189 assert!(cache.contains("k"));
190 std::thread::sleep(Duration::from_millis(100));
191 assert!(!cache.contains("k"));
192 }
193}