use crate::core::NormalizedPath;
use crate::depgraph::{ContextKey, DepGraph};
use crate::fscache::CacheSystem;
use dashmap::DashMap;
use rayon::prelude::*;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use super::server::{trim_fast_hit_cache, CachedArtifact, FastHitEntry};
const METADATA_ENTRY_BYTES: usize = 400;
const JOURNAL_ENTRY_BYTES: usize = 280;
const DEPGRAPH_FILE_BYTES: usize = 600;
const DEPGRAPH_CONTEXT_BYTES: usize = 2048;
const FAST_HIT_ENTRY_BYTES: usize = 200;
const ARTIFACT_OVERHEAD_BYTES: usize = 200;
const FAST_HIT_BUDGET_TRIM_MAX_AGE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct MemorySnapshot {
pub(crate) metadata_entries: usize,
pub(crate) journal_entries: usize,
pub(crate) depgraph_files: usize,
pub(crate) depgraph_contexts: usize,
pub(crate) fast_hit_entries: usize,
pub(crate) artifact_entries: usize,
pub(crate) artifact_payload_bytes: usize,
pub(crate) in_flight_bytes: usize,
pub(crate) total_bytes: usize,
}
pub(crate) fn memory_snapshot(
cache_system: &CacheSystem,
dep_graph: &DepGraph,
fast_hit_cache: &DashMap<ContextKey, FastHitEntry>,
artifacts: &DashMap<String, CachedArtifact>,
in_flight_bytes: usize,
) -> MemorySnapshot {
let metadata_entries = cache_system.metadata().len();
let journal_entries = cache_system.journal().last_change_len();
let dg_stats = dep_graph.stats();
let depgraph_files = dg_stats.file_count;
let depgraph_contexts = dg_stats.context_count;
let fast_hit_entries = fast_hit_cache.len();
let artifact_entries = artifacts.len();
let artifact_payload_bytes: usize = artifacts
.iter()
.map(|entry| entry.value().meta.total_size as usize)
.sum();
let total_bytes = metadata_entries * METADATA_ENTRY_BYTES
+ journal_entries * JOURNAL_ENTRY_BYTES
+ depgraph_files * DEPGRAPH_FILE_BYTES
+ depgraph_contexts * DEPGRAPH_CONTEXT_BYTES
+ fast_hit_entries * FAST_HIT_ENTRY_BYTES
+ artifact_entries * ARTIFACT_OVERHEAD_BYTES
+ in_flight_bytes;
MemorySnapshot {
metadata_entries,
journal_entries,
depgraph_files,
depgraph_contexts,
fast_hit_entries,
artifact_entries,
artifact_payload_bytes,
in_flight_bytes,
total_bytes,
}
}
pub(crate) fn evict_to_budget(
budget_bytes: u64,
cache_system: &CacheSystem,
dep_graph: &DepGraph,
fast_hit_cache: &DashMap<ContextKey, FastHitEntry>,
artifacts: &DashMap<String, CachedArtifact>,
in_flight_bytes: usize,
) -> (u64, usize) {
let snap = memory_snapshot(
cache_system,
dep_graph,
fast_hit_cache,
artifacts,
in_flight_bytes,
);
if (snap.total_bytes as u64) <= budget_bytes {
return (0, 0);
}
let target = (budget_bytes as f64 * 0.9) as u64;
let mut to_free = snap.total_bytes as u64 - target;
let mut total_freed: u64 = 0;
let mut total_items: usize = 0;
if to_free > 0 && snap.fast_hit_entries > 0 {
let removed = trim_fast_hit_cache(fast_hit_cache, FAST_HIT_BUDGET_TRIM_MAX_AGE);
let freed = (removed * FAST_HIT_ENTRY_BYTES) as u64;
total_freed += freed;
total_items += removed;
to_free = to_free.saturating_sub(freed);
}
if to_free > 0 && !cache_system.metadata().is_empty() {
let entries_to_evict = (to_free as usize / METADATA_ENTRY_BYTES)
.max(1)
.min(cache_system.metadata().len());
let (meta_removed, journal_removed) = cache_system.evict_oldest(entries_to_evict);
let freed =
(meta_removed * METADATA_ENTRY_BYTES + journal_removed * JOURNAL_ENTRY_BYTES) as u64;
total_freed += freed;
total_items += meta_removed + journal_removed;
to_free = to_free.saturating_sub(freed);
}
if to_free > 0 {
let dg_stats = dep_graph.stats();
if dg_stats.context_count > 0 {
let removed = dep_graph.trim(Duration::ZERO);
let freed = (removed * DEPGRAPH_CONTEXT_BYTES) as u64;
let files_after = dep_graph.stats().file_count;
let files_freed = dg_stats.file_count.saturating_sub(files_after);
let file_bytes = (files_freed * DEPGRAPH_FILE_BYTES) as u64;
total_freed += freed + file_bytes;
total_items += removed + files_freed;
}
}
(total_freed, total_items)
}
struct DiskArtifact {
key: String,
total_size: u64,
mtime: std::time::SystemTime,
files: Vec<NormalizedPath>,
}
pub(crate) fn evict_disk_artifacts(
artifact_dir: &Path,
artifacts: &DashMap<String, CachedArtifact>,
max_cache_size: u64,
) -> (u64, usize) {
let entries = match std::fs::read_dir(artifact_dir) {
Ok(e) => e,
Err(_) => return (0, 0),
};
let mut groups: HashMap<String, DiskArtifact> = HashMap::new();
let mut total_disk: u64 = 0;
for entry in entries.flatten() {
let path = entry.path();
let Some(fname) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let key = if let Some(stem) = fname.strip_suffix(".meta") {
stem.to_string()
} else if let Some(pos) = fname.rfind('_') {
fname[..pos].to_string()
} else {
continue;
};
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
total_disk += size;
let group = groups.entry(key.clone()).or_insert_with(|| DiskArtifact {
key,
total_size: 0,
mtime: std::time::SystemTime::UNIX_EPOCH,
files: Vec::new(),
});
group.total_size += size;
if let Ok(meta) = path.metadata() {
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if mtime > group.mtime {
group.mtime = mtime;
}
}
group.files.push(path.into());
}
if total_disk <= max_cache_size {
return (0, 0);
}
let target = (max_cache_size as f64 * 0.9) as u64;
let mut to_free = total_disk.saturating_sub(target);
let mut sorted: Vec<DiskArtifact> = groups.into_values().collect();
sorted.sort_by_key(|a| a.mtime);
let mut bytes_freed: u64 = 0;
let mut artifacts_removed: usize = 0;
let mut to_evict: Vec<DiskArtifact> = Vec::new();
for artifact in sorted {
if to_free == 0 {
break;
}
bytes_freed += artifact.total_size;
to_free = to_free.saturating_sub(artifact.total_size);
artifacts_removed += 1;
to_evict.push(artifact);
}
let all_files: Vec<&NormalizedPath> = to_evict.iter().flat_map(|a| &a.files).collect();
all_files.par_iter().for_each(|file| {
let _ = std::fs::remove_file(file);
});
for artifact in &to_evict {
artifacts.remove(&artifact.key);
}
(bytes_freed, artifacts_removed)
}
#[cfg(test)]
mod tests {
use super::super::server::CachedPayload;
use super::*;
use crate::depgraph::CompileContext;
use crate::fscache::{Confidence, FileMetadata};
use std::time::{Instant, SystemTime};
fn empty_caches() -> (
CacheSystem,
DepGraph,
DashMap<ContextKey, FastHitEntry>,
DashMap<String, CachedArtifact>,
) {
(
CacheSystem::new(),
DepGraph::new(),
DashMap::new(),
DashMap::new(),
)
}
fn make_ctx(source: &str) -> CompileContext {
CompileContext {
source_file: source.into(),
include_search: crate::depgraph::IncludeSearchPaths::default(),
defines: Vec::new(),
flags: Vec::new(),
force_includes: Vec::new(),
unknown_flags: Vec::new(),
}
}
fn make_context_key(source: &str) -> ContextKey {
make_ctx(source).context_key()
}
#[test]
fn snapshot_empty() {
let (cs, dg, fh, art) = empty_caches();
let snap = memory_snapshot(&cs, &dg, &fh, &art, 0);
assert_eq!(snap.total_bytes, 0);
assert_eq!(snap.metadata_entries, 0);
assert_eq!(snap.fast_hit_entries, 0);
assert_eq!(snap.artifact_entries, 0);
}
#[test]
fn snapshot_with_entries() {
let (cs, dg, fh, _art) = empty_caches();
fh.insert(
make_context_key("/tmp/snap.c"),
FastHitEntry {
clock: crate::fscache::Clock::ZERO,
artifact_key_hex: String::new(),
cached_at: Instant::now(),
},
);
let snap = memory_snapshot(&cs, &dg, &fh, &DashMap::new(), 0);
assert_eq!(snap.fast_hit_entries, 1);
assert!(snap.total_bytes >= FAST_HIT_ENTRY_BYTES);
}
#[test]
fn evict_noop_under_budget() {
let (cs, dg, fh, art) = empty_caches();
let (freed, items) = evict_to_budget(1_073_741_824, &cs, &dg, &fh, &art, 0);
assert_eq!(freed, 0);
assert_eq!(items, 0);
}
#[test]
fn evict_fast_hit_first() {
let (cs, dg, fh, art) = empty_caches();
let aged = Instant::now() - Duration::from_secs(60);
for i in 0..100 {
fh.insert(
make_context_key(&format!("/tmp/fh{i}.c")),
FastHitEntry {
clock: crate::fscache::Clock::ZERO,
artifact_key_hex: String::new(),
cached_at: aged,
},
);
}
let budget = 1000; let (freed, items) = evict_to_budget(budget, &cs, &dg, &fh, &art, 0);
assert!(freed > 0);
assert_eq!(items, 100); assert!(fh.is_empty());
}
#[test]
fn evict_fast_hit_preserves_recent_entries() {
let (cs, dg, fh, art) = empty_caches();
let aged = Instant::now() - Duration::from_secs(60);
let recent = Instant::now();
for i in 0..50 {
fh.insert(
make_context_key(&format!("/tmp/old{i}.c")),
FastHitEntry {
clock: crate::fscache::Clock::ZERO,
artifact_key_hex: String::new(),
cached_at: aged,
},
);
}
for i in 0..50 {
fh.insert(
make_context_key(&format!("/tmp/new{i}.c")),
FastHitEntry {
clock: crate::fscache::Clock::ZERO,
artifact_key_hex: String::new(),
cached_at: recent,
},
);
}
let budget = 1000; let (_freed, items) = evict_to_budget(budget, &cs, &dg, &fh, &art, 0);
assert_eq!(items, 50, "only the 50 aged entries should evict");
assert_eq!(fh.len(), 50, "the 50 recent entries must survive");
for i in 0..50 {
assert!(
fh.contains_key(&make_context_key(&format!("/tmp/new{i}.c"))),
"recent entry /tmp/new{i}.c should survive the budget-pressure trim",
);
}
}
#[test]
fn evict_cascades_to_metadata() {
let (cs, dg, fh, art) = empty_caches();
for i in 0..50 {
cs.metadata().insert(
NormalizedPath::from(format!("/tmp/meta{i}.c")),
FileMetadata {
mtime: SystemTime::now(),
size: 100,
confidence: Confidence::High,
last_verified: Instant::now(),
content_hash: None,
},
);
}
let paths: Vec<NormalizedPath> = (0..50)
.map(|i| NormalizedPath::from(format!("/tmp/meta{i}.c")))
.collect();
cs.apply_changes(paths);
let budget = 1000; let (freed, items) = evict_to_budget(budget, &cs, &dg, &fh, &art, 0);
assert!(freed > 0);
assert!(items > 0);
assert!(cs.metadata().len() < 50);
}
#[test]
fn evict_cascades_to_depgraph() {
let (cs, dg, fh, art) = empty_caches();
for i in 0..20 {
dg.register(make_ctx(&format!("/tmp/src{i}.c")));
}
for i in 0..10 {
cs.metadata().insert(
NormalizedPath::from(format!("/tmp/m{i}.c")),
FileMetadata {
mtime: SystemTime::now(),
size: 100,
confidence: Confidence::High,
last_verified: Instant::now(),
content_hash: None,
},
);
}
let budget = 1000; let (freed, items) = evict_to_budget(budget, &cs, &dg, &fh, &art, 0);
assert!(freed > 0);
assert!(items > 0);
assert_eq!(dg.stats().context_count, 0);
}
#[test]
fn snapshot_includes_in_flight_bytes() {
let (cs, dg, fh, art) = empty_caches();
let snap = memory_snapshot(&cs, &dg, &fh, &art, 500_000);
assert_eq!(snap.in_flight_bytes, 500_000);
assert_eq!(snap.total_bytes, 500_000);
}
#[test]
fn in_flight_bytes_push_over_budget_triggers_eviction() {
let (cs, dg, fh, art) = empty_caches();
let aged = Instant::now() - Duration::from_secs(60);
for i in 0..100 {
fh.insert(
make_context_key(&format!("/tmp/inflight{i}.c")),
FastHitEntry {
clock: crate::fscache::Clock::ZERO,
artifact_key_hex: String::new(),
cached_at: aged,
},
);
}
let (freed, items) = evict_to_budget(100_000, &cs, &dg, &fh, &art, 0);
assert_eq!(freed, 0);
assert_eq!(items, 0);
let (freed, items) = evict_to_budget(100_000, &cs, &dg, &fh, &art, 90_000);
assert!(freed > 0);
assert!(items > 0);
assert!(fh.is_empty());
}
#[test]
fn in_flight_bytes_alone_over_budget_evicts_nothing_when_caches_empty() {
let (cs, dg, fh, art) = empty_caches();
let (freed, items) = evict_to_budget(1000, &cs, &dg, &fh, &art, 50_000);
assert_eq!(freed, 0);
assert_eq!(items, 0);
}
fn make_artifact(payload_size: usize) -> CachedArtifact {
use crate::artifact::ArtifactIndex;
CachedArtifact {
meta: ArtifactIndex::new(
vec!["test.o".to_string()],
vec![payload_size as u64],
Vec::new(),
Vec::new(),
0,
),
stdout: std::sync::Arc::new(Vec::new()),
stderr: std::sync::Arc::new(Vec::new()),
payloads: Some(std::sync::Arc::from(vec![CachedPayload::Bytes(
std::sync::Arc::new(vec![0u8; payload_size]),
)])),
last_used: Instant::now(),
}
}
#[test]
fn snapshot_excludes_artifact_payload() {
let (cs, dg, fh, art) = empty_caches();
art.insert("big_artifact".to_string(), make_artifact(10_000_000));
let snap = memory_snapshot(&cs, &dg, &fh, &art, 0);
assert_eq!(snap.artifact_entries, 1);
assert_eq!(snap.artifact_payload_bytes, 10_000_000);
assert_eq!(snap.total_bytes, ARTIFACT_OVERHEAD_BYTES);
}
#[test]
fn evict_no_longer_wipes_depgraph_with_large_artifact_payload() {
let (cs, dg, fh, art) = empty_caches();
for i in 0..10 {
dg.register(make_ctx(&format!("/tmp/depgraph{i}.c")));
}
assert_eq!(dg.stats().context_count, 10);
for i in 0..5 {
art.insert(format!("art_{i}"), make_artifact(500_000));
}
let budget = 1_073_741_824u64; let (freed, items) = evict_to_budget(budget, &cs, &dg, &fh, &art, 0);
assert_eq!(freed, 0);
assert_eq!(items, 0);
assert_eq!(dg.stats().context_count, 10);
}
fn write_fake_artifact(dir: &Path, key: &str, size: usize) {
let meta_path = dir.join(format!("{key}.meta"));
let data_path = dir.join(format!("{key}_0"));
std::fs::write(&meta_path, vec![0u8; 64]).unwrap();
std::fs::write(&data_path, vec![0u8; size]).unwrap();
}
#[test]
fn disk_eviction_noop_under_budget() {
let dir = tempfile::tempdir().unwrap();
let artifacts: DashMap<String, CachedArtifact> = DashMap::new();
write_fake_artifact(dir.path(), "aaa", 100);
let (freed, removed) = evict_disk_artifacts(dir.path(), &artifacts, 1_000_000);
assert_eq!(freed, 0);
assert_eq!(removed, 0);
}
#[test]
fn disk_eviction_removes_oldest_first() {
let dir = tempfile::tempdir().unwrap();
let artifacts: DashMap<String, CachedArtifact> = DashMap::new();
write_fake_artifact(dir.path(), "old", 5000);
std::thread::sleep(Duration::from_millis(50));
write_fake_artifact(dir.path(), "mid", 5000);
std::thread::sleep(Duration::from_millis(50));
write_fake_artifact(dir.path(), "new", 5000);
let (freed, removed) = evict_disk_artifacts(dir.path(), &artifacts, 10_000);
assert!(freed > 0);
assert!(removed >= 1);
assert!(dir.path().join("new.meta").exists());
assert!(!dir.path().join("old.meta").exists());
}
#[test]
fn disk_eviction_removes_dashmap_entries() {
let dir = tempfile::tempdir().unwrap();
let artifacts: DashMap<String, CachedArtifact> = DashMap::new();
write_fake_artifact(dir.path(), "key1", 5000);
artifacts.insert("key1".to_string(), make_artifact(5000));
let (freed, removed) = evict_disk_artifacts(dir.path(), &artifacts, 0);
assert!(freed > 0);
assert_eq!(removed, 1);
assert!(artifacts.is_empty());
}
#[test]
fn disk_eviction_targets_90_percent() {
let dir = tempfile::tempdir().unwrap();
let artifacts: DashMap<String, CachedArtifact> = DashMap::new();
for i in 0..10 {
write_fake_artifact(dir.path(), &format!("k{i:02}"), 1000);
std::thread::sleep(Duration::from_millis(20));
}
let (freed, removed) = evict_disk_artifacts(dir.path(), &artifacts, 10_000);
assert!(freed > 0);
assert!(removed >= 1);
assert!(removed <= 5);
}
}