Skip to main content

axon/
cache_runtime.rs

1//! v2.40.0 — the result-memoization cache core.
2//!
3//! This is the production-hardened runtime behind the `cache` primitive. The
4//! type checker (v2.40.0) 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:** 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:** concurrent misses for one key compute ONCE;
14//!   the rest wait for that result (no thundering herd).
15//! - **Provable-forever, never non-deterministic-forever:** enforced at
16//!   compile time; the runtime simply honours the (optional) TTL.
17//! - **Production hygiene:** 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 — 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 ─────────────────────────────────
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 v2.39.0 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.
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, the design decision). 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. 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.
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        // the design decision — 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. 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// ── v2.89.0 — resolution, hoisted to plan-build time ────────────────────
358
359/// Everything the dispatch path needs to key ONE memoised call, resolved from
360/// the `IRProgram` once, when the plan is built.
361///
362/// # Why this type exists
363///
364/// [`CacheRuntime::dispatch`] took `&IRProgram` because [`resolve_tool_cache`]
365/// needs the whole module to answer "which cache governs this tool?" — the
366/// default-policy rule is a property of the module, not of the tool. But
367/// `DispatchCtx` carries no `IRProgram`, deliberately: it carries narrow,
368/// pre-resolved catalogs (`credentials`, `anchors`, the v2.69.0 shield policies)
369/// so the hot path looks nothing up that could have been looked up once.
370///
371/// Threading the IR through the runtime to satisfy one call would have inverted
372/// that, and for no gain: the answer cannot change between the deploy and the
373/// call. So resolution moves to where the IR already is, and the runtime
374/// receives the answer.
375///
376/// This is the v2.69.0 `collect_shield_policies` shape, and it is also the v2.87.0
377/// discipline — [`resolve_tool_cache`] stays the ONE place that decides, and
378/// gains a second caller rather than a second copy.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct ResolvedCachePolicy {
381    /// The governing cache's name — also the backend NAMESPACE, so `invalidate`
382    /// flushes exactly this cache's entries.
383    pub cache_name: String,
384    /// Raw TTL literal (`"5m"`); `None` ⇒ cache-forever, sound only because
385    /// `axon-T865` proved the memoised thing deterministic.
386    pub ttl: Option<String>,
387    /// The `key:` subset; empty ⇒ every bound argument keys the entry.
388    pub key_params: Vec<String>,
389    /// The declaration fingerprint — a redeploy that changes what is
390    /// memoised changes this, so a behaviour change can never be served a
391    /// result cached under the old behaviour.
392    pub fingerprint: String,
393    /// Part of the key so two tools with identical arguments but different
394    /// result types never collide.
395    pub output_type: String,
396}
397
398impl ResolvedCachePolicy {
399    fn from_parts(cache: &IRCache, fingerprint: String, output_type: String) -> Self {
400        ResolvedCachePolicy {
401            cache_name: cache.name.clone(),
402            ttl: cache.ttl.clone(),
403            key_params: cache.key_params.clone(),
404            fingerprint,
405            output_type,
406        }
407    }
408
409    /// v2.89.0 — the policy governing a `retrieve … cache: <Name>`.
410    ///
411    /// A retrieve names its cache directly, so there is no eligibility question
412    /// to resolve — but it still needs a fingerprint, and the honest one is the
413    /// STORE it reads. A redeploy that changes the store's shape must not serve
414    /// rows cached against the old one, exactly as a changed tool declaration
415    /// must not. `axon-T865` already forces a finite `ttl:` here,
416    /// because a store read is never `pure`.
417    pub fn for_retrieve(cache: &IRCache, store_name: &str) -> Self {
418        let mut h = Sha256::new();
419        update_part(&mut h, "retrieve");
420        update_part(&mut h, store_name);
421        Self::from_parts(
422            cache,
423            hex(&h.finalize())[..16].to_string(),
424            String::new(),
425        )
426    }
427}
428
429/// Resolve, for every tool in the program, which cache (if any) memoises it —
430/// keyed by TOOL NAME, which is how the dispatch path knows a tool.
431///
432/// Empty for a program with no `cache` declaration, which is the overwhelming
433/// majority: an absent entry is the same "not memoised" answer
434/// [`resolve_tool_cache`] gives, so a cache-less program pays one failed hash
435/// lookup per tool call and behaves byte-identically to pre-v2.89.0.
436pub fn resolve_tool_cache_policies(ir: &IRProgram) -> HashMap<String, ResolvedCachePolicy> {
437    let mut out = HashMap::new();
438    if ir.caches.is_empty() {
439        return out;
440    }
441    for tool in &ir.tools {
442        if let Some(cache) = resolve_tool_cache(ir, tool) {
443            out.insert(
444                tool.name.clone(),
445                ResolvedCachePolicy::from_parts(
446                    cache,
447                    tool_fingerprint(tool),
448                    tool.output_type.clone().unwrap_or_default(),
449                ),
450            );
451        }
452    }
453    out
454}
455
456/// v2.89.0 — **everything a deployment memoises**, resolved once from the
457/// `IRProgram`.
458///
459/// # Why one struct and not three parameters
460///
461/// The three maps are not independent knobs; they are one answer to "what does
462/// this program memoise?". A caller that supplied two of them would get a
463/// runtime that caches but never invalidates, or one that flushes a namespace
464/// nothing writes to — silent wrong answers, both, and the shape that makes
465/// them reachable is a builder with three setters.
466///
467/// Bundled, the only way to half-wire the cache is not to wire it at all, which
468/// is the honest `None` default. This is the v2.87.0 lesson expressed as an API:
469/// make the second door impossible rather than remembering to walk through it.
470///
471/// Travels the same route as `scopes`, `observables` and `credentials` — built
472/// where the IR is, passed down as a resolved catalog, so the hot path looks
473/// nothing up that could have been looked up once.
474#[derive(Debug, Clone, Default)]
475pub struct CachePlan {
476    /// Tool name → the policy memoising it.
477    pub tool_policies: HashMap<String, ResolvedCachePolicy>,
478    /// Cache name → declaration, for `retrieve … cache:` and namespace flushes.
479    pub caches: HashMap<String, IRCache>,
480    /// Channel name → the namespaces an `emit` on it flushes.
481    pub invalidation_channels: HashMap<String, Vec<String>>,
482}
483
484impl CachePlan {
485    /// Resolve the whole plan from a compiled program.
486    pub fn from_ir(ir: &IRProgram) -> Self {
487        CachePlan {
488            tool_policies: resolve_tool_cache_policies(ir),
489            caches: ir
490                .caches
491                .iter()
492                .map(|c| (c.name.clone(), c.clone()))
493                .collect(),
494            invalidation_channels: resolve_invalidation_channels(ir),
495        }
496    }
497
498    /// `true` for a program that declares no `cache` at all — the overwhelming
499    /// majority, and the case where attaching a runtime buys nothing.
500    pub fn is_empty(&self) -> bool {
501        self.caches.is_empty()
502    }
503}
504
505/// Channel name → the cache namespaces an `emit` on it flushes (`invalidate_on:`).
506///
507/// Inverted at plan-build time so `run_emit` answers "does this emit invalidate
508/// anything?" with one hash lookup instead of scanning every cache declaration
509/// on every emit. Empty ⇒ no cache in this program declares `invalidate_on:`,
510/// and the emit path is byte-identical to pre-v2.89.0.
511pub fn resolve_invalidation_channels(ir: &IRProgram) -> HashMap<String, Vec<String>> {
512    let mut out: HashMap<String, Vec<String>> = HashMap::new();
513    for cache in &ir.caches {
514        for channel in &cache.invalidate_on {
515            out.entry(channel.clone())
516                .or_default()
517                .push(cache.name.clone());
518        }
519    }
520    out
521}
522
523// ── Integration layer (the one seam the runner calls) ───────────────────────
524
525/// Ties policy resolution + content-addressed key derivation + single-flight
526/// compute-through into one call the dispatch path makes per tool. The
527/// enterprise injects a Redis `backend` + the real `tenant`; the OSS default is
528/// an in-process backend under a `"local"` tenant. This is the whole runtime
529/// contract for v2.40.0 — a hit returns before `compute` runs (so a budget gate
530/// placed after the lookup never sees it, the design decision).
531pub struct CacheRuntime {
532    backend: Arc<dyn CacheBackend>,
533    tenant: String,
534}
535
536/// The outcome of a cache-mediated dispatch — lets the caller emit the right
537/// `cache:hit` / `cache:miss` audit signal without re-deriving it.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum CacheOutcome {
540    Hit(Vec<u8>),
541    Miss(Vec<u8>),
542    /// The tool is not cache-eligible; carries the freshly computed value so
543    /// the caller uses it exactly as it would a `Miss`, minus the audit signal.
544    Uncached(Vec<u8>),
545}
546
547impl CacheOutcome {
548    /// The result value, regardless of hit/miss/uncached.
549    pub fn value(&self) -> &[u8] {
550        match self {
551            CacheOutcome::Hit(v) | CacheOutcome::Miss(v) | CacheOutcome::Uncached(v) => v,
552        }
553    }
554}
555
556/// v2.89.0 — a reserved place to put a computed value, handed out by
557/// [`CacheRuntime::probe`] on a miss and consumed by [`CacheRuntime::store`].
558///
559/// It carries the derived key rather than the inputs, so the value is stored
560/// under the key the lookup missed on — a caller cannot accidentally store
561/// against a key derived from arguments that changed in between.
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct CacheSlot {
564    namespace: String,
565    key: String,
566    ttl: Option<Duration>,
567}
568
569/// v2.89.0 — what a pre-dispatch probe found.
570///
571/// The three arms are the three different things a caller must do, which is why
572/// this is not an `Option<Vec<u8>>`: "nothing memoises this call" and
573/// "memoised, but absent" look identical to an `Option` and are not the same
574/// fact — the first stores nothing afterwards, the second must.
575#[derive(Debug, Clone, PartialEq, Eq)]
576pub enum CacheProbe {
577    /// No policy governs this call. Dispatch normally; store nothing.
578    NotCached,
579    /// A memoised value. **Do not dispatch, and do not charge a budget** — that
580    /// ordering is the design decision, and it is the caller's to honour.
581    Hit(Vec<u8>),
582    /// Memoised but absent. Dispatch, then hand the value to
583    /// [`CacheRuntime::store`] with this slot.
584    Miss(CacheSlot),
585}
586
587impl CacheRuntime {
588    pub fn new(backend: Arc<dyn CacheBackend>, tenant: impl Into<String>) -> Self {
589        CacheRuntime {
590            backend,
591            tenant: tenant.into(),
592        }
593    }
594
595    /// In-process, single-tenant default (OSS runtime with no injected tier).
596    pub fn in_process() -> Self {
597        Self::new(Arc::new(InProcessCache::default()), "local")
598    }
599
600    /// v2.89.0 — the OSS runtime a production flow run gets: the
601    /// **process-wide** in-process tier, keyed to **this run's tenant**.
602    ///
603    /// # Why the split, and what each half prevents
604    ///
605    /// This looks like a detail and is the difference between a cache and a
606    /// decoration.
607    ///
608    /// Build the whole `CacheRuntime` per flow run and the backend is empty
609    /// every time: a tool called once per run — which is most tools — never
610    /// hits anything, and v2.89.0 would ship a memoiser that memoises within a
611    /// single run and forgets between them. Wired, tested, and worthless.
612    ///
613    /// Share the whole `CacheRuntime` across runs and it is worse than
614    /// worthless. `tenant` is a field of the runtime, not a parameter of the
615    /// call, so one shared instance would key every tenant's results under
616    /// whichever tenant built it first — and the design decision puts the tenant IN the key
617    /// precisely so that a mis-namespacing backend still cannot leak. A shared
618    /// runtime with a fixed tenant defeats that from above the backend, where
619    /// the key is derived.
620    ///
621    /// So the BACKEND is process-wide (entries survive between runs, which is
622    /// what makes it a cache) and the TENANT comes from the run (which is what
623    /// keeps them apart). Constructing this is two `Arc` clones.
624    ///
625    /// The enterprise v2.40.0 Redis tier replaces the backend here and inherits
626    /// the same discipline unchanged — it is a different `CacheBackend`, not a
627    /// different call site.
628    pub fn process_local(tenant: impl Into<String>) -> Self {
629        static TIER: std::sync::OnceLock<Arc<InProcessCache>> = std::sync::OnceLock::new();
630        let backend = TIER.get_or_init(|| Arc::new(InProcessCache::default()));
631        Self::new(backend.clone(), tenant)
632    }
633
634    /// Look up (or compute-and-store) a tool result. `args` is the full bound
635    /// `(name, value)` set; the `key:` subset (if any) is applied here.
636    /// `compute` runs ONLY on a miss and its error is never cached.
637    pub fn dispatch<F, E>(
638        &self,
639        ir: &IRProgram,
640        tool: &IRToolSpec,
641        args: &[(String, String)],
642        compute: F,
643    ) -> Result<CacheOutcome, E>
644    where
645        F: FnOnce() -> Result<Vec<u8>, E>,
646    {
647        // v2.89.0 — resolution and execution split, so the dispatch path can
648        // supply a policy resolved once at plan-build time (see
649        // [`ResolvedCachePolicy`]). This entry point keeps its `&IRProgram`
650        // signature and resolves on the spot; both routes run the SAME body
651        // below, so there is one memoisation law and two ways to reach it —
652        // never two laws.
653        let policy = resolve_tool_cache(ir, tool).map(|cache| {
654            ResolvedCachePolicy::from_parts(
655                cache,
656                tool_fingerprint(tool),
657                tool.output_type.clone().unwrap_or_default(),
658            )
659        });
660        self.dispatch_resolved(policy.as_ref(), &tool.name, args, compute)
661    }
662
663    /// v2.89.0 — the memoisation body, against an already-resolved policy.
664    ///
665    /// `subject` is what the entry is keyed to — a tool's name, or a store's
666    /// name for a `retrieve`. `policy: None` means "nothing memoises this":
667    /// `compute` runs and the value comes back as [`CacheOutcome::Uncached`],
668    /// which the caller uses exactly as a `Miss` minus the audit signal.
669    ///
670    /// # The ordering that the design decision rests on
671    ///
672    /// A hit returns BEFORE `compute` is called. That is not an optimisation,
673    /// it is the guarantee: the caller places its budget charge inside
674    /// `compute`'s caller, so a hit cannot decrement a `budget { rate: … }`
675    /// quota. v2.40.0's plan calls this *"structurally guaranteed by ordering the
676    /// cache lookup before the budget gate"* — the structure is right here.
677    pub fn dispatch_resolved<F, E>(
678        &self,
679        policy: Option<&ResolvedCachePolicy>,
680        subject: &str,
681        args: &[(String, String)],
682        compute: F,
683    ) -> Result<CacheOutcome, E>
684    where
685        F: FnOnce() -> Result<Vec<u8>, E>,
686    {
687        match self.probe(policy, subject, args) {
688            CacheProbe::NotCached => compute().map(CacheOutcome::Uncached),
689            CacheProbe::Hit(v) => Ok(CacheOutcome::Hit(v)),
690            CacheProbe::Miss(slot) => {
691                let value = compute()?;
692                self.store(&slot, value.clone());
693                Ok(CacheOutcome::Miss(value))
694            }
695        }
696    }
697
698    /// v2.89.0 — **look, without computing.**
699    ///
700    /// # Why the seam had to split
701    ///
702    /// [`dispatch_resolved`](Self::dispatch_resolved) takes a `compute` closure,
703    /// which is the right shape when the work is synchronous and owns nothing.
704    /// The real tool-call path is neither: the work between the lookup and the
705    /// value is `async`, it borrows `&mut DispatchCtx`, and it is not one call
706    /// but four in sequence — the budget charge, the lease charge, the
707    /// concurrency permit, then the vendor dispatch. None of that fits inside
708    /// an `FnOnce() -> Result<Vec<u8>, E>`, and contorting it to fit would have
709    /// meant either blocking the executor or duplicating the memoisation law at
710    /// the call site.
711    ///
712    /// So the law splits into the two moments an async caller actually has:
713    /// probe before the work, store after it. `dispatch_resolved` is now
714    /// implemented in terms of these, so the synchronous seam v2.40.0 designed and
715    /// the asynchronous one v2.89.0 needed run the SAME key derivation, the same
716    /// TTL parse and the same namespace — one law, two ways in.
717    ///
718    /// **The ordering the design decision rests on is the caller's to keep**: a
719    /// [`CacheProbe::Hit`] means the call must not be dispatched AND no budget
720    /// charged. Returning early on a hit is what makes "a hit never consumes a
721    /// quota" structural rather than hopeful, and it is asserted from a real
722    /// deploy in `cache_hits.rs`.
723    pub fn probe(
724        &self,
725        policy: Option<&ResolvedCachePolicy>,
726        subject: &str,
727        args: &[(String, String)],
728    ) -> CacheProbe {
729        let Some(policy) = policy else {
730            return CacheProbe::NotCached;
731        };
732        // Apply the `key:` subset (empty ⇒ all args).
733        let key_args: Vec<(String, String)> = if policy.key_params.is_empty() {
734            args.to_vec()
735        } else {
736            args.iter()
737                .filter(|(k, _)| policy.key_params.contains(k))
738                .cloned()
739                .collect()
740        };
741        let key = derive_key(
742            &self.tenant,
743            &policy.cache_name,
744            subject,
745            &policy.fingerprint,
746            &policy.output_type,
747            &key_args,
748        );
749        if let Some(v) = self.backend.get(&policy.cache_name, &key) {
750            return CacheProbe::Hit(v);
751        }
752        CacheProbe::Miss(CacheSlot {
753            namespace: policy.cache_name.clone(),
754            key,
755            ttl: policy.ttl.as_deref().and_then(parse_duration),
756        })
757    }
758
759    /// v2.89.0 — fill the slot a [`CacheProbe::Miss`] reserved.
760    ///
761    /// Errors are never stored — that is the caller's decision, and it
762    /// is expressed by simply not calling this. Oversized values are dropped by
763    /// the backend, never truncated into a wrong value.
764    pub fn store(&self, slot: &CacheSlot, value: Vec<u8>) {
765        self.backend.put(&slot.namespace, &slot.key, value, slot.ttl);
766    }
767
768    /// Flush a cache namespace (called when an `emit` fires on one of its
769    /// `invalidate_on:` channels).
770    pub fn invalidate(&self, cache_name: &str) {
771        self.backend.invalidate(cache_name);
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::sync::atomic::{AtomicUsize, Ordering};
779    use std::sync::Arc as StdArc;
780
781    fn ir_from(src: &str) -> IRProgram {
782        let toks = axon_frontend::lexer::Lexer::new(src, "<cache-test>")
783            .tokenize()
784            .unwrap();
785        let prog = axon_frontend::parser::Parser::new(toks).parse().unwrap();
786        axon_frontend::ir_generator::IRGenerator::new().generate(&prog)
787    }
788
789    const CACHE_PROG: &str = concat!(
790        "flow F() -> Unit { step S { ask: \"hi\" } }\n",
791        "tool Enrich { provider: http effects: <pure> output_type: Report parameters: { id: String } }\n",
792        "cache DefaultPure { default: true }\n",
793    );
794
795    #[test]
796    fn end_to_end_pure_tool_second_call_is_a_hit() {
797        let ir = ir_from(CACHE_PROG);
798        let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
799        let rt = CacheRuntime::in_process();
800        let computes = StdArc::new(AtomicUsize::new(0));
801        let args = vec![("id".to_string(), "42".to_string())];
802
803        let call = || {
804            let computes = computes.clone();
805            rt.dispatch::<_, ()>(&ir, tool, &args, || {
806                computes.fetch_add(1, Ordering::SeqCst);
807                Ok(b"enriched".to_vec())
808            })
809        };
810        // First call → miss (computes once).
811        assert_eq!(call().unwrap(), CacheOutcome::Miss(b"enriched".to_vec()));
812        // Second call, same args → hit (no recompute).
813        assert_eq!(call().unwrap(), CacheOutcome::Hit(b"enriched".to_vec()));
814        assert_eq!(computes.load(Ordering::SeqCst), 1, "pure tool computed once");
815
816        // A different arg value → a fresh miss (distinct content-addressed key).
817        let args2 = vec![("id".to_string(), "99".to_string())];
818        let out = rt
819            .dispatch::<_, ()>(&ir, tool, &args2, || Ok(b"other".to_vec()))
820            .unwrap();
821        assert_eq!(out, CacheOutcome::Miss(b"other".to_vec()));
822    }
823
824    #[test]
825    fn ineligible_tool_is_uncached() {
826        // A network tool with no cache reference and no covering default.
827        let ir = ir_from(concat!(
828            "flow F() -> Unit { step S { ask: \"hi\" } }\n",
829            "tool Fetch { provider: http effects: <network> parameters: { url: String } }\n",
830        ));
831        let tool = ir.tools.iter().find(|t| t.name == "Fetch").unwrap();
832        let rt = CacheRuntime::in_process();
833        let out = rt
834            .dispatch::<_, ()>(&ir, tool, &[], || Ok(b"x".to_vec()))
835            .unwrap();
836        assert_eq!(out, CacheOutcome::Uncached(b"x".to_vec()));
837    }
838
839    #[test]
840    fn invalidate_forces_recompute() {
841        let ir = ir_from(CACHE_PROG);
842        let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
843        let rt = CacheRuntime::in_process();
844        let args = vec![("id".to_string(), "1".to_string())];
845        rt.dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v1".to_vec())).unwrap();
846        rt.invalidate("DefaultPure");
847        let out = rt
848            .dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v2".to_vec()))
849            .unwrap();
850        assert_eq!(out, CacheOutcome::Miss(b"v2".to_vec()), "invalidated → recompute");
851    }
852
853    #[test]
854    fn duration_parsing() {
855        assert_eq!(parse_duration("10s"), Some(Duration::from_secs(10)));
856        assert_eq!(parse_duration("500ms"), Some(Duration::from_millis(500)));
857        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
858        assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
859        assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
860        assert_eq!(parse_duration("bogus"), None);
861    }
862
863    #[test]
864    fn key_is_content_addressed_and_deploy_safe() {
865        let args = vec![("city".to_string(), "London".to_string())];
866        let base = derive_key("t1", "C", "Weather", "fp1", "Out", &args);
867        // Same everything → same key.
868        assert_eq!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args));
869        // Different tenant → different key (the design decision isolation in the key).
870        assert_ne!(base, derive_key("t2", "C", "Weather", "fp1", "Out", &args));
871        // Different tool fingerprint (a redeploy) → different key.
872        assert_ne!(base, derive_key("t1", "C", "Weather", "fp2", "Out", &args));
873        // Different arg value → different key.
874        let args2 = vec![("city".to_string(), "Paris".to_string())];
875        assert_ne!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args2));
876    }
877
878    #[test]
879    fn arg_order_does_not_change_key() {
880        let a = vec![("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())];
881        let b = vec![("b".to_string(), "2".to_string()), ("a".to_string(), "1".to_string())];
882        assert_eq!(
883            derive_key("t", "C", "T", "fp", "O", &a),
884            derive_key("t", "C", "T", "fp", "O", &b)
885        );
886    }
887
888    #[test]
889    fn arg_boundaries_are_forgery_proof() {
890        // ("ab","c") vs ("a","bc") must NOT collide (length-prefixing).
891        let a = vec![("ab".to_string(), "c".to_string())];
892        let b = vec![("a".to_string(), "bc".to_string())];
893        assert_ne!(
894            derive_key("t", "C", "T", "fp", "O", &a),
895            derive_key("t", "C", "T", "fp", "O", &b)
896        );
897    }
898
899    #[test]
900    fn hit_returns_stored_value() {
901        let c = InProcessCache::default();
902        c.put("C", "k", b"value".to_vec(), None);
903        assert_eq!(c.get("C", "k"), Some(b"value".to_vec()));
904        assert_eq!(c.get("C", "missing"), None);
905    }
906
907    #[test]
908    fn ttl_expiry_evicts() {
909        let c = InProcessCache::default();
910        c.put("C", "k", b"v".to_vec(), Some(Duration::from_millis(1)));
911        std::thread::sleep(Duration::from_millis(30));
912        assert_eq!(c.get("C", "k"), None, "expired entry must be gone");
913    }
914
915    #[test]
916    fn invalidate_flushes_only_its_namespace() {
917        let c = InProcessCache::default();
918        c.put("A", "k", b"1".to_vec(), None);
919        c.put("B", "k", b"2".to_vec(), None);
920        c.invalidate("A");
921        assert_eq!(c.get("A", "k"), None);
922        assert_eq!(c.get("B", "k"), Some(b"2".to_vec()), "other cache untouched");
923    }
924
925    #[test]
926    fn oversized_value_is_not_cached() {
927        let c = InProcessCache::new(10, 4);
928        c.put("C", "k", vec![0u8; 100], None);
929        assert_eq!(c.get("C", "k"), None, "oversized value must not be cached");
930    }
931
932    #[test]
933    fn capacity_evicts_lru() {
934        let c = InProcessCache::new(2, DEFAULT_MAX_VALUE_BYTES);
935        c.put("C", "a", b"1".to_vec(), None);
936        c.put("C", "b", b"2".to_vec(), None);
937        let _ = c.get("C", "a"); // touch a → b is now LRU
938        c.put("C", "c", b"3".to_vec(), None); // evicts b
939        assert_eq!(c.get("C", "a"), Some(b"1".to_vec()));
940        assert_eq!(c.get("C", "b"), None, "LRU entry evicted");
941        assert_eq!(c.get("C", "c"), Some(b"3".to_vec()));
942    }
943
944    #[test]
945    fn errors_are_never_cached() {
946        let c = InProcessCache::default();
947        let r: Result<Vec<u8>, &str> =
948            c.get_or_compute("C", "k", None, || Err("boom"));
949        assert!(r.is_err());
950        assert_eq!(c.get("C", "k"), None, "a computed error must not be cached");
951    }
952
953    #[test]
954    fn single_flight_coalesces_concurrent_misses() {
955        let c = StdArc::new(InProcessCache::default());
956        let computes = StdArc::new(AtomicUsize::new(0));
957        let mut handles = Vec::new();
958        for _ in 0..16 {
959            let c = c.clone();
960            let computes = computes.clone();
961            handles.push(std::thread::spawn(move || {
962                c.get_or_compute::<_, ()>("C", "hot", None, || {
963                    computes.fetch_add(1, Ordering::SeqCst);
964                    std::thread::sleep(Duration::from_millis(20));
965                    Ok(b"result".to_vec())
966                })
967                .unwrap()
968            }));
969        }
970        for h in handles {
971            assert_eq!(h.join().unwrap(), b"result".to_vec());
972        }
973        assert_eq!(
974            computes.load(Ordering::SeqCst),
975            1,
976            "single-flight: concurrent misses for one key compute exactly once"
977        );
978    }
979}