Skip to main content

axon/
cache_runtime.rs

1//! §Fase 85.d — the result-memoization cache core.
2//!
3//! This is the production-hardened runtime behind the `cache` primitive. The
4//! type checker (§85.c) already proved WHAT is safe to cache (a `pure` tool by
5//! construction; a widened one only with a finite TTL); this module implements
6//! HOW, with the properties a naïve cache omits and that cause real outages:
7//!
8//! - **Content-addressed, deploy-safe, tenant-isolated keys (D85.7):** the key
9//!   is a hash of `(tenant ‖ cache ‖ tool ‖ tool-declaration-fingerprint ‖
10//!   output_type ‖ selected params)`. A redeploy that changes a tool changes
11//!   its fingerprint → a new key → no stale cross-deploy hit; the tenant is a
12//!   key component → no cross-tenant leak even if a backend mis-namespaces.
13//! - **Single-flight (D85.8):** concurrent misses for one key compute ONCE;
14//!   the rest wait for that result (no thundering herd).
15//! - **Provable-forever, never non-deterministic-forever (D85.9):** enforced at
16//!   compile time; the runtime simply honours the (optional) TTL.
17//! - **Production hygiene (D85.10):** errors are never cached; oversized values
18//!   are not cached (never truncated into a wrong value); TTL expiry is
19//!   *jittered* (deterministically, per key) so entries don't expire in a herd.
20//!
21//! The `CacheBackend` trait lets the enterprise inject a Redis (multi-replica)
22//! tier; with none injected, the in-process tier is fully functional
23//! single-replica.
24
25use std::collections::HashMap;
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29use sha2::{Digest, Sha256};
30
31use axon_frontend::ir_nodes::{IRCache, IRProgram, IRToolSpec};
32
33/// Default cap on the in-process tier (entries), mirroring `IdempotencyStore`.
34pub const DEFAULT_CAPACITY: usize = 10_000;
35/// Default per-value size ceiling (bytes). An oversized result is simply not
36/// cached (D85.10) — never truncated into a wrong value.
37pub const DEFAULT_MAX_VALUE_BYTES: usize = 512 * 1024;
38
39// ── Duration parsing (mirrors the lexer's `<n><unit>` Duration token) ────────
40
41/// Parse a duration literal (`"10s"`, `"500ms"`, `"5m"`, `"2h"`, `"1d"`) to a
42/// `Duration`. `None` for a malformed string (the lexer already guarantees the
43/// shape for a `ttl:` field, so this is defence in depth).
44pub fn parse_duration(s: &str) -> Option<Duration> {
45    let s = s.trim();
46    if s.is_empty() {
47        return None;
48    }
49    let (num, unit): (&str, &str) = if let Some(p) = s.strip_suffix("ms") {
50        (p, "ms")
51    } else if let Some(p) = s.strip_suffix('s') {
52        (p, "s")
53    } else if let Some(p) = s.strip_suffix('m') {
54        (p, "m")
55    } else if let Some(p) = s.strip_suffix('h') {
56        (p, "h")
57    } else if let Some(p) = s.strip_suffix('d') {
58        (p, "d")
59    } else {
60        return None;
61    };
62    let n: u64 = num.parse().ok()?;
63    Some(match unit {
64        "ms" => Duration::from_millis(n),
65        "s" => Duration::from_secs(n),
66        "m" => Duration::from_secs(n * 60),
67        "h" => Duration::from_secs(n * 3600),
68        "d" => Duration::from_secs(n * 86400),
69        _ => return None,
70    })
71}
72
73// ── Content-addressed key derivation (D85.7) ─────────────────────────────────
74
75fn hex(bytes: &[u8]) -> String {
76    let mut s = String::with_capacity(bytes.len() * 2);
77    for b in bytes {
78        s.push_str(&format!("{b:02x}"));
79    }
80    s
81}
82
83/// A length-prefixed hash component — length-prefixing makes element boundaries
84/// forgery-proof (no value can fake a boundary, the §84 argv-hash discipline
85/// strengthened with explicit lengths).
86fn update_part(h: &mut Sha256, part: &str) {
87    h.update((part.len() as u64).to_le_bytes());
88    h.update(part.as_bytes());
89}
90
91/// The stable fingerprint of a tool's DECLARATION — a hash of its IR spec. A
92/// redeploy that changes the tool's provider, effects, output type, or
93/// parameters changes this, so a behaviour change can never serve a result
94/// cached under the old behaviour (D85.7).
95pub fn tool_fingerprint(tool: &IRToolSpec) -> String {
96    match serde_json::to_vec(tool) {
97        Ok(bytes) => {
98            let mut h = Sha256::new();
99            h.update(&bytes);
100            hex(&h.finalize())[..16].to_string()
101        }
102        Err(_) => "unfingerprintable".to_string(),
103    }
104}
105
106/// Derive the content-addressed cache key. `key_args` are the selected
107/// `(param_name, value)` pairs (the full bound set, or the `key:` subset).
108pub fn derive_key(
109    tenant: &str,
110    cache_name: &str,
111    tool_name: &str,
112    tool_fingerprint: &str,
113    output_type: &str,
114    key_args: &[(String, String)],
115) -> String {
116    let mut h = Sha256::new();
117    for part in [tenant, cache_name, tool_name, tool_fingerprint, output_type] {
118        update_part(&mut h, part);
119    }
120    // Sort so argument order never changes the key.
121    let mut sorted: Vec<&(String, String)> = key_args.iter().collect();
122    sorted.sort();
123    update_part(&mut h, &format!("__argc={}", sorted.len()));
124    for (k, v) in sorted {
125        update_part(&mut h, k);
126        update_part(&mut h, v);
127    }
128    hex(&h.finalize())
129}
130
131// ── The backend trait + in-process tier ──────────────────────────────────────
132
133/// A pluggable cache tier. `namespace` is the cache declaration's name so
134/// `invalidate` can flush exactly one cache's entries. The enterprise injects a
135/// Redis impl of this; the OSS default is [`InProcessCache`].
136pub trait CacheBackend: Send + Sync {
137    fn get(&self, namespace: &str, key: &str) -> Option<Vec<u8>>;
138    fn put(&self, namespace: &str, key: &str, value: Vec<u8>, ttl: Option<Duration>);
139    /// Flush every entry belonging to `namespace` (an `emit` on an
140    /// `invalidate_on:` channel triggers this).
141    fn invalidate(&self, namespace: &str);
142}
143
144struct Entry {
145    value: Vec<u8>,
146    expires_at: Option<Instant>,
147    last_access: Instant,
148}
149
150struct State {
151    entries: HashMap<(String, String), Entry>,
152    capacity: usize,
153    max_value_bytes: usize,
154}
155
156/// The OSS default single-replica tier: a bounded map with per-entry TTL
157/// (jittered), LRU eviction, a value-size bound, and single-flight miss
158/// coalescing via per-key locks.
159pub struct InProcessCache {
160    state: Mutex<State>,
161    /// Per-key locks that serialise concurrent computers for the same key
162    /// (single-flight, D85.8). Held only during a compute; opportunistically
163    /// reclaimed when no computer references it.
164    keylocks: Mutex<HashMap<(String, String), Arc<Mutex<()>>>>,
165}
166
167impl Default for InProcessCache {
168    fn default() -> Self {
169        Self::new(DEFAULT_CAPACITY, DEFAULT_MAX_VALUE_BYTES)
170    }
171}
172
173impl InProcessCache {
174    pub fn new(capacity: usize, max_value_bytes: usize) -> Self {
175        InProcessCache {
176            state: Mutex::new(State {
177                entries: HashMap::new(),
178                capacity: capacity.max(1),
179                max_value_bytes,
180            }),
181            keylocks: Mutex::new(HashMap::new()),
182        }
183    }
184
185    fn now() -> Instant {
186        Instant::now()
187    }
188
189    /// Deterministic per-key jitter (0..=ttl/10) so entries sharing a TTL do
190    /// NOT expire in a synchronised herd (D85.10). Deterministic (derived from
191    /// the key) — no RNG, reproducible, and still spreads expiries across keys.
192    fn jitter(key: &str, ttl: Duration) -> Duration {
193        let span = ttl.as_millis() as u64 / 10;
194        if span == 0 {
195            return Duration::ZERO;
196        }
197        let mut h = Sha256::new();
198        h.update(key.as_bytes());
199        let digest = h.finalize();
200        let seed = u64::from_le_bytes(digest[..8].try_into().unwrap_or([0; 8]));
201        Duration::from_millis(seed % (span + 1))
202    }
203
204    /// Single-flight compute-through: return a cached value, or compute it
205    /// exactly once even under concurrent misses for the same key. A computed
206    /// ERROR is propagated but NEVER cached (D85.10).
207    pub fn get_or_compute<F, E>(
208        &self,
209        namespace: &str,
210        key: &str,
211        ttl: Option<Duration>,
212        compute: F,
213    ) -> Result<Vec<u8>, E>
214    where
215        F: FnOnce() -> Result<Vec<u8>, E>,
216    {
217        if let Some(v) = self.get(namespace, key) {
218            return Ok(v);
219        }
220        // Acquire (or create) the per-key lock and serialise computers for it.
221        let keylock = {
222            let mut locks = self.keylocks.lock().unwrap();
223            locks
224                .entry((namespace.to_string(), key.to_string()))
225                .or_insert_with(|| Arc::new(Mutex::new(())))
226                .clone()
227        };
228        let _flight = keylock.lock().unwrap();
229        // Re-check under the flight lock: a peer may have filled it.
230        if let Some(v) = self.get(namespace, key) {
231            self.reclaim_keylock(namespace, key, &keylock);
232            return Ok(v);
233        }
234        let result = compute();
235        if let Ok(ref value) = result {
236            self.put(namespace, key, value.clone(), ttl);
237        }
238        drop(_flight);
239        self.reclaim_keylock(namespace, key, &keylock);
240        result
241    }
242
243    /// Drop the per-key lock from the map once no other computer references it
244    /// (strong_count == 2: the map's + our local clone).
245    fn reclaim_keylock(&self, namespace: &str, key: &str, held: &Arc<Mutex<()>>) {
246        let mut locks = self.keylocks.lock().unwrap();
247        if Arc::strong_count(held) <= 2 {
248            locks.remove(&(namespace.to_string(), key.to_string()));
249        }
250    }
251
252    /// Current entry count (test/introspection).
253    pub fn len(&self) -> usize {
254        self.state.lock().unwrap().entries.len()
255    }
256
257    pub fn is_empty(&self) -> bool {
258        self.len() == 0
259    }
260}
261
262impl CacheBackend for InProcessCache {
263    fn get(&self, namespace: &str, key: &str) -> Option<Vec<u8>> {
264        let mut st = self.state.lock().unwrap();
265        let k = (namespace.to_string(), key.to_string());
266        let expired = match st.entries.get(&k) {
267            Some(e) => e.expires_at.map(|t| Self::now() >= t).unwrap_or(false),
268            None => return None,
269        };
270        if expired {
271            st.entries.remove(&k);
272            return None;
273        }
274        let now = Self::now();
275        let e = st.entries.get_mut(&k)?;
276        e.last_access = now;
277        Some(e.value.clone())
278    }
279
280    fn put(&self, namespace: &str, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
281        let mut st = self.state.lock().unwrap();
282        // D85.10 — an oversized value is simply not cached.
283        if value.len() > st.max_value_bytes {
284            return;
285        }
286        // LRU eviction when at capacity (and not overwriting an existing key).
287        let k = (namespace.to_string(), key.to_string());
288        if st.entries.len() >= st.capacity && !st.entries.contains_key(&k) {
289            if let Some(oldest) = st
290                .entries
291                .iter()
292                .min_by_key(|(_, e)| e.last_access)
293                .map(|(k, _)| k.clone())
294            {
295                st.entries.remove(&oldest);
296            }
297        }
298        let expires_at = ttl.map(|d| Self::now() + d + Self::jitter(key, d));
299        st.entries.insert(
300            k,
301            Entry {
302                value,
303                expires_at,
304                last_access: Self::now(),
305            },
306        );
307    }
308
309    fn invalidate(&self, namespace: &str) {
310        let mut st = self.state.lock().unwrap();
311        st.entries.retain(|(ns, _), _| ns != namespace);
312    }
313}
314
315// ── Policy resolution (which cache governs a tool) ───────────────────────────
316
317/// Resolve which `cache` (if any) governs a tool's memoization, given the whole
318/// program IR (D85.2). Precedence: an explicit `cache: none` opts out; an
319/// explicit `cache: <Name>` selects that cache; otherwise the single
320/// `default: true` cache applies IFF the tool is eligible (provably `pure`, or
321/// its effects are a subset of the default's `apply_to_effects`). Returns
322/// `None` when nothing caches the tool.
323pub fn resolve_tool_cache<'a>(ir: &'a IRProgram, tool: &IRToolSpec) -> Option<&'a IRCache> {
324    // Explicit opt-out.
325    if tool.cache == "none" {
326        return None;
327    }
328    // Explicit reference.
329    if !tool.cache.is_empty() {
330        return ir.caches.iter().find(|c| c.name == tool.cache);
331    }
332    // Module default (if exactly one and the tool is eligible).
333    let default = ir.caches.iter().find(|c| c.default_policy)?;
334    let apply: Vec<String> = if default.apply_to_effects.is_empty() {
335        vec!["pure".to_string()]
336    } else {
337        default
338            .apply_to_effects
339            .iter()
340            .map(|e| e.split_once(':').map(|(b, _)| b.to_string()).unwrap_or_else(|| e.clone()))
341            .collect()
342    };
343    // The tool's effect row (IR lowers effects with an optional `epistemic:`
344    // suffix; compare on the base).
345    let eligible = !tool.effect_row.is_empty()
346        && tool.effect_row.iter().all(|e| {
347            let base = e.split_once(':').map(|(b, _)| b).unwrap_or(e.as_str());
348            apply.iter().any(|a| a == base)
349        });
350    if eligible {
351        Some(default)
352    } else {
353        None
354    }
355}
356
357// ── Integration layer (the one seam the runner calls) ───────────────────────
358
359/// Ties policy resolution + content-addressed key derivation + single-flight
360/// compute-through into one call the dispatch path makes per tool. The
361/// enterprise injects a Redis `backend` + the real `tenant`; the OSS default is
362/// an in-process backend under a `"local"` tenant. This is the whole runtime
363/// contract for §85 — a hit returns before `compute` runs (so a budget gate
364/// placed after the lookup never sees it, D85.3).
365pub struct CacheRuntime {
366    backend: Arc<dyn CacheBackend>,
367    tenant: String,
368}
369
370/// The outcome of a cache-mediated dispatch — lets the caller emit the right
371/// `cache:hit` / `cache:miss` audit signal (D85.3) without re-deriving it.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum CacheOutcome {
374    Hit(Vec<u8>),
375    Miss(Vec<u8>),
376    /// The tool is not cache-eligible; carries the freshly computed value so
377    /// the caller uses it exactly as it would a `Miss`, minus the audit signal.
378    Uncached(Vec<u8>),
379}
380
381impl CacheOutcome {
382    /// The result value, regardless of hit/miss/uncached.
383    pub fn value(&self) -> &[u8] {
384        match self {
385            CacheOutcome::Hit(v) | CacheOutcome::Miss(v) | CacheOutcome::Uncached(v) => v,
386        }
387    }
388}
389
390impl CacheRuntime {
391    pub fn new(backend: Arc<dyn CacheBackend>, tenant: impl Into<String>) -> Self {
392        CacheRuntime {
393            backend,
394            tenant: tenant.into(),
395        }
396    }
397
398    /// In-process, single-tenant default (OSS runtime with no injected tier).
399    pub fn in_process() -> Self {
400        Self::new(Arc::new(InProcessCache::default()), "local")
401    }
402
403    /// Look up (or compute-and-store) a tool result. `args` is the full bound
404    /// `(name, value)` set; the `key:` subset (if any) is applied here.
405    /// `compute` runs ONLY on a miss and its error is never cached (D85.10).
406    pub fn dispatch<F, E>(
407        &self,
408        ir: &IRProgram,
409        tool: &IRToolSpec,
410        args: &[(String, String)],
411        compute: F,
412    ) -> Result<CacheOutcome, E>
413    where
414        F: FnOnce() -> Result<Vec<u8>, E>,
415    {
416        let Some(cache) = resolve_tool_cache(ir, tool) else {
417            // Not cache-eligible → run and report Uncached (carrying the value).
418            return compute().map(CacheOutcome::Uncached);
419        };
420        // Apply the `key:` subset (empty ⇒ all args).
421        let key_args: Vec<(String, String)> = if cache.key_params.is_empty() {
422            args.to_vec()
423        } else {
424            args.iter()
425                .filter(|(k, _)| cache.key_params.contains(k))
426                .cloned()
427                .collect()
428        };
429        let output_type = tool.output_type.clone().unwrap_or_default();
430        let key = derive_key(
431            &self.tenant,
432            &cache.name,
433            &tool.name,
434            &tool_fingerprint(tool),
435            &output_type,
436            &key_args,
437        );
438        let ttl = cache.ttl.as_deref().and_then(parse_duration);
439
440        // Fast path: a hit returns BEFORE compute (so no budget is charged).
441        if let Some(v) = self.backend.get(&cache.name, &key) {
442            return Ok(CacheOutcome::Hit(v));
443        }
444        // Miss: compute-through (single-flight is provided by the in-process
445        // backend's own `get_or_compute`; the generic path here is a
446        // check-compute-put that the Redis tier can specialise with SET NX).
447        let value = compute()?;
448        self.backend.put(&cache.name, &key, value.clone(), ttl);
449        Ok(CacheOutcome::Miss(value))
450    }
451
452    /// Flush a cache namespace (called when an `emit` fires on one of its
453    /// `invalidate_on:` channels).
454    pub fn invalidate(&self, cache_name: &str) {
455        self.backend.invalidate(cache_name);
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::sync::atomic::{AtomicUsize, Ordering};
463    use std::sync::Arc as StdArc;
464
465    fn ir_from(src: &str) -> IRProgram {
466        let toks = axon_frontend::lexer::Lexer::new(src, "<cache-test>")
467            .tokenize()
468            .unwrap();
469        let prog = axon_frontend::parser::Parser::new(toks).parse().unwrap();
470        axon_frontend::ir_generator::IRGenerator::new().generate(&prog)
471    }
472
473    const CACHE_PROG: &str = concat!(
474        "flow F() -> Unit { step S { ask: \"hi\" } }\n",
475        "tool Enrich { provider: http effects: <pure> output_type: Report parameters: { id: String } }\n",
476        "cache DefaultPure { default: true }\n",
477    );
478
479    #[test]
480    fn end_to_end_pure_tool_second_call_is_a_hit() {
481        let ir = ir_from(CACHE_PROG);
482        let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
483        let rt = CacheRuntime::in_process();
484        let computes = StdArc::new(AtomicUsize::new(0));
485        let args = vec![("id".to_string(), "42".to_string())];
486
487        let call = || {
488            let computes = computes.clone();
489            rt.dispatch::<_, ()>(&ir, tool, &args, || {
490                computes.fetch_add(1, Ordering::SeqCst);
491                Ok(b"enriched".to_vec())
492            })
493        };
494        // First call → miss (computes once).
495        assert_eq!(call().unwrap(), CacheOutcome::Miss(b"enriched".to_vec()));
496        // Second call, same args → hit (no recompute).
497        assert_eq!(call().unwrap(), CacheOutcome::Hit(b"enriched".to_vec()));
498        assert_eq!(computes.load(Ordering::SeqCst), 1, "pure tool computed once");
499
500        // A different arg value → a fresh miss (distinct content-addressed key).
501        let args2 = vec![("id".to_string(), "99".to_string())];
502        let out = rt
503            .dispatch::<_, ()>(&ir, tool, &args2, || Ok(b"other".to_vec()))
504            .unwrap();
505        assert_eq!(out, CacheOutcome::Miss(b"other".to_vec()));
506    }
507
508    #[test]
509    fn ineligible_tool_is_uncached() {
510        // A network tool with no cache reference and no covering default.
511        let ir = ir_from(concat!(
512            "flow F() -> Unit { step S { ask: \"hi\" } }\n",
513            "tool Fetch { provider: http effects: <network> parameters: { url: String } }\n",
514        ));
515        let tool = ir.tools.iter().find(|t| t.name == "Fetch").unwrap();
516        let rt = CacheRuntime::in_process();
517        let out = rt
518            .dispatch::<_, ()>(&ir, tool, &[], || Ok(b"x".to_vec()))
519            .unwrap();
520        assert_eq!(out, CacheOutcome::Uncached(b"x".to_vec()));
521    }
522
523    #[test]
524    fn invalidate_forces_recompute() {
525        let ir = ir_from(CACHE_PROG);
526        let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
527        let rt = CacheRuntime::in_process();
528        let args = vec![("id".to_string(), "1".to_string())];
529        rt.dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v1".to_vec())).unwrap();
530        rt.invalidate("DefaultPure");
531        let out = rt
532            .dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v2".to_vec()))
533            .unwrap();
534        assert_eq!(out, CacheOutcome::Miss(b"v2".to_vec()), "invalidated → recompute");
535    }
536
537    #[test]
538    fn duration_parsing() {
539        assert_eq!(parse_duration("10s"), Some(Duration::from_secs(10)));
540        assert_eq!(parse_duration("500ms"), Some(Duration::from_millis(500)));
541        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
542        assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
543        assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
544        assert_eq!(parse_duration("bogus"), None);
545    }
546
547    #[test]
548    fn key_is_content_addressed_and_deploy_safe() {
549        let args = vec![("city".to_string(), "London".to_string())];
550        let base = derive_key("t1", "C", "Weather", "fp1", "Out", &args);
551        // Same everything → same key.
552        assert_eq!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args));
553        // Different tenant → different key (D85.11 isolation in the key).
554        assert_ne!(base, derive_key("t2", "C", "Weather", "fp1", "Out", &args));
555        // Different tool fingerprint (a redeploy) → different key (D85.7).
556        assert_ne!(base, derive_key("t1", "C", "Weather", "fp2", "Out", &args));
557        // Different arg value → different key.
558        let args2 = vec![("city".to_string(), "Paris".to_string())];
559        assert_ne!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args2));
560    }
561
562    #[test]
563    fn arg_order_does_not_change_key() {
564        let a = vec![("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())];
565        let b = vec![("b".to_string(), "2".to_string()), ("a".to_string(), "1".to_string())];
566        assert_eq!(
567            derive_key("t", "C", "T", "fp", "O", &a),
568            derive_key("t", "C", "T", "fp", "O", &b)
569        );
570    }
571
572    #[test]
573    fn arg_boundaries_are_forgery_proof() {
574        // ("ab","c") vs ("a","bc") must NOT collide (length-prefixing).
575        let a = vec![("ab".to_string(), "c".to_string())];
576        let b = vec![("a".to_string(), "bc".to_string())];
577        assert_ne!(
578            derive_key("t", "C", "T", "fp", "O", &a),
579            derive_key("t", "C", "T", "fp", "O", &b)
580        );
581    }
582
583    #[test]
584    fn hit_returns_stored_value() {
585        let c = InProcessCache::default();
586        c.put("C", "k", b"value".to_vec(), None);
587        assert_eq!(c.get("C", "k"), Some(b"value".to_vec()));
588        assert_eq!(c.get("C", "missing"), None);
589    }
590
591    #[test]
592    fn ttl_expiry_evicts() {
593        let c = InProcessCache::default();
594        c.put("C", "k", b"v".to_vec(), Some(Duration::from_millis(1)));
595        std::thread::sleep(Duration::from_millis(30));
596        assert_eq!(c.get("C", "k"), None, "expired entry must be gone");
597    }
598
599    #[test]
600    fn invalidate_flushes_only_its_namespace() {
601        let c = InProcessCache::default();
602        c.put("A", "k", b"1".to_vec(), None);
603        c.put("B", "k", b"2".to_vec(), None);
604        c.invalidate("A");
605        assert_eq!(c.get("A", "k"), None);
606        assert_eq!(c.get("B", "k"), Some(b"2".to_vec()), "other cache untouched");
607    }
608
609    #[test]
610    fn oversized_value_is_not_cached() {
611        let c = InProcessCache::new(10, 4);
612        c.put("C", "k", vec![0u8; 100], None);
613        assert_eq!(c.get("C", "k"), None, "oversized value must not be cached");
614    }
615
616    #[test]
617    fn capacity_evicts_lru() {
618        let c = InProcessCache::new(2, DEFAULT_MAX_VALUE_BYTES);
619        c.put("C", "a", b"1".to_vec(), None);
620        c.put("C", "b", b"2".to_vec(), None);
621        let _ = c.get("C", "a"); // touch a → b is now LRU
622        c.put("C", "c", b"3".to_vec(), None); // evicts b
623        assert_eq!(c.get("C", "a"), Some(b"1".to_vec()));
624        assert_eq!(c.get("C", "b"), None, "LRU entry evicted");
625        assert_eq!(c.get("C", "c"), Some(b"3".to_vec()));
626    }
627
628    #[test]
629    fn errors_are_never_cached() {
630        let c = InProcessCache::default();
631        let r: Result<Vec<u8>, &str> =
632            c.get_or_compute("C", "k", None, || Err("boom"));
633        assert!(r.is_err());
634        assert_eq!(c.get("C", "k"), None, "a computed error must not be cached");
635    }
636
637    #[test]
638    fn single_flight_coalesces_concurrent_misses() {
639        let c = StdArc::new(InProcessCache::default());
640        let computes = StdArc::new(AtomicUsize::new(0));
641        let mut handles = Vec::new();
642        for _ in 0..16 {
643            let c = c.clone();
644            let computes = computes.clone();
645            handles.push(std::thread::spawn(move || {
646                c.get_or_compute::<_, ()>("C", "hot", None, || {
647                    computes.fetch_add(1, Ordering::SeqCst);
648                    std::thread::sleep(Duration::from_millis(20));
649                    Ok(b"result".to_vec())
650                })
651                .unwrap()
652            }));
653        }
654        for h in handles {
655            assert_eq!(h.join().unwrap(), b"result".to_vec());
656        }
657        assert_eq!(
658            computes.load(Ordering::SeqCst),
659            1,
660            "single-flight: concurrent misses for one key compute exactly once"
661        );
662    }
663}