use std::time::Duration;
use crate::cache::CacheManager;
use crate::config::{EvictionPolicy, ResolvedAssetConfig};
use crate::error::Result;
use crate::index::{AssetIndex, CachedAsset, now_ms};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RotationStats {
pub aged_out: usize,
pub size_evicted: usize,
pub bytes_freed: u64,
}
impl RotationStats {
pub fn removed(&self) -> usize {
self.aged_out + self.size_evicted
}
}
#[derive(Debug, Clone)]
pub struct Rotator {
max_cache_size: u64,
max_file_age: Duration,
policy: EvictionPolicy,
}
impl Rotator {
pub fn new(config: &ResolvedAssetConfig) -> Self {
Self {
max_cache_size: config.max_cache_size,
max_file_age: config.max_file_age,
policy: config.eviction_policy,
}
}
pub fn rotate(&self, index: &mut AssetIndex, cache: &CacheManager) -> Result<RotationStats> {
let mut stats = RotationStats::default();
if matches!(self.policy, EvictionPolicy::None) {
return Ok(stats);
}
let max_age_ms: u64 = self.max_file_age.as_millis().min(u128::from(u64::MAX)) as u64;
let cutoff_ms = now_ms().saturating_sub(max_age_ms);
let expired: Vec<String> = index
.assets
.iter()
.filter(|(_, a)| a.last_accessed_ms < cutoff_ms)
.map(|(id, _)| id.clone())
.collect();
for id in expired {
if let Some(asset) = index.remove(&id) {
remove_cached_file(cache, &asset, &mut stats.bytes_freed)?;
stats.aged_out += 1;
}
}
let mut current_size = index.total_size();
if self.max_cache_size > 0 && current_size > self.max_cache_size {
let mut entries: Vec<CachedAsset> = index.assets.values().cloned().collect();
self.sort_for_eviction(&mut entries);
for asset in entries {
if current_size <= self.max_cache_size {
break;
}
if let Some(removed) = index.remove(&asset.id) {
current_size = current_size.saturating_sub(removed.size);
remove_cached_file(cache, &removed, &mut stats.bytes_freed)?;
stats.size_evicted += 1;
}
}
}
Ok(stats)
}
fn sort_for_eviction(&self, entries: &mut [CachedAsset]) {
match self.policy {
EvictionPolicy::Lru => {
entries.sort_by(|a, b| {
a.last_accessed_ms
.cmp(&b.last_accessed_ms)
.then_with(|| b.size.cmp(&a.size))
.then_with(|| a.id.cmp(&b.id))
});
}
EvictionPolicy::Fifo => {
entries.sort_by(|a, b| {
a.downloaded_at_ms
.cmp(&b.downloaded_at_ms)
.then_with(|| a.id.cmp(&b.id))
});
}
EvictionPolicy::None => {}
}
}
pub fn within_budget(&self, index: &AssetIndex) -> bool {
self.max_cache_size == 0 || index.total_size() <= self.max_cache_size
}
}
fn remove_cached_file(
cache: &CacheManager,
asset: &CachedAsset,
bytes_freed: &mut u64,
) -> Result<()> {
let Some(abs) = crate::cache::resolve_under_root(cache.root(), &asset.local_path) else {
tracing::warn!(
asset_id = asset.id.as_str(),
path = ?asset.local_path,
"skipping eviction — index entry points outside the cache root",
);
return Ok(());
};
let existed = cache.exists(&abs);
cache.delete(&abs)?;
if existed {
*bytes_freed += asset.size;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::CacheManager;
use crate::config::ResolvedAssetConfig;
use crate::index::{CachedAsset, NewCachedAsset};
use devboy_core::asset::AssetContext;
use std::path::PathBuf;
use tempfile::tempdir;
fn cfg(cache_dir: PathBuf, max_size: u64, max_age: Duration) -> ResolvedAssetConfig {
ResolvedAssetConfig {
cache_dir,
max_cache_size: max_size,
max_file_age: max_age,
eviction_policy: EvictionPolicy::Lru,
}
}
fn store_asset(cache: &CacheManager, id: &str, size: u64) -> CachedAsset {
let ctx = AssetContext::Issue {
key: "DEV-1".into(),
};
let data = vec![0u8; size as usize];
let stored = cache
.store(&ctx, id, &format!("{id}.bin"), &data)
.expect("store");
let rel = stored
.path
.strip_prefix(cache.root())
.unwrap()
.to_path_buf();
CachedAsset::new(NewCachedAsset {
id: id.into(),
filename: format!("{id}.bin"),
mime_type: None,
size: stored.size,
local_path: rel,
context: ctx,
checksum_sha256: stored.checksum_sha256,
remote_url: None,
})
}
#[test]
fn policy_none_is_noop() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let asset = store_asset(&cache, "a1", 10);
index.upsert(asset);
let mut resolved = cfg(tmp.path().to_path_buf(), 0, Duration::from_secs(1));
resolved.eviction_policy = EvictionPolicy::None;
let rotator = Rotator::new(&resolved);
let stats = rotator.rotate(&mut index, &cache).unwrap();
assert_eq!(stats.removed(), 0);
assert_eq!(index.len(), 1);
}
#[test]
fn age_based_eviction() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let mut old = store_asset(&cache, "old", 5);
old.last_accessed_ms = now_ms() - 600_000;
index.upsert(old);
index.upsert(store_asset(&cache, "fresh", 5));
let resolved = cfg(
tmp.path().to_path_buf(),
1024 * 1024,
Duration::from_secs(60),
);
let rotator = Rotator::new(&resolved);
let stats = rotator.rotate(&mut index, &cache).unwrap();
assert_eq!(stats.aged_out, 1);
assert_eq!(stats.size_evicted, 0);
assert!(index.get("old").is_none());
assert!(index.get("fresh").is_some());
}
#[test]
fn size_based_lru_eviction() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let mut a = store_asset(&cache, "a", 100);
a.last_accessed_ms = 1_000;
index.upsert(a);
let mut b = store_asset(&cache, "b", 100);
b.last_accessed_ms = 2_000;
index.upsert(b);
let mut c = store_asset(&cache, "c", 100);
c.last_accessed_ms = 3_000;
index.upsert(c);
let resolved = cfg(
tmp.path().to_path_buf(),
150,
Duration::from_secs(100 * 365 * 86_400),
);
let rotator = Rotator::new(&resolved);
let stats = rotator.rotate(&mut index, &cache).unwrap();
assert_eq!(stats.aged_out, 0);
assert_eq!(stats.size_evicted, 2);
assert!(index.get("a").is_none());
assert!(index.get("b").is_none());
assert!(index.get("c").is_some());
assert!(index.total_size() <= 150);
}
#[test]
fn fifo_orders_by_download_time() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let mut a = store_asset(&cache, "a", 100);
a.downloaded_at_ms = 1_000;
a.last_accessed_ms = 9_000; index.upsert(a);
let mut b = store_asset(&cache, "b", 100);
b.downloaded_at_ms = 2_000;
b.last_accessed_ms = 1_000;
index.upsert(b);
let mut resolved = cfg(
tmp.path().to_path_buf(),
150,
Duration::from_secs(100 * 365 * 86_400),
);
resolved.eviction_policy = EvictionPolicy::Fifo;
let rotator = Rotator::new(&resolved);
rotator.rotate(&mut index, &cache).unwrap();
assert!(index.get("a").is_none());
assert!(index.get("b").is_some());
}
#[test]
fn within_budget_fast_path() {
let tmp = tempdir().unwrap();
let mut index = AssetIndex::empty();
let resolved = cfg(
tmp.path().to_path_buf(),
1_000,
Duration::from_secs(100 * 365 * 86_400),
);
let rotator = Rotator::new(&resolved);
assert!(rotator.within_budget(&index));
index.upsert(CachedAsset::new(NewCachedAsset {
id: "a".into(),
filename: "a.bin".into(),
mime_type: None,
size: 500,
local_path: PathBuf::from("issues/x/a.bin"),
context: AssetContext::Issue { key: "x".into() },
checksum_sha256: "deadbeef".into(),
remote_url: None,
}));
assert!(rotator.within_budget(&index));
}
#[test]
fn age_ties_prefer_larger_files() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let mut small = store_asset(&cache, "small", 10);
small.last_accessed_ms = 1_000;
index.upsert(small);
let mut big = store_asset(&cache, "big", 1_000);
big.last_accessed_ms = 1_000;
index.upsert(big);
let resolved = cfg(
tmp.path().to_path_buf(),
100,
Duration::from_secs(100 * 365 * 86_400),
);
let rotator = Rotator::new(&resolved);
rotator.rotate(&mut index, &cache).unwrap();
assert!(index.get("big").is_none());
assert!(index.get("small").is_some());
}
#[test]
fn bytes_freed_only_counts_existing_files() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
let mut real = store_asset(&cache, "real", 100);
real.last_accessed_ms = 1_000;
index.upsert(real);
let phantom = CachedAsset::new(NewCachedAsset {
id: "phantom".into(),
filename: "phantom.bin".into(),
mime_type: None,
size: 999,
local_path: PathBuf::from("issues/ghost/phantom.bin"),
context: AssetContext::Issue {
key: "ghost".into(),
},
checksum_sha256: "abcd".into(),
remote_url: None,
});
index.upsert(CachedAsset {
last_accessed_ms: 1_000,
..phantom
});
let resolved = cfg(
tmp.path().to_path_buf(),
50,
Duration::from_secs(100 * 365 * 86_400),
);
let rotator = Rotator::new(&resolved);
let stats = rotator.rotate(&mut index, &cache).unwrap();
assert!(index.get("real").is_none());
assert!(index.get("phantom").is_none());
assert_eq!(stats.bytes_freed, 100);
}
#[test]
fn bytes_freed_skips_unsafe_paths() {
let tmp = tempdir().unwrap();
let cache = CacheManager::new(tmp.path().to_path_buf()).unwrap();
let mut index = AssetIndex::empty();
index.upsert(CachedAsset {
last_accessed_ms: 1_000,
..CachedAsset::new(NewCachedAsset {
id: "hostile".into(),
filename: "passwd".into(),
mime_type: None,
size: 4096,
local_path: PathBuf::from("../../etc/passwd"),
context: AssetContext::Issue { key: "x".into() },
checksum_sha256: "dead".into(),
remote_url: None,
})
});
let resolved = cfg(
tmp.path().to_path_buf(),
1,
Duration::from_secs(100 * 365 * 86_400),
);
let rotator = Rotator::new(&resolved);
let stats = rotator.rotate(&mut index, &cache).unwrap();
assert!(index.get("hostile").is_none());
assert_eq!(stats.bytes_freed, 0);
}
}