use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub enabled: bool,
pub max_entries: usize,
pub ttl_secs: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_entries: 1000, ttl_secs: 3600, }
}
}
#[derive(Debug, Clone)]
struct CachedResult {
content: String,
created_at: Instant,
last_accessed: Instant,
access_count: usize,
}
impl CachedResult {
fn new(content: String) -> Self {
let now = Instant::now();
Self {
content,
created_at: now,
last_accessed: now,
access_count: 1,
}
}
fn access(&mut self) {
self.last_accessed = Instant::now();
self.access_count += 1;
}
fn is_expired(&self, ttl: Duration) -> bool {
self.created_at.elapsed() > ttl
}
}
#[derive(Debug, Clone, Default)]
pub struct CacheStats {
pub lookups: usize,
pub hits: usize,
pub misses: usize,
pub evictions: usize,
pub expirations: usize,
}
impl CacheStats {
pub fn hit_rate(&self) -> f64 {
if self.lookups == 0 {
0.0
} else {
self.hits as f64 / self.lookups as f64
}
}
}
pub struct LlmResultCache {
cache: Arc<RwLock<HashMap<String, CachedResult>>>,
config: CacheConfig,
stats: Arc<RwLock<CacheStats>>,
}
impl LlmResultCache {
pub fn new(config: CacheConfig) -> Self {
if config.enabled {
info!(
"🔧 LLM result cache enabled (max_entries: {}, ttl: {}s)",
config.max_entries, config.ttl_secs
);
} else {
info!("⚠️ LLM result cache disabled");
}
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
config,
stats: Arc::new(RwLock::new(CacheStats::default())),
}
}
pub async fn get(&self, key: &str) -> Option<String> {
if !self.config.enabled {
return None;
}
let mut cache = self.cache.write().await;
let mut stats = self.stats.write().await;
stats.lookups += 1;
if let Some(entry) = cache.get_mut(key) {
let ttl = Duration::from_secs(self.config.ttl_secs);
if entry.is_expired(ttl) {
cache.remove(key);
stats.misses += 1;
stats.expirations += 1;
debug!("🗑️ Cache expired for key: {}", &key[..8]);
return None;
}
entry.access();
stats.hits += 1;
debug!(
"✅ Cache HIT for key: {} (accessed {} times, age: {:.1}s)",
&key[..8],
entry.access_count,
entry.created_at.elapsed().as_secs_f64()
);
Some(entry.content.clone())
} else {
stats.misses += 1;
debug!("❌ Cache MISS for key: {}", &key[..8]);
None
}
}
pub async fn put(&self, key: String, content: String) {
if !self.config.enabled {
return;
}
let mut cache = self.cache.write().await;
if cache.len() >= self.config.max_entries && !cache.contains_key(&key) {
self.evict_lru(&mut cache).await;
}
cache.insert(key.clone(), CachedResult::new(content));
debug!(
"💾 Cached result for key: {} (total entries: {})",
&key[..8],
cache.len()
);
}
async fn evict_lru(&self, cache: &mut HashMap<String, CachedResult>) {
if cache.is_empty() {
return;
}
let lru_key = cache
.iter()
.min_by_key(|(_, entry)| entry.last_accessed)
.map(|(key, _)| key.clone());
if let Some(key) = lru_key {
cache.remove(&key);
let mut stats = self.stats.write().await;
stats.evictions += 1;
debug!("🗑️ Evicted LRU entry: {}", &key[..8]);
}
}
pub async fn clear(&self) {
let mut cache = self.cache.write().await;
cache.clear();
info!("🗑️ Cache cleared");
}
pub async fn stats(&self) -> CacheStats {
self.stats.read().await.clone()
}
pub async fn reset_stats(&self) {
let mut stats = self.stats.write().await;
*stats = CacheStats::default();
}
pub async fn size(&self) -> usize {
self.cache.read().await.len()
}
pub async fn cleanup_expired(&self) {
if !self.config.enabled {
return;
}
let mut cache = self.cache.write().await;
let ttl = Duration::from_secs(self.config.ttl_secs);
let expired_keys: Vec<String> = cache
.iter()
.filter(|(_, entry)| entry.is_expired(ttl))
.map(|(key, _)| key.clone())
.collect();
let count = expired_keys.len();
for key in expired_keys {
cache.remove(&key);
}
if count > 0 {
let mut stats = self.stats.write().await;
stats.expirations += count;
info!("🗑️ Cleaned up {} expired cache entries", count);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_cache_basic() {
let cache = LlmResultCache::new(CacheConfig::default());
assert_eq!(cache.get("key1").await, None);
cache.put("key1".to_string(), "content1".to_string()).await;
assert_eq!(cache.get("key1").await, Some("content1".to_string()));
let stats = cache.stats().await;
assert_eq!(stats.lookups, 2);
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
assert_eq!(stats.hit_rate(), 0.5);
}
#[tokio::test]
async fn test_cache_lru_eviction() {
let config = CacheConfig {
enabled: true,
max_entries: 2,
ttl_secs: 3600,
};
let cache = LlmResultCache::new(config);
cache.put("key1".to_string(), "content1".to_string()).await;
cache.put("key2".to_string(), "content2".to_string()).await;
cache.get("key2").await;
cache.put("key3".to_string(), "content3".to_string()).await;
assert_eq!(cache.get("key1").await, None);
assert_eq!(cache.get("key2").await, Some("content2".to_string()));
assert_eq!(cache.get("key3").await, Some("content3".to_string()));
let stats = cache.stats().await;
assert_eq!(stats.evictions, 1);
}
#[tokio::test]
async fn test_cache_disabled() {
let config = CacheConfig {
enabled: false,
max_entries: 100,
ttl_secs: 3600,
};
let cache = LlmResultCache::new(config);
cache.put("key1".to_string(), "content1".to_string()).await;
assert_eq!(cache.get("key1").await, None);
let stats = cache.stats().await;
assert_eq!(stats.lookups, 0); }
#[tokio::test]
async fn test_cache_ttl() {
let config = CacheConfig {
enabled: true,
max_entries: 100,
ttl_secs: 0, };
let cache = LlmResultCache::new(config);
cache.put("key1".to_string(), "content1".to_string()).await;
tokio::time::sleep(Duration::from_millis(10)).await;
assert_eq!(cache.get("key1").await, None);
let stats = cache.stats().await;
assert_eq!(stats.expirations, 1);
}
}