use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct CacheEntry {
result: String,
created_at: Instant,
ttl: Duration,
hit_count: usize,
}
impl CacheEntry {
fn new(result: String, ttl: Duration) -> Self {
Self {
result,
created_at: Instant::now(),
ttl,
hit_count: 0,
}
}
fn is_expired(&self) -> bool {
self.created_at.elapsed() > self.ttl
}
fn increment_hit_count(&mut self) {
self.hit_count += 1;
}
}
pub struct QueryCache {
cache: Arc<RwLock<HashMap<String, CacheEntry>>>,
default_ttl: Duration,
max_size: usize,
stats: Arc<RwLock<CacheStats>>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CacheStats {
pub hits: usize,
pub misses: usize,
pub evictions: usize,
pub total_entries: usize,
}
impl CacheStats {
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
}
}
}
impl QueryCache {
pub fn new(default_ttl_secs: u64, max_size: usize) -> Self {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
default_ttl: Duration::from_secs(default_ttl_secs),
max_size,
stats: Arc::new(RwLock::new(CacheStats::default())),
}
}
fn cache_key(dataset: &str, query: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
dataset.hash(&mut hasher);
query.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn get(&self, dataset: &str, query: &str) -> Option<String> {
let key = Self::cache_key(dataset, query);
let mut cache = self.cache.write().expect("rwlock should not be poisoned");
let mut stats = self.stats.write().expect("rwlock should not be poisoned");
if let Some(entry) = cache.get_mut(&key) {
if entry.is_expired() {
cache.remove(&key);
stats.misses += 1;
stats.total_entries = cache.len();
None
} else {
entry.increment_hit_count();
stats.hits += 1;
Some(entry.result.clone())
}
} else {
stats.misses += 1;
None
}
}
pub fn set(&self, dataset: &str, query: &str, result: String) {
self.set_with_ttl(dataset, query, result, self.default_ttl);
}
pub fn set_with_ttl(&self, dataset: &str, query: &str, result: String, ttl: Duration) {
let key = Self::cache_key(dataset, query);
let mut cache = self.cache.write().expect("rwlock should not be poisoned");
self.evict_expired(&mut cache);
if cache.len() >= self.max_size {
self.evict_lru(&mut cache);
}
cache.insert(key, CacheEntry::new(result, ttl));
let mut stats = self.stats.write().expect("rwlock should not be poisoned");
stats.total_entries = cache.len();
}
fn evict_expired(&self, cache: &mut HashMap<String, CacheEntry>) {
let expired_keys: Vec<String> = cache
.iter()
.filter(|(_, entry)| entry.is_expired())
.map(|(key, _)| key.clone())
.collect();
let mut stats = self.stats.write().expect("rwlock should not be poisoned");
for key in expired_keys {
cache.remove(&key);
stats.evictions += 1;
}
}
fn evict_lru(&self, cache: &mut HashMap<String, CacheEntry>) {
if let Some((lru_key, _)) = cache.iter().min_by_key(|(_, entry)| entry.hit_count) {
let lru_key = lru_key.clone();
cache.remove(&lru_key);
let mut stats = self.stats.write().expect("rwlock should not be poisoned");
stats.evictions += 1;
}
}
pub fn clear(&self) {
let mut cache = self.cache.write().expect("rwlock should not be poisoned");
cache.clear();
let mut stats = self.stats.write().expect("rwlock should not be poisoned");
stats.total_entries = 0;
}
pub fn stats(&self) -> CacheStats {
self.stats
.read()
.expect("rwlock should not be poisoned")
.clone()
}
pub fn size(&self) -> usize {
self.cache
.read()
.expect("rwlock should not be poisoned")
.len()
}
}
static GLOBAL_CACHE: OnceLock<QueryCache> = OnceLock::new();
pub fn global_cache() -> &'static QueryCache {
GLOBAL_CACHE.get_or_init(|| {
QueryCache::new(300, 1000)
})
}
pub mod commands {
use super::*;
pub async fn stats_command() -> Result<()> {
let cache = global_cache();
let stats = cache.stats();
println!("📊 Query Cache Statistics\n");
println!(" Total Entries: {}", stats.total_entries);
println!(" Cache Hits: {}", stats.hits);
println!(" Cache Misses: {}", stats.misses);
println!(" Hit Rate: {:.2}%", stats.hit_rate() * 100.0);
println!(" Evictions: {}", stats.evictions);
println!();
Ok(())
}
pub async fn clear_command() -> Result<()> {
let cache = global_cache();
let before_size = cache.size();
cache.clear();
println!("✅ Cache cleared");
println!(" Removed {} entries", before_size);
Ok(())
}
pub async fn config_command(ttl: Option<u64>, max_size: Option<usize>) -> Result<()> {
println!("📝 Cache Configuration\n");
if let Some(ttl_secs) = ttl {
println!(" TTL: {} seconds", ttl_secs);
}
if let Some(size) = max_size {
println!(" Max Size: {} entries", size);
}
println!();
println!("💡 Note: Cache configuration is applied at startup");
println!(" Set OXIRS_CACHE_TTL and OXIRS_CACHE_SIZE environment variables");
Ok(())
}
}
#[derive(Debug)]
pub struct CacheConfig {
pub enabled: bool,
pub ttl_secs: u64,
pub max_size: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
ttl_secs: 300, max_size: 1000, }
}
}
impl CacheConfig {
pub fn from_env() -> Self {
let enabled = std::env::var("OXIRS_CACHE_ENABLED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(true);
let ttl_secs = std::env::var("OXIRS_CACHE_TTL")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(300);
let max_size = std::env::var("OXIRS_CACHE_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1000);
Self {
enabled,
ttl_secs,
max_size,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_basic_operations() {
let cache = QueryCache::new(60, 100);
let dataset = "test_dataset";
let query = "SELECT ?s WHERE { ?s ?p ?o }";
let result = r#"{"results": []}"#.to_string();
assert!(cache.get(dataset, query).is_none());
cache.set(dataset, query, result.clone());
assert_eq!(cache.get(dataset, query), Some(result));
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
}
#[test]
#[ignore = "inherently slow: requires wall-clock TTL expiry (use nextest --ignored to run)"]
fn test_cache_expiration() {
let cache = QueryCache::new(1, 100); let dataset = "test_dataset";
let query = "SELECT ?s WHERE { ?s ?p ?o }";
let result = r#"{"results": []}"#.to_string();
cache.set(dataset, query, result.clone());
assert!(cache.get(dataset, query).is_some());
std::thread::sleep(Duration::from_secs(2));
assert!(cache.get(dataset, query).is_none());
}
#[test]
fn test_cache_eviction() {
let cache = QueryCache::new(60, 2);
cache.set("ds1", "q1", "r1".to_string());
cache.set("ds1", "q2", "r2".to_string());
assert_eq!(cache.size(), 2);
cache.set("ds1", "q3", "r3".to_string());
assert_eq!(cache.size(), 2);
}
}