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}