1use 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
33pub const DEFAULT_CAPACITY: usize = 10_000;
35pub const DEFAULT_MAX_VALUE_BYTES: usize = 512 * 1024;
38
39pub 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
73fn 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
83fn 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
91pub 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
106pub 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 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
131pub 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 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
156pub struct InProcessCache {
160 state: Mutex<State>,
161 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 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 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 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 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 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 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 if value.len() > st.max_value_bytes {
284 return;
285 }
286 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
315pub fn resolve_tool_cache<'a>(ir: &'a IRProgram, tool: &IRToolSpec) -> Option<&'a IRCache> {
324 if tool.cache == "none" {
326 return None;
327 }
328 if !tool.cache.is_empty() {
330 return ir.caches.iter().find(|c| c.name == tool.cache);
331 }
332 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 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
357pub struct CacheRuntime {
366 backend: Arc<dyn CacheBackend>,
367 tenant: String,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum CacheOutcome {
374 Hit(Vec<u8>),
375 Miss(Vec<u8>),
376 Uncached(Vec<u8>),
379}
380
381impl CacheOutcome {
382 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 pub fn in_process() -> Self {
400 Self::new(Arc::new(InProcessCache::default()), "local")
401 }
402
403 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 return compute().map(CacheOutcome::Uncached);
419 };
420 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 if let Some(v) = self.backend.get(&cache.name, &key) {
442 return Ok(CacheOutcome::Hit(v));
443 }
444 let value = compute()?;
448 self.backend.put(&cache.name, &key, value.clone(), ttl);
449 Ok(CacheOutcome::Miss(value))
450 }
451
452 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 assert_eq!(call().unwrap(), CacheOutcome::Miss(b"enriched".to_vec()));
496 assert_eq!(call().unwrap(), CacheOutcome::Hit(b"enriched".to_vec()));
498 assert_eq!(computes.load(Ordering::SeqCst), 1, "pure tool computed once");
499
500 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 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 assert_eq!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args));
553 assert_ne!(base, derive_key("t2", "C", "Weather", "fp1", "Out", &args));
555 assert_ne!(base, derive_key("t1", "C", "Weather", "fp2", "Out", &args));
557 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 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"); c.put("C", "c", b"3".to_vec(), None); 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}