//! Gateway Cache - LRU cache for frequently accessed content //! //! Caches resolved content to reduce load on storage nodes //! and improve response times for popular content. use crate::cid::ContentId; use super::GatewayResponse; use std::collections::HashMap; /// LRU cache entry #[derive(Debug, Clone)] struct CacheEntry { /// Cached response response: GatewayResponse, /// Access count access_count: u64, /// Last access timestamp last_access: u64, /// Size in bytes size: u64, } /// LRU cache for gateway responses pub struct GatewayCache { /// Maximum cache size in bytes max_size: u64, /// Current size in bytes current_size: u64, /// Cached entries by CID entries: HashMap, /// Access order for LRU eviction (CID, last_access) access_order: Vec<(ContentId, u64)>, /// Cache statistics stats: CacheStats, } /// Cache statistics #[derive(Debug, Clone, Default)] pub struct CacheStats { /// Total hits pub hits: u64, /// Total misses pub misses: u64, /// Total evictions pub evictions: u64, /// Total bytes cached pub bytes_cached: u64, /// Total bytes evicted pub bytes_evicted: u64, } impl GatewayCache { /// Create a new cache with maximum size in bytes pub fn new(max_size: u64) -> Self { Self { max_size, current_size: 0, entries: HashMap::new(), access_order: Vec::new(), stats: CacheStats::default(), } } /// Get an entry from the cache pub fn get(&self, cid: &ContentId) -> Option { self.entries.get(cid).map(|entry| entry.response.clone()) } /// Get an entry and update access stats pub fn get_mut(&mut self, cid: &ContentId) -> Option { let now = current_timestamp(); if let Some(entry) = self.entries.get_mut(cid) { entry.access_count += 1; entry.last_access = now; self.stats.hits += 1; // Update access order if let Some(pos) = self.access_order.iter().position(|(c, _)| c == cid) { self.access_order.remove(pos); } self.access_order.push((cid.clone(), now)); Some(entry.response.clone()) } else { self.stats.misses += 1; None } } /// Put an entry in the cache pub fn put(&mut self, cid: ContentId, response: GatewayResponse) { let size = response.content.len() as u64; // Don't cache if larger than max size if size > self.max_size { return; } // Remove existing entry if present if self.entries.contains_key(&cid) { self.remove(&cid); } // Evict entries until we have space while self.current_size + size > self.max_size && !self.entries.is_empty() { self.evict_lru(); } let now = current_timestamp(); let entry = CacheEntry { response, access_count: 1, last_access: now, size, }; self.entries.insert(cid.clone(), entry); self.access_order.push((cid, now)); self.current_size += size; self.stats.bytes_cached += size; } /// Remove an entry from the cache pub fn remove(&mut self, cid: &ContentId) -> Option { if let Some(entry) = self.entries.remove(cid) { self.current_size -= entry.size; self.access_order.retain(|(c, _)| c != cid); Some(entry.response) } else { None } } /// Evict the least recently used entry fn evict_lru(&mut self) { if let Some((cid, _)) = self.access_order.first().cloned() { if let Some(entry) = self.entries.remove(&cid) { self.current_size -= entry.size; self.stats.evictions += 1; self.stats.bytes_evicted += entry.size; } self.access_order.remove(0); } } /// Clear the entire cache pub fn clear(&mut self) { self.entries.clear(); self.access_order.clear(); self.current_size = 0; } /// Get cache statistics pub fn stats(&self) -> &CacheStats { &self.stats } /// Get current cache size in bytes pub fn size(&self) -> u64 { self.current_size } /// Get maximum cache size in bytes pub fn max_size(&self) -> u64 { self.max_size } /// Get number of entries in cache pub fn len(&self) -> usize { self.entries.len() } /// Check if cache is empty pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// Check if CID is in cache pub fn contains(&self, cid: &ContentId) -> bool { self.entries.contains_key(cid) } /// Get hit rate pub fn hit_rate(&self) -> f64 { let total = self.stats.hits + self.stats.misses; if total == 0 { 0.0 } else { self.stats.hits as f64 / total as f64 } } /// Prune entries not accessed since cutoff timestamp pub fn prune_stale(&mut self, cutoff_timestamp: u64) { let stale: Vec = self .entries .iter() .filter(|(_, entry)| entry.last_access < cutoff_timestamp) .map(|(cid, _)| cid.clone()) .collect(); for cid in stale { self.remove(&cid); } } } /// Get current timestamp in seconds fn current_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() } #[cfg(test)] mod tests { use super::*; use crate::cid::ContentId; fn make_response(data: &[u8]) -> GatewayResponse { let cid = ContentId::from_content(data); GatewayResponse { cid: cid.clone(), content: data.to_vec(), mime_type: "application/octet-stream".to_string(), size: data.len() as u64, } } #[test] fn test_cache_put_get() { let mut cache = GatewayCache::new(1024); let data = b"hello world"; let cid = ContentId::from_content(data); let response = make_response(data); cache.put(cid.clone(), response); let retrieved = cache.get(&cid).unwrap(); assert_eq!(retrieved.content, data); } #[test] fn test_cache_eviction() { let mut cache = GatewayCache::new(100); // Add entries until we exceed limit for i in 0..10 { let data = vec![i; 20]; let cid = ContentId::from_content(&data); let response = make_response(&data); cache.put(cid, response); } // Should have evicted some entries assert!(cache.size() <= 100); assert!(cache.len() < 10); } #[test] fn test_cache_lru_order() { let mut cache = GatewayCache::new(100); // Add 3 entries (each ~10 bytes) let entries: Vec<_> = (0..3) .map(|i| { let data = vec![i; 10]; let cid = ContentId::from_content(&data); let response = make_response(&data); (cid, response) }) .collect(); for (cid, response) in &entries { cache.put(cid.clone(), response.clone()); } // Access first entry to make it recently used cache.get_mut(&entries[0].0); // Add more entries to trigger eviction for i in 3..10 { let data = vec![i; 10]; let cid = ContentId::from_content(&data); let response = make_response(&data); cache.put(cid, response); } // First entry should still be present (was accessed recently) assert!(cache.contains(&entries[0].0)); } }