use bytes::Bytes; use moka::future::Cache; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tracing::{debug, info}; /// Configuration for the file content cache #[derive(Debug, Clone)] pub struct FileContentCacheConfig { /// Maximum size of individual files to cache (bytes) pub max_file_size: usize, /// Maximum total cache size (bytes) pub max_total_size: usize, /// Maximum number of entries pub max_entries: usize, } impl Default for FileContentCacheConfig { fn default() -> Self { Self { max_file_size: 10 * 1024 * 1024, // 10MB max per file max_total_size: 512 * 1024 * 1024, // 512MB total cache max_entries: 10000, // Max 10k files } } } impl FileContentCacheConfig { /// Create a new configuration with custom values pub fn new(max_file_mb: usize, max_total_mb: usize, max_entries: usize) -> Self { Self { max_file_size: max_file_mb * 1024 * 1024, max_total_size: max_total_mb * 1024 * 1024, max_entries, } } } /// Cache entry with metadata #[derive(Clone)] struct CacheEntry { content: Bytes, etag: Arc, content_type: Arc, } /// Lock-free concurrent file content cache backed by `moka`. /// /// Unlike the previous `lru::LruCache` + `RwLock` design, `moka` uses /// lock-free reads. Concurrent downloads no longer serialize on a write lock /// just to update LRU order. pub struct FileContentCache { cache: Cache, config: FileContentCacheConfig, hits: AtomicUsize, misses: AtomicUsize, } impl FileContentCache { /// Create a new file content cache with the given configuration pub fn new(config: FileContentCacheConfig) -> Self { info!( "Initializing FileContentCache (moka): max_file={}MB, max_total={}MB, max_entries={}", config.max_file_size / (1024 * 1024), config.max_total_size / (1024 * 1024), config.max_entries ); let cache = Cache::builder() .max_capacity(config.max_total_size as u64) .weigher(|_key: &String, value: &CacheEntry| -> u32 { // Weight = content size. moka evicts entries when the sum // of weights exceeds max_capacity. value.content.len().min(u32::MAX as usize) as u32 }) .time_to_live(Duration::from_secs(3600)) .time_to_idle(Duration::from_secs(300)) .build(); Self { cache, config, hits: AtomicUsize::new(0), misses: AtomicUsize::new(0), } } } impl Default for FileContentCache { fn default() -> Self { Self::new(FileContentCacheConfig::default()) } } impl FileContentCache { /// Check if a file should be cached based on its size pub fn should_cache(&self, size: usize) -> bool { size <= self.config.max_file_size } /// Get file content from cache (lock-free read) /// /// Returns `(content, etag, content_type)` if found. /// All three clones are O(1): `Bytes` and `Arc` only bump a ref count. pub async fn get(&self, file_id: &str) -> Option<(Bytes, Arc, Arc)> { if let Some(entry) = self.cache.get(file_id).await { self.hits.fetch_add(1, Ordering::Relaxed); debug!("Cache HIT for file: {}", file_id); Some(( entry.content.clone(), entry.etag.clone(), entry.content_type.clone(), )) } else { self.misses.fetch_add(1, Ordering::Relaxed); debug!("Cache MISS for file: {}", file_id); None } } /// Check if file exists in cache without updating LRU order pub async fn contains(&self, file_id: &str) -> bool { self.cache.contains_key(file_id) } /// Put file content into cache /// /// Moka handles eviction automatically based on weight (content size). pub async fn put( &self, file_id: String, content: Bytes, etag: Arc, content_type: Arc, ) { let size = content.len(); // Don't cache if too large if size > self.config.max_file_size { debug!("File {} too large to cache: {} bytes", file_id, size); return; } let entry = CacheEntry { content, etag, content_type, }; self.cache.insert(file_id.clone(), entry).await; debug!("Cached file {} ({} bytes)", file_id, size); } /// Remove a file from cache (e.g., when file is deleted or modified) pub async fn invalidate(&self, file_id: &str) { self.cache.remove(file_id).await; debug!("Invalidated cache for file: {}", file_id); } /// Clear the entire cache pub async fn clear(&self) { self.cache.invalidate_all(); info!("Cache cleared"); } /// Get cache statistics pub fn stats(&self) -> CacheStats { let hits = self.hits.load(Ordering::Relaxed); let misses = self.misses.load(Ordering::Relaxed); let total = hits + misses; let hit_rate = if total > 0 { (hits as f64 / total as f64) * 100.0 } else { 0.0 }; CacheStats { current_size_bytes: self.cache.weighted_size() as usize, max_size_bytes: self.config.max_total_size, hits, misses, hit_rate_percent: hit_rate, } } } /// Cache statistics #[derive(Debug, Clone)] pub struct CacheStats { pub current_size_bytes: usize, pub max_size_bytes: usize, pub hits: usize, pub misses: usize, pub hit_rate_percent: f64, } /// Thread-safe wrapper for sharing across handlers pub type SharedFileContentCache = Arc; // ─── ContentCachePort implementation ───────────────────────── use crate::application::ports::cache_ports::ContentCachePort; impl ContentCachePort for FileContentCache { fn should_cache(&self, size: usize) -> bool { FileContentCache::should_cache(self, size) } async fn get(&self, file_id: &str) -> Option<(Bytes, Arc, Arc)> { FileContentCache::get(self, file_id).await } async fn put(&self, file_id: String, content: Bytes, etag: Arc, content_type: Arc) { FileContentCache::put(self, file_id, content, etag, content_type).await } async fn invalidate(&self, file_id: &str) { FileContentCache::invalidate(self, file_id).await } async fn clear(&self) { FileContentCache::clear(self).await } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_cache_put_get() { let cache = FileContentCache::new(FileContentCacheConfig { max_file_size: 1024, max_total_size: 4096, max_entries: 100, }); let content = Bytes::from("Hello, World!"); cache .put( "file1".to_string(), content.clone(), "etag1".into(), "text/plain".into(), ) .await; let result = cache.get("file1").await; assert!(result.is_some()); let (cached_content, etag, content_type) = result.unwrap(); assert_eq!(cached_content, content); assert_eq!(&*etag, "etag1"); assert_eq!(&*content_type, "text/plain"); } #[tokio::test] async fn test_cache_eviction() { let cache = FileContentCache::new(FileContentCacheConfig { max_file_size: 50, // only files ≤ 50 bytes are cacheable max_total_size: 1024, max_entries: 100, }); // A file within the limit should be cached let small = Bytes::from(vec![0u8; 50]); cache .put("small".to_string(), small, "e1".into(), "app/bin".into()) .await; assert!(cache.get("small").await.is_some()); // A file exceeding max_file_size is rejected by our own logic let big = Bytes::from(vec![1u8; 51]); cache .put("big".to_string(), big, "e2".into(), "app/bin".into()) .await; assert!( cache.get("big").await.is_none(), "File exceeding max_file_size must not be cached" ); // Explicit invalidation removes entries immediately cache.invalidate("small").await; assert!( cache.get("small").await.is_none(), "Invalidated entry must be gone" ); } #[tokio::test] async fn test_cache_invalidate() { let cache = FileContentCache::new(FileContentCacheConfig::default()); let content = Bytes::from("test"); cache .put("file1".to_string(), content, "e".into(), "t".into()) .await; assert!(cache.get("file1").await.is_some()); cache.invalidate("file1").await; assert!(cache.get("file1").await.is_none()); } }