use crate::scripting::FaultDecision;
use anyhow::Result;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use tracing::{debug, trace};
#[derive(Clone, Debug)]
pub struct DecisionCacheConfig {
pub enabled: bool,
pub max_size: usize,
pub ttl_seconds: u64,
}
impl Default for DecisionCacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_size: 10000,
ttl_seconds: 300, }
}
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct CacheKey {
method: String,
path: String,
headers: Vec<(String, String)>,
body_hash: u64,
rule_id: String,
}
impl CacheKey {
pub fn new(
method: String,
path: String,
mut headers: Vec<(String, String)>,
body: &serde_json::Value,
rule_id: String,
) -> Self {
headers.sort_by(|a, b| a.0.cmp(&b.0));
let body_hash = Self::hash_json(body);
Self {
method,
path,
headers,
body_hash,
rule_id,
}
}
fn hash_json(value: &serde_json::Value) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let json_str = serde_json::to_string(value).unwrap_or_default();
json_str.hash(&mut hasher);
hasher.finish()
}
}
#[derive(Clone, Debug)]
struct CacheEntry {
decision: FaultDecision,
created_at: Instant,
last_accessed: Instant,
access_count: u64,
}
impl CacheEntry {
fn new(decision: FaultDecision) -> Self {
let now = Instant::now();
Self {
decision,
created_at: now,
last_accessed: now,
access_count: 0,
}
}
fn is_expired(&self, ttl: Duration) -> bool {
if ttl.is_zero() {
return false; }
self.created_at.elapsed() > ttl
}
fn touch(&mut self) {
self.last_accessed = Instant::now();
self.access_count += 1;
}
}
#[derive(Clone, Debug, Default)]
pub struct CacheMetrics {
pub hits: u64,
pub misses: u64,
pub inserts: u64,
pub evictions: u64,
pub expirations: u64,
pub size: usize,
}
impl CacheMetrics {
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
}
#[derive(Debug)]
struct CacheState {
entries: HashMap<CacheKey, CacheEntry>,
metrics: CacheMetrics,
}
enum EntryState {
Expired,
Valid(FaultDecision, u64),
}
pub struct DecisionCache {
config: DecisionCacheConfig,
state: Arc<RwLock<CacheState>>,
}
impl DecisionCache {
pub fn new(config: DecisionCacheConfig) -> Self {
debug!(
"Creating decision cache: enabled={}, max_size={}, ttl={}s",
config.enabled, config.max_size, config.ttl_seconds
);
Self {
config,
state: Arc::new(RwLock::new(CacheState {
entries: HashMap::new(),
metrics: CacheMetrics::default(),
})),
}
}
pub fn get(&self, key: &CacheKey) -> Option<FaultDecision> {
if !self.config.enabled {
return None;
}
let mut state = self.state.write().expect("decision cache lock poisoned");
let ttl = Duration::from_secs(self.config.ttl_seconds);
let entry_state = state.entries.get(key).map(|entry| {
if entry.is_expired(ttl) {
EntryState::Expired
} else {
EntryState::Valid(entry.decision.clone(), entry.access_count)
}
});
match entry_state {
Some(EntryState::Expired) => {
trace!("Cache entry expired for key: {:?}", key);
state.entries.remove(key);
state.metrics.misses += 1;
state.metrics.expirations += 1;
state.metrics.size = state.entries.len();
None
}
Some(EntryState::Valid(decision, access_count)) => {
if let Some(entry) = state.entries.get_mut(key) {
entry.touch();
}
trace!(
"Cache hit for key: {:?} (access_count: {})",
key,
access_count + 1
);
state.metrics.hits += 1;
Some(decision)
}
None => {
trace!("Cache miss for key: {:?}", key);
state.metrics.misses += 1;
None
}
}
}
pub fn insert(&self, key: CacheKey, decision: FaultDecision) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
let mut state = self.state.write().expect("decision cache lock poisoned");
if state.entries.len() >= self.config.max_size && !state.entries.contains_key(&key) {
Self::evict_lru(&mut state);
}
state.entries.insert(key.clone(), CacheEntry::new(decision));
trace!("Cache insert for key: {:?}", key);
state.metrics.inserts += 1;
state.metrics.size = state.entries.len();
Ok(())
}
fn evict_lru(state: &mut CacheState) {
if let Some((key_to_evict, _)) = state
.entries
.iter()
.min_by_key(|(_, entry)| entry.last_accessed)
.map(|(k, v)| (k.clone(), v.clone()))
{
state.entries.remove(&key_to_evict);
state.metrics.evictions += 1;
trace!("Evicted LRU entry: {:?}", key_to_evict);
}
}
pub fn clear(&self) {
let mut state = self.state.write().expect("decision cache lock poisoned");
state.entries.clear();
state.metrics.size = 0;
debug!("Cache cleared");
}
pub fn metrics(&self) -> CacheMetrics {
self.state
.read()
.expect("decision cache lock poisoned")
.metrics
.clone()
}
pub fn cleanup_expired(&self) {
if !self.config.enabled || self.config.ttl_seconds == 0 {
return;
}
let mut state = self.state.write().expect("decision cache lock poisoned");
let ttl = Duration::from_secs(self.config.ttl_seconds);
let expired_keys: Vec<CacheKey> = state
.entries
.iter()
.filter(|(_, entry)| entry.is_expired(ttl))
.map(|(k, _)| k.clone())
.collect();
let count = expired_keys.len();
for key in expired_keys {
state.entries.remove(&key);
}
if count > 0 {
debug!("Cleaned up {} expired cache entries", count);
state.metrics.expirations += count as u64;
state.metrics.size = state.entries.len();
}
}
pub fn size(&self) -> usize {
self.state
.read()
.expect("decision cache lock poisoned")
.entries
.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::thread;
#[test]
fn test_cache_key_creation() {
let headers = vec![
("content-type".to_string(), "application/json".to_string()),
("x-request-id".to_string(), "123".to_string()),
];
let key1 = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
headers.clone(),
&json!({"foo": "bar"}),
"rule1".to_string(),
);
let key2 = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
headers.clone(),
&json!({"foo": "bar"}),
"rule1".to_string(),
);
assert_eq!(key1, key2);
}
#[test]
fn test_cache_key_different_order_headers() {
let headers1 = vec![
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string()),
];
let headers2 = vec![
("b".to_string(), "2".to_string()),
("a".to_string(), "1".to_string()),
];
let key1 = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
headers1,
&json!({}),
"rule1".to_string(),
);
let key2 = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
headers2,
&json!({}),
"rule1".to_string(),
);
assert_eq!(key1, key2);
}
#[test]
fn test_cache_basic_operations() {
let config = DecisionCacheConfig {
enabled: true,
max_size: 100,
ttl_seconds: 0, };
let cache = DecisionCache::new(config);
let key = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
vec![],
&json!({}),
"rule1".to_string(),
);
assert!(cache.get(&key).is_none());
let decision = FaultDecision::Latency {
duration_ms: 100,
rule_id: "rule1".to_string(),
};
cache.insert(key.clone(), decision.clone()).unwrap();
let cached = cache.get(&key).unwrap();
match cached {
FaultDecision::Latency { duration_ms, .. } => {
assert_eq!(duration_ms, 100);
}
_ => panic!("Expected Latency decision"),
}
let metrics = cache.metrics();
assert_eq!(metrics.hits, 1);
assert_eq!(metrics.misses, 1);
assert_eq!(metrics.inserts, 1);
assert_eq!(metrics.size, 1);
}
#[test]
fn test_cache_expiration() {
let config = DecisionCacheConfig {
enabled: true,
max_size: 100,
ttl_seconds: 1, };
let cache = DecisionCache::new(config);
let key = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
vec![],
&json!({}),
"rule1".to_string(),
);
let decision = FaultDecision::None;
cache.insert(key.clone(), decision).unwrap();
assert!(cache.get(&key).is_some());
thread::sleep(Duration::from_secs(2));
assert!(cache.get(&key).is_none());
let metrics = cache.metrics();
assert_eq!(metrics.expirations, 1);
}
#[test]
fn test_cache_lru_eviction() {
let config = DecisionCacheConfig {
enabled: true,
max_size: 3,
ttl_seconds: 0,
};
let cache = DecisionCache::new(config);
for i in 0..3 {
let key = CacheKey::new(
"GET".to_string(),
format!("/api/test{i}"),
vec![],
&json!({}),
format!("rule{i}"),
);
cache.insert(key, FaultDecision::None).unwrap();
}
assert_eq!(cache.size(), 3);
let key1 = CacheKey::new(
"GET".to_string(),
"/api/test1".to_string(),
vec![],
&json!({}),
"rule1".to_string(),
);
cache.get(&key1);
let key2 = CacheKey::new(
"GET".to_string(),
"/api/test2".to_string(),
vec![],
&json!({}),
"rule2".to_string(),
);
cache.get(&key2);
let key3 = CacheKey::new(
"GET".to_string(),
"/api/test3".to_string(),
vec![],
&json!({}),
"rule3".to_string(),
);
cache.insert(key3, FaultDecision::None).unwrap();
assert_eq!(cache.size(), 3);
let key0 = CacheKey::new(
"GET".to_string(),
"/api/test0".to_string(),
vec![],
&json!({}),
"rule0".to_string(),
);
assert!(cache.get(&key0).is_none());
assert!(cache.get(&key1).is_some());
assert!(cache.get(&key2).is_some());
let metrics = cache.metrics();
assert_eq!(metrics.evictions, 1);
}
#[test]
fn test_cache_disabled() {
let config = DecisionCacheConfig {
enabled: false,
max_size: 100,
ttl_seconds: 0,
};
let cache = DecisionCache::new(config);
let key = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
vec![],
&json!({}),
"rule1".to_string(),
);
let decision = FaultDecision::None;
cache.insert(key.clone(), decision).unwrap();
assert!(cache.get(&key).is_none());
assert_eq!(cache.size(), 0);
}
#[test]
fn test_cache_clear() {
let config = DecisionCacheConfig::default();
let cache = DecisionCache::new(config);
for i in 0..5 {
let key = CacheKey::new(
"GET".to_string(),
format!("/api/test{i}"),
vec![],
&json!({}),
format!("rule{i}"),
);
cache.insert(key, FaultDecision::None).unwrap();
}
assert_eq!(cache.size(), 5);
cache.clear();
assert_eq!(cache.size(), 0);
}
#[test]
fn test_cache_hit_rate() {
let config = DecisionCacheConfig::default();
let cache = DecisionCache::new(config);
let key = CacheKey::new(
"GET".to_string(),
"/api/test".to_string(),
vec![],
&json!({}),
"rule1".to_string(),
);
cache.get(&key);
cache.insert(key.clone(), FaultDecision::None).unwrap();
cache.get(&key);
cache.get(&key);
cache.get(&key);
let metrics = cache.metrics();
assert_eq!(metrics.hits, 3);
assert_eq!(metrics.misses, 1);
assert_eq!(metrics.hit_rate(), 0.75); }
#[test]
fn test_cache_cleanup_expired() {
let config = DecisionCacheConfig {
enabled: true,
max_size: 100,
ttl_seconds: 1,
};
let cache = DecisionCache::new(config);
for i in 0..5 {
let key = CacheKey::new(
"GET".to_string(),
format!("/api/test{i}"),
vec![],
&json!({}),
format!("rule{i}"),
);
cache.insert(key, FaultDecision::None).unwrap();
}
assert_eq!(cache.size(), 5);
thread::sleep(Duration::from_secs(2));
cache.cleanup_expired();
assert_eq!(cache.size(), 0);
let metrics = cache.metrics();
assert_eq!(metrics.expirations, 5);
}
}