use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use axon_frontend::ir_nodes::{IRCache, IRProgram, IRToolSpec};
pub const DEFAULT_CAPACITY: usize = 10_000;
pub const DEFAULT_MAX_VALUE_BYTES: usize = 512 * 1024;
pub fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num, unit): (&str, &str) = if let Some(p) = s.strip_suffix("ms") {
(p, "ms")
} else if let Some(p) = s.strip_suffix('s') {
(p, "s")
} else if let Some(p) = s.strip_suffix('m') {
(p, "m")
} else if let Some(p) = s.strip_suffix('h') {
(p, "h")
} else if let Some(p) = s.strip_suffix('d') {
(p, "d")
} else {
return None;
};
let n: u64 = num.parse().ok()?;
Some(match unit {
"ms" => Duration::from_millis(n),
"s" => Duration::from_secs(n),
"m" => Duration::from_secs(n * 60),
"h" => Duration::from_secs(n * 3600),
"d" => Duration::from_secs(n * 86400),
_ => return None,
})
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn update_part(h: &mut Sha256, part: &str) {
h.update((part.len() as u64).to_le_bytes());
h.update(part.as_bytes());
}
pub fn tool_fingerprint(tool: &IRToolSpec) -> String {
match serde_json::to_vec(tool) {
Ok(bytes) => {
let mut h = Sha256::new();
h.update(&bytes);
hex(&h.finalize())[..16].to_string()
}
Err(_) => "unfingerprintable".to_string(),
}
}
pub fn derive_key(
tenant: &str,
cache_name: &str,
tool_name: &str,
tool_fingerprint: &str,
output_type: &str,
key_args: &[(String, String)],
) -> String {
let mut h = Sha256::new();
for part in [tenant, cache_name, tool_name, tool_fingerprint, output_type] {
update_part(&mut h, part);
}
let mut sorted: Vec<&(String, String)> = key_args.iter().collect();
sorted.sort();
update_part(&mut h, &format!("__argc={}", sorted.len()));
for (k, v) in sorted {
update_part(&mut h, k);
update_part(&mut h, v);
}
hex(&h.finalize())
}
pub trait CacheBackend: Send + Sync {
fn get(&self, namespace: &str, key: &str) -> Option<Vec<u8>>;
fn put(&self, namespace: &str, key: &str, value: Vec<u8>, ttl: Option<Duration>);
fn invalidate(&self, namespace: &str);
}
struct Entry {
value: Vec<u8>,
expires_at: Option<Instant>,
last_access: Instant,
}
struct State {
entries: HashMap<(String, String), Entry>,
capacity: usize,
max_value_bytes: usize,
}
pub struct InProcessCache {
state: Mutex<State>,
keylocks: Mutex<HashMap<(String, String), Arc<Mutex<()>>>>,
}
impl Default for InProcessCache {
fn default() -> Self {
Self::new(DEFAULT_CAPACITY, DEFAULT_MAX_VALUE_BYTES)
}
}
impl InProcessCache {
pub fn new(capacity: usize, max_value_bytes: usize) -> Self {
InProcessCache {
state: Mutex::new(State {
entries: HashMap::new(),
capacity: capacity.max(1),
max_value_bytes,
}),
keylocks: Mutex::new(HashMap::new()),
}
}
fn now() -> Instant {
Instant::now()
}
fn jitter(key: &str, ttl: Duration) -> Duration {
let span = ttl.as_millis() as u64 / 10;
if span == 0 {
return Duration::ZERO;
}
let mut h = Sha256::new();
h.update(key.as_bytes());
let digest = h.finalize();
let seed = u64::from_le_bytes(digest[..8].try_into().unwrap_or([0; 8]));
Duration::from_millis(seed % (span + 1))
}
pub fn get_or_compute<F, E>(
&self,
namespace: &str,
key: &str,
ttl: Option<Duration>,
compute: F,
) -> Result<Vec<u8>, E>
where
F: FnOnce() -> Result<Vec<u8>, E>,
{
if let Some(v) = self.get(namespace, key) {
return Ok(v);
}
let keylock = {
let mut locks = self.keylocks.lock().unwrap();
locks
.entry((namespace.to_string(), key.to_string()))
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _flight = keylock.lock().unwrap();
if let Some(v) = self.get(namespace, key) {
self.reclaim_keylock(namespace, key, &keylock);
return Ok(v);
}
let result = compute();
if let Ok(ref value) = result {
self.put(namespace, key, value.clone(), ttl);
}
drop(_flight);
self.reclaim_keylock(namespace, key, &keylock);
result
}
fn reclaim_keylock(&self, namespace: &str, key: &str, held: &Arc<Mutex<()>>) {
let mut locks = self.keylocks.lock().unwrap();
if Arc::strong_count(held) <= 2 {
locks.remove(&(namespace.to_string(), key.to_string()));
}
}
pub fn len(&self) -> usize {
self.state.lock().unwrap().entries.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl CacheBackend for InProcessCache {
fn get(&self, namespace: &str, key: &str) -> Option<Vec<u8>> {
let mut st = self.state.lock().unwrap();
let k = (namespace.to_string(), key.to_string());
let expired = match st.entries.get(&k) {
Some(e) => e.expires_at.map(|t| Self::now() >= t).unwrap_or(false),
None => return None,
};
if expired {
st.entries.remove(&k);
return None;
}
let now = Self::now();
let e = st.entries.get_mut(&k)?;
e.last_access = now;
Some(e.value.clone())
}
fn put(&self, namespace: &str, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
let mut st = self.state.lock().unwrap();
if value.len() > st.max_value_bytes {
return;
}
let k = (namespace.to_string(), key.to_string());
if st.entries.len() >= st.capacity && !st.entries.contains_key(&k) {
if let Some(oldest) = st
.entries
.iter()
.min_by_key(|(_, e)| e.last_access)
.map(|(k, _)| k.clone())
{
st.entries.remove(&oldest);
}
}
let expires_at = ttl.map(|d| Self::now() + d + Self::jitter(key, d));
st.entries.insert(
k,
Entry {
value,
expires_at,
last_access: Self::now(),
},
);
}
fn invalidate(&self, namespace: &str) {
let mut st = self.state.lock().unwrap();
st.entries.retain(|(ns, _), _| ns != namespace);
}
}
pub fn resolve_tool_cache<'a>(ir: &'a IRProgram, tool: &IRToolSpec) -> Option<&'a IRCache> {
if tool.cache == "none" {
return None;
}
if !tool.cache.is_empty() {
return ir.caches.iter().find(|c| c.name == tool.cache);
}
let default = ir.caches.iter().find(|c| c.default_policy)?;
let apply: Vec<String> = if default.apply_to_effects.is_empty() {
vec!["pure".to_string()]
} else {
default
.apply_to_effects
.iter()
.map(|e| e.split_once(':').map(|(b, _)| b.to_string()).unwrap_or_else(|| e.clone()))
.collect()
};
let eligible = !tool.effect_row.is_empty()
&& tool.effect_row.iter().all(|e| {
let base = e.split_once(':').map(|(b, _)| b).unwrap_or(e.as_str());
apply.iter().any(|a| a == base)
});
if eligible {
Some(default)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCachePolicy {
pub cache_name: String,
pub ttl: Option<String>,
pub key_params: Vec<String>,
pub fingerprint: String,
pub output_type: String,
}
impl ResolvedCachePolicy {
fn from_parts(cache: &IRCache, fingerprint: String, output_type: String) -> Self {
ResolvedCachePolicy {
cache_name: cache.name.clone(),
ttl: cache.ttl.clone(),
key_params: cache.key_params.clone(),
fingerprint,
output_type,
}
}
pub fn for_retrieve(cache: &IRCache, store_name: &str) -> Self {
let mut h = Sha256::new();
update_part(&mut h, "retrieve");
update_part(&mut h, store_name);
Self::from_parts(
cache,
hex(&h.finalize())[..16].to_string(),
String::new(),
)
}
}
pub fn resolve_tool_cache_policies(ir: &IRProgram) -> HashMap<String, ResolvedCachePolicy> {
let mut out = HashMap::new();
if ir.caches.is_empty() {
return out;
}
for tool in &ir.tools {
if let Some(cache) = resolve_tool_cache(ir, tool) {
out.insert(
tool.name.clone(),
ResolvedCachePolicy::from_parts(
cache,
tool_fingerprint(tool),
tool.output_type.clone().unwrap_or_default(),
),
);
}
}
out
}
#[derive(Debug, Clone, Default)]
pub struct CachePlan {
pub tool_policies: HashMap<String, ResolvedCachePolicy>,
pub caches: HashMap<String, IRCache>,
pub invalidation_channels: HashMap<String, Vec<String>>,
}
impl CachePlan {
pub fn from_ir(ir: &IRProgram) -> Self {
CachePlan {
tool_policies: resolve_tool_cache_policies(ir),
caches: ir
.caches
.iter()
.map(|c| (c.name.clone(), c.clone()))
.collect(),
invalidation_channels: resolve_invalidation_channels(ir),
}
}
pub fn is_empty(&self) -> bool {
self.caches.is_empty()
}
}
pub fn resolve_invalidation_channels(ir: &IRProgram) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for cache in &ir.caches {
for channel in &cache.invalidate_on {
out.entry(channel.clone())
.or_default()
.push(cache.name.clone());
}
}
out
}
pub struct CacheRuntime {
backend: Arc<dyn CacheBackend>,
tenant: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheOutcome {
Hit(Vec<u8>),
Miss(Vec<u8>),
Uncached(Vec<u8>),
}
impl CacheOutcome {
pub fn value(&self) -> &[u8] {
match self {
CacheOutcome::Hit(v) | CacheOutcome::Miss(v) | CacheOutcome::Uncached(v) => v,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheSlot {
namespace: String,
key: String,
ttl: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheProbe {
NotCached,
Hit(Vec<u8>),
Miss(CacheSlot),
}
impl CacheRuntime {
pub fn new(backend: Arc<dyn CacheBackend>, tenant: impl Into<String>) -> Self {
CacheRuntime {
backend,
tenant: tenant.into(),
}
}
pub fn in_process() -> Self {
Self::new(Arc::new(InProcessCache::default()), "local")
}
pub fn process_local(tenant: impl Into<String>) -> Self {
static TIER: std::sync::OnceLock<Arc<InProcessCache>> = std::sync::OnceLock::new();
let backend = TIER.get_or_init(|| Arc::new(InProcessCache::default()));
Self::new(backend.clone(), tenant)
}
pub fn dispatch<F, E>(
&self,
ir: &IRProgram,
tool: &IRToolSpec,
args: &[(String, String)],
compute: F,
) -> Result<CacheOutcome, E>
where
F: FnOnce() -> Result<Vec<u8>, E>,
{
let policy = resolve_tool_cache(ir, tool).map(|cache| {
ResolvedCachePolicy::from_parts(
cache,
tool_fingerprint(tool),
tool.output_type.clone().unwrap_or_default(),
)
});
self.dispatch_resolved(policy.as_ref(), &tool.name, args, compute)
}
pub fn dispatch_resolved<F, E>(
&self,
policy: Option<&ResolvedCachePolicy>,
subject: &str,
args: &[(String, String)],
compute: F,
) -> Result<CacheOutcome, E>
where
F: FnOnce() -> Result<Vec<u8>, E>,
{
match self.probe(policy, subject, args) {
CacheProbe::NotCached => compute().map(CacheOutcome::Uncached),
CacheProbe::Hit(v) => Ok(CacheOutcome::Hit(v)),
CacheProbe::Miss(slot) => {
let value = compute()?;
self.store(&slot, value.clone());
Ok(CacheOutcome::Miss(value))
}
}
}
pub fn probe(
&self,
policy: Option<&ResolvedCachePolicy>,
subject: &str,
args: &[(String, String)],
) -> CacheProbe {
let Some(policy) = policy else {
return CacheProbe::NotCached;
};
let key_args: Vec<(String, String)> = if policy.key_params.is_empty() {
args.to_vec()
} else {
args.iter()
.filter(|(k, _)| policy.key_params.contains(k))
.cloned()
.collect()
};
let key = derive_key(
&self.tenant,
&policy.cache_name,
subject,
&policy.fingerprint,
&policy.output_type,
&key_args,
);
if let Some(v) = self.backend.get(&policy.cache_name, &key) {
return CacheProbe::Hit(v);
}
CacheProbe::Miss(CacheSlot {
namespace: policy.cache_name.clone(),
key,
ttl: policy.ttl.as_deref().and_then(parse_duration),
})
}
pub fn store(&self, slot: &CacheSlot, value: Vec<u8>) {
self.backend.put(&slot.namespace, &slot.key, value, slot.ttl);
}
pub fn invalidate(&self, cache_name: &str) {
self.backend.invalidate(cache_name);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc as StdArc;
fn ir_from(src: &str) -> IRProgram {
let toks = axon_frontend::lexer::Lexer::new(src, "<cache-test>")
.tokenize()
.unwrap();
let prog = axon_frontend::parser::Parser::new(toks).parse().unwrap();
axon_frontend::ir_generator::IRGenerator::new().generate(&prog)
}
const CACHE_PROG: &str = concat!(
"flow F() -> Unit { step S { ask: \"hi\" } }\n",
"tool Enrich { provider: http effects: <pure> output_type: Report parameters: { id: String } }\n",
"cache DefaultPure { default: true }\n",
);
#[test]
fn end_to_end_pure_tool_second_call_is_a_hit() {
let ir = ir_from(CACHE_PROG);
let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
let rt = CacheRuntime::in_process();
let computes = StdArc::new(AtomicUsize::new(0));
let args = vec![("id".to_string(), "42".to_string())];
let call = || {
let computes = computes.clone();
rt.dispatch::<_, ()>(&ir, tool, &args, || {
computes.fetch_add(1, Ordering::SeqCst);
Ok(b"enriched".to_vec())
})
};
assert_eq!(call().unwrap(), CacheOutcome::Miss(b"enriched".to_vec()));
assert_eq!(call().unwrap(), CacheOutcome::Hit(b"enriched".to_vec()));
assert_eq!(computes.load(Ordering::SeqCst), 1, "pure tool computed once");
let args2 = vec![("id".to_string(), "99".to_string())];
let out = rt
.dispatch::<_, ()>(&ir, tool, &args2, || Ok(b"other".to_vec()))
.unwrap();
assert_eq!(out, CacheOutcome::Miss(b"other".to_vec()));
}
#[test]
fn ineligible_tool_is_uncached() {
let ir = ir_from(concat!(
"flow F() -> Unit { step S { ask: \"hi\" } }\n",
"tool Fetch { provider: http effects: <network> parameters: { url: String } }\n",
));
let tool = ir.tools.iter().find(|t| t.name == "Fetch").unwrap();
let rt = CacheRuntime::in_process();
let out = rt
.dispatch::<_, ()>(&ir, tool, &[], || Ok(b"x".to_vec()))
.unwrap();
assert_eq!(out, CacheOutcome::Uncached(b"x".to_vec()));
}
#[test]
fn invalidate_forces_recompute() {
let ir = ir_from(CACHE_PROG);
let tool = ir.tools.iter().find(|t| t.name == "Enrich").unwrap();
let rt = CacheRuntime::in_process();
let args = vec![("id".to_string(), "1".to_string())];
rt.dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v1".to_vec())).unwrap();
rt.invalidate("DefaultPure");
let out = rt
.dispatch::<_, ()>(&ir, tool, &args, || Ok(b"v2".to_vec()))
.unwrap();
assert_eq!(out, CacheOutcome::Miss(b"v2".to_vec()), "invalidated → recompute");
}
#[test]
fn duration_parsing() {
assert_eq!(parse_duration("10s"), Some(Duration::from_secs(10)));
assert_eq!(parse_duration("500ms"), Some(Duration::from_millis(500)));
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
assert_eq!(parse_duration("bogus"), None);
}
#[test]
fn key_is_content_addressed_and_deploy_safe() {
let args = vec![("city".to_string(), "London".to_string())];
let base = derive_key("t1", "C", "Weather", "fp1", "Out", &args);
assert_eq!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args));
assert_ne!(base, derive_key("t2", "C", "Weather", "fp1", "Out", &args));
assert_ne!(base, derive_key("t1", "C", "Weather", "fp2", "Out", &args));
let args2 = vec![("city".to_string(), "Paris".to_string())];
assert_ne!(base, derive_key("t1", "C", "Weather", "fp1", "Out", &args2));
}
#[test]
fn arg_order_does_not_change_key() {
let a = vec![("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())];
let b = vec![("b".to_string(), "2".to_string()), ("a".to_string(), "1".to_string())];
assert_eq!(
derive_key("t", "C", "T", "fp", "O", &a),
derive_key("t", "C", "T", "fp", "O", &b)
);
}
#[test]
fn arg_boundaries_are_forgery_proof() {
let a = vec![("ab".to_string(), "c".to_string())];
let b = vec![("a".to_string(), "bc".to_string())];
assert_ne!(
derive_key("t", "C", "T", "fp", "O", &a),
derive_key("t", "C", "T", "fp", "O", &b)
);
}
#[test]
fn hit_returns_stored_value() {
let c = InProcessCache::default();
c.put("C", "k", b"value".to_vec(), None);
assert_eq!(c.get("C", "k"), Some(b"value".to_vec()));
assert_eq!(c.get("C", "missing"), None);
}
#[test]
fn ttl_expiry_evicts() {
let c = InProcessCache::default();
c.put("C", "k", b"v".to_vec(), Some(Duration::from_millis(1)));
std::thread::sleep(Duration::from_millis(30));
assert_eq!(c.get("C", "k"), None, "expired entry must be gone");
}
#[test]
fn invalidate_flushes_only_its_namespace() {
let c = InProcessCache::default();
c.put("A", "k", b"1".to_vec(), None);
c.put("B", "k", b"2".to_vec(), None);
c.invalidate("A");
assert_eq!(c.get("A", "k"), None);
assert_eq!(c.get("B", "k"), Some(b"2".to_vec()), "other cache untouched");
}
#[test]
fn oversized_value_is_not_cached() {
let c = InProcessCache::new(10, 4);
c.put("C", "k", vec![0u8; 100], None);
assert_eq!(c.get("C", "k"), None, "oversized value must not be cached");
}
#[test]
fn capacity_evicts_lru() {
let c = InProcessCache::new(2, DEFAULT_MAX_VALUE_BYTES);
c.put("C", "a", b"1".to_vec(), None);
c.put("C", "b", b"2".to_vec(), None);
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()));
assert_eq!(c.get("C", "b"), None, "LRU entry evicted");
assert_eq!(c.get("C", "c"), Some(b"3".to_vec()));
}
#[test]
fn errors_are_never_cached() {
let c = InProcessCache::default();
let r: Result<Vec<u8>, &str> =
c.get_or_compute("C", "k", None, || Err("boom"));
assert!(r.is_err());
assert_eq!(c.get("C", "k"), None, "a computed error must not be cached");
}
#[test]
fn single_flight_coalesces_concurrent_misses() {
let c = StdArc::new(InProcessCache::default());
let computes = StdArc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..16 {
let c = c.clone();
let computes = computes.clone();
handles.push(std::thread::spawn(move || {
c.get_or_compute::<_, ()>("C", "hot", None, || {
computes.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(20));
Ok(b"result".to_vec())
})
.unwrap()
}));
}
for h in handles {
assert_eq!(h.join().unwrap(), b"result".to_vec());
}
assert_eq!(
computes.load(Ordering::SeqCst),
1,
"single-flight: concurrent misses for one key compute exactly once"
);
}
}