diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..01d0f572 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# TODO - CacheStore LRU Eviction Fix + +- [ ] Implement directory-aware size calculation in `crates/core/src/cache/store.rs` +- [ ] Add LRU eviction triggered by `put()` when `current_size + new_entry_size > max_size` +- [ ] Scan cache files, sort by last access timestamp (fallback to modified), delete oldest until enough space +- [ ] Update access semantics on `get()` so reads affect LRU ordering +- [ ] Add/extend unit tests validating aggregate size stays within limit after evictions +- [x] Run `cargo test` to verify correctness + + diff --git a/crates/core/src/cache/store.rs b/crates/core/src/cache/store.rs index 894146c5..e75721da 100644 --- a/crates/core/src/cache/store.rs +++ b/crates/core/src/cache/store.rs @@ -1,7 +1,16 @@ use crate::error::{GratError, GratResult}; -use std::path::PathBuf; + +use std::cmp::Ordering; + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + + + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum CacheCategory { WasmBlob, @@ -25,11 +34,11 @@ impl CacheCategory { pub struct CacheStore { cache_dir: PathBuf, - #[allow(dead_code)] max_size: u64, } impl CacheStore { + pub fn new(cache_dir: PathBuf, max_size_mb: u64) -> GratResult { std::fs::create_dir_all(&cache_dir) .map_err(|e| GratError::CacheError(format!("Failed to create cache dir: {e}")))?; @@ -50,27 +59,38 @@ impl CacheStore { } pub fn put(&self, category: CacheCategory, key: &str, value: &[u8]) -> GratResult<()> { - if value.len() as u64 > self.max_size { + let new_size = value.len() as u64; + if new_size > self.max_size { return Err(GratError::CacheError(format!( "Cache entry exceeds configured cache size limit of {} bytes", self.max_size ))); } + // Ensure we can fit the new entry by evicting least-recently-used files. + let current_size = self.total_cache_size()?; + if current_size.saturating_add(new_size) > self.max_size { + self.evict_lru_to_fit(new_size)?; + } + let path = self.entry_path(category, key); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| GratError::CacheError(format!("Failed to create dir: {e}")))?; } + std::fs::write(&path, value) .map_err(|e| GratError::CacheError(format!("Failed to write cache entry: {e}")))?; Ok(()) } + pub fn get(&self, category: CacheCategory, key: &str) -> GratResult>> { let path = self.entry_path(category, key); if path.exists() { + // Reading the file updates filesystem access metadata on most platforms. let data = std::fs::read(&path) + .map_err(|e| GratError::CacheError(format!("Failed to read cache entry: {e}")))?; Ok(Some(data)) } else { @@ -78,10 +98,12 @@ impl CacheStore { } } + pub fn contains(&self, category: CacheCategory, key: &str) -> bool { self.entry_path(category, key).exists() } + pub fn remove(&self, category: CacheCategory, key: &str) -> GratResult<()> { let path = self.entry_path(category, key); if path.exists() { @@ -104,8 +126,106 @@ impl CacheStore { fn entry_path(&self, category: CacheCategory, key: &str) -> PathBuf { self.cache_dir.join(category.as_str()).join(key) } + + fn total_cache_size(&self) -> GratResult { + let mut total: u64 = 0; + + if !self.cache_dir.exists() { + return Ok(0); + } + + for entry in walk_dir_files(&self.cache_dir) { + let size = entry.metadata().map_err(|e| { + GratError::CacheError(format!("Failed to read cache file metadata: {e}")) + })?.len(); + total = total.saturating_add(size); + } + + Ok(total) + } + + fn evict_lru_to_fit(&self, required_new_entry_size: u64) -> GratResult<()> { + // Keep evicting oldest files until we can fit the required new entry. + loop { + let current_size = self.total_cache_size()?; + if current_size.saturating_add(required_new_entry_size) <= self.max_size { + return Ok(()); + } + + let mut files = Vec::new(); + if self.cache_dir.exists() { + for entry in walk_dir_files(&self.cache_dir) { + + let meta = entry.metadata().map_err(|e| { + GratError::CacheError(format!("Failed to read cache file metadata: {e}")) + })?; + + let accessed = meta + .accessed() + .or_else(|_| meta.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + + files.push((accessed, entry)); + } + } + + if files.is_empty() { + // No files to evict, but we still don't fit. + return Err(GratError::CacheError(format!( + "Cache max_size={} too small or eviction impossible", + self.max_size + ))); + } + + // Oldest first => delete until we create headroom. + files.sort_by(|a, b| { + let ord = a.0.cmp(&b.0); + if ord == Ordering::Equal { + // Stable tie-breaker: delete longer ago deterministically. + a.1.path().cmp(&b.1.path()) + } else { + ord + } + }); + + let (oldest_ts, oldest_file) = files + .into_iter() + .next() + .expect("checked empty"); + + let path = oldest_file.path(); + // Best-effort delete. + std::fs::remove_file(&path).map_err(|e| { + GratError::CacheError(format!("Failed to evict cache file {:?}: {e}", path)) + })?; + + // If deletion succeeded, loop will re-check size. + let _ = oldest_ts; + } + } +} + +fn walk_dir_files(dir: &Path) -> Vec { + fn visit_dir(dir: &Path, out: &mut Vec) { + if let Ok(read_dir) = std::fs::read_dir(dir) { + for e in read_dir.flatten() { + let p = e.path(); + if p.is_dir() { + visit_dir(&p, out); + } else { + out.push(e); + } + } + } + } + + let mut files = Vec::new(); + visit_dir(dir, &mut files); + files } + + #[cfg(test)] mod tests { use super::*;