Skip to main content

mesofact_core/proxy/
cache.rs

1//! Mode 2 response cache — in-memory LRU keyed by the 7-input SHA-256
2//! composition from `.yah/docs/architecture/mesofact.md` §"Cache-key
3//! composition", with the fresh / stale (SWR) / expired state machine from
4//! §"Mode 2 caching beyond TTL".
5//!
6//! Invalidation is generation-driven, not purge-driven: each source's current
7//! generation token (sqlite file mtime, r2 Last-Modified, …) is folded into the
8//! key (input 6), so a backend bump yields a different key and an automatic
9//! miss on the next request — no reverse tag index needed in the hot path.
10
11use lru::LruCache;
12use sha2::{Digest, Sha256};
13use std::collections::BTreeMap;
14use std::num::NonZeroUsize;
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18/// Default LRU capacity (entries). Tunable via `ResponseCache::with_capacity`.
19pub const DEFAULT_CAPACITY: usize = 4096;
20
21/// The ordered inputs to the cache key. Maps are `BTreeMap` so iteration is
22/// already sorted — input 3 (params), 4 (query), 5 (vary), and 6 (source
23/// generations) all require deterministic key order.
24#[derive(Debug, Clone)]
25pub struct KeyInputs<'a> {
26    /// 1. Whole-cache buster — changes every deploy.
27    pub build_id: &'a str,
28    /// 2. The route *pattern* (`/p/:id`), not the resolved URL.
29    pub route_pattern: &'a str,
30    /// 3. Resolved param map, keys sorted.
31    pub params: &'a BTreeMap<String, String>,
32    /// 4. Query string, keys sorted.
33    pub query: &'a BTreeMap<String, String>,
34    /// 5. `cache_policy.vary` header values, keyed by header name (sorted).
35    pub vary: &'a BTreeMap<String, String>,
36    /// 6. `source_name → generation` for every source in `source_reads`.
37    pub source_generations: &'a BTreeMap<String, String>,
38    /// 7. `user.id` (or `"_anon"`) — `Some` only when the route `requires` user.
39    pub user_id: Option<&'a str>,
40}
41
42/// Compose the SHA-256 cache key as a lowercase hex string. Each field is fed
43/// with a label and a record separator so component boundaries can't collide
44/// (e.g. a param value can't impersonate a query key).
45pub fn compose_key(inputs: &KeyInputs) -> String {
46    let mut h = Sha256::new();
47    feed(&mut h, b"build_id", inputs.build_id.as_bytes());
48    feed(&mut h, b"route", inputs.route_pattern.as_bytes());
49    feed_map(&mut h, b"params", inputs.params);
50    feed_map(&mut h, b"query", inputs.query);
51    feed_map(&mut h, b"vary", inputs.vary);
52    feed_map(&mut h, b"gen", inputs.source_generations);
53    if let Some(uid) = inputs.user_id {
54        feed(&mut h, b"user", uid.as_bytes());
55    }
56    hex::encode(h.finalize())
57}
58
59fn feed(h: &mut Sha256, label: &[u8], value: &[u8]) {
60    h.update(label);
61    h.update([0u8]);
62    h.update(value);
63    h.update([0x1eu8]); // ASCII record separator
64}
65
66fn feed_map(h: &mut Sha256, label: &[u8], map: &BTreeMap<String, String>) {
67    h.update(label);
68    h.update([0x02u8]); // start-of-map
69    for (k, v) in map {
70        h.update(k.as_bytes());
71        h.update([0x1fu8]); // unit separator between k and v
72        h.update(v.as_bytes());
73        h.update([0x1eu8]);
74    }
75    h.update([0x03u8]); // end-of-map
76}
77
78/// Freshness verdict for a stored entry at a given instant.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CacheState {
81    /// `age < ttl` — serve as-is.
82    Fresh,
83    /// `ttl <= age < ttl + swr` — serve, kick off async re-render.
84    Stale,
85    /// `age >= ttl + swr` — synchronous miss.
86    Expired,
87}
88
89impl CacheState {
90    /// Metric/`X-Mesofact-Cache` label.
91    pub fn label(self) -> &'static str {
92        match self {
93            CacheState::Fresh => "fresh",
94            CacheState::Stale => "stale",
95            CacheState::Expired => "expired",
96        }
97    }
98}
99
100/// A cached render result plus the TTL/SWR window it was stored under.
101#[derive(Debug, Clone)]
102pub struct CacheEntry {
103    pub status: u16,
104    pub html: String,
105    pub headers: BTreeMap<String, String>,
106    pub ttl: Duration,
107    pub swr: Duration,
108    pub stored_at: Instant,
109}
110
111impl CacheEntry {
112    pub fn state_at(&self, now: Instant) -> CacheState {
113        let age = now.saturating_duration_since(self.stored_at);
114        if age < self.ttl {
115            CacheState::Fresh
116        } else if age < self.ttl + self.swr {
117            CacheState::Stale
118        } else {
119            CacheState::Expired
120        }
121    }
122
123    pub fn state(&self) -> CacheState {
124        self.state_at(Instant::now())
125    }
126}
127
128/// Thread-safe LRU response cache. `get`/`insert` take `&self`; the `Mutex`
129/// guards the `LruCache` (whose `get` needs `&mut` to bump recency).
130pub struct ResponseCache {
131    inner: Mutex<LruCache<String, CacheEntry>>,
132}
133
134impl ResponseCache {
135    pub fn new() -> Self {
136        Self::with_capacity(DEFAULT_CAPACITY)
137    }
138
139    pub fn with_capacity(cap: usize) -> Self {
140        let cap = NonZeroUsize::new(cap).unwrap_or(NonZeroUsize::new(1).unwrap());
141        Self { inner: Mutex::new(LruCache::new(cap)) }
142    }
143
144    /// Look up an entry, bumping its recency. Returns a clone so the lock is
145    /// released before the caller inspects state / rebuilds a response.
146    pub fn get(&self, key: &str) -> Option<CacheEntry> {
147        self.inner.lock().unwrap().get(key).cloned()
148    }
149
150    pub fn insert(&self, key: String, entry: CacheEntry) {
151        self.inner.lock().unwrap().put(key, entry);
152    }
153
154    /// Drop a single key (used by an SWR refresh that produced an uncacheable
155    /// result, e.g. a 5xx — see `should_cache`).
156    pub fn remove(&self, key: &str) {
157        self.inner.lock().unwrap().pop(key);
158    }
159
160    pub fn len(&self) -> usize {
161        self.inner.lock().unwrap().len()
162    }
163
164    pub fn is_empty(&self) -> bool {
165        self.len() == 0
166    }
167}
168
169impl Default for ResponseCache {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175/// Caching policy by status class (§"Mode 2 caching beyond TTL"):
176/// 2xx/3xx cache under `ttl`+`swr`; non-2xx (4xx) cache under `negative_ttl`
177/// with no SWR window; 5xx is never cached. Returns `(ttl, swr)` to store
178/// under, or `None` to skip caching.
179pub fn cache_window(
180    status: u16,
181    ttl: Duration,
182    swr: Duration,
183    negative_ttl: Duration,
184) -> Option<(Duration, Duration)> {
185    if (500..600).contains(&status) {
186        None
187    } else if (200..400).contains(&status) {
188        Some((ttl, swr))
189    } else {
190        // 4xx and other non-2xx: negative cache, no stale window.
191        Some((negative_ttl, Duration::ZERO))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
200        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
201    }
202
203    fn base_inputs<'a>(
204        build_id: &'a str,
205        empty: &'a BTreeMap<String, String>,
206    ) -> KeyInputs<'a> {
207        KeyInputs {
208            build_id,
209            route_pattern: "/p/:id",
210            params: empty,
211            query: empty,
212            vary: empty,
213            source_generations: empty,
214            user_id: None,
215        }
216    }
217
218    #[test]
219    fn key_is_deterministic_and_64_hex_chars() {
220        let empty = map(&[]);
221        let k1 = compose_key(&base_inputs("b1", &empty));
222        let k2 = compose_key(&base_inputs("b1", &empty));
223        assert_eq!(k1, k2);
224        assert_eq!(k1.len(), 64);
225        assert!(k1.chars().all(|c| c.is_ascii_hexdigit()));
226    }
227
228    #[test]
229    fn build_id_busts_the_key() {
230        let empty = map(&[]);
231        assert_ne!(
232            compose_key(&base_inputs("b1", &empty)),
233            compose_key(&base_inputs("b2", &empty)),
234        );
235    }
236
237    #[test]
238    fn source_generation_bump_changes_the_key() {
239        let empty = map(&[]);
240        let g1 = map(&[("project_db", "100")]);
241        let g2 = map(&[("project_db", "200")]);
242        let mut a = base_inputs("b1", &empty);
243        a.source_generations = &g1;
244        let mut b = base_inputs("b1", &empty);
245        b.source_generations = &g2;
246        assert_ne!(compose_key(&a), compose_key(&b));
247    }
248
249    #[test]
250    fn user_id_only_folds_in_when_present() {
251        let empty = map(&[]);
252        let anon = base_inputs("b1", &empty);
253        let mut with_user = base_inputs("b1", &empty);
254        with_user.user_id = Some("u42");
255        assert_ne!(compose_key(&anon), compose_key(&with_user));
256    }
257
258    #[test]
259    fn map_field_boundaries_do_not_collide() {
260        // params {a: "b"} must not hash the same as params {} + query {a: "b"}.
261        let empty = map(&[]);
262        let ab = map(&[("a", "b")]);
263        let mut as_param = base_inputs("b1", &empty);
264        as_param.params = &ab;
265        let mut as_query = base_inputs("b1", &empty);
266        as_query.query = &ab;
267        assert_ne!(compose_key(&as_param), compose_key(&as_query));
268    }
269
270    fn entry(ttl: u64, swr: u64, stored_ago: Duration) -> CacheEntry {
271        CacheEntry {
272            status: 200,
273            html: "x".into(),
274            headers: BTreeMap::new(),
275            ttl: Duration::from_secs(ttl),
276            swr: Duration::from_secs(swr),
277            stored_at: Instant::now() - stored_ago,
278        }
279    }
280
281    #[test]
282    fn state_machine_fresh_stale_expired() {
283        // ttl=60, swr=300 → fresh <60s, stale 60..360s, expired ≥360s.
284        assert_eq!(entry(60, 300, Duration::from_secs(10)).state(), CacheState::Fresh);
285        assert_eq!(entry(60, 300, Duration::from_secs(120)).state(), CacheState::Stale);
286        assert_eq!(entry(60, 300, Duration::from_secs(400)).state(), CacheState::Expired);
287    }
288
289    #[test]
290    fn zero_ttl_zero_swr_is_immediately_expired() {
291        assert_eq!(entry(0, 0, Duration::from_millis(1)).state(), CacheState::Expired);
292    }
293
294    #[test]
295    fn cache_window_classes() {
296        let ttl = Duration::from_secs(60);
297        let swr = Duration::from_secs(300);
298        let neg = Duration::from_secs(10);
299        // 2xx/3xx → (ttl, swr)
300        assert_eq!(cache_window(200, ttl, swr, neg), Some((ttl, swr)));
301        assert_eq!(cache_window(302, ttl, swr, neg), Some((ttl, swr)));
302        // 4xx → negative cache, no swr
303        assert_eq!(cache_window(404, ttl, swr, neg), Some((neg, Duration::ZERO)));
304        // 5xx → never cached
305        assert_eq!(cache_window(503, ttl, swr, neg), None);
306    }
307
308    #[test]
309    fn lru_round_trip_and_recency_eviction() {
310        let cache = ResponseCache::with_capacity(2);
311        cache.insert("a".into(), entry(60, 0, Duration::ZERO));
312        cache.insert("b".into(), entry(60, 0, Duration::ZERO));
313        // Touch "a" so "b" becomes the LRU victim.
314        assert!(cache.get("a").is_some());
315        cache.insert("c".into(), entry(60, 0, Duration::ZERO));
316        assert!(cache.get("a").is_some());
317        assert!(cache.get("b").is_none(), "b should have been evicted");
318        assert!(cache.get("c").is_some());
319    }
320}