use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::store::{CACHE_DIR, WORKSPACE_MARKER_FILE, WORKSPACES_DIR, acquire_lock, cache_root, global_blobs_dir};
use crate::store_gc::{GcError, GcReport, dir_size, read_dir};
pub const CACHE_BUDGET_ENV: &str = "BASEMIND_CACHE_BUDGET_MB";
const DEFAULT_CACHE_BUDGET_MIB: u64 = 20 * 1024;
const BUDGET_EVICTION_HOT_FLOOR: Duration = Duration::from_secs(24 * 60 * 60);
pub const GC_STATE_FILE: &str = "gc-state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcState {
pub at_epoch_secs: u64,
pub scanned: usize,
pub removed: usize,
pub bytes_freed: u64,
pub workspaces_reaped: usize,
pub workspace_bytes_freed: u64,
#[serde(default)]
pub workspaces_evicted: usize,
#[serde(default)]
pub evicted_bytes_freed: u64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct EvictReport {
pub total_bytes_before: u64,
pub evicted: usize,
pub bytes_freed: u64,
}
pub fn cache_budget_bytes() -> Option<u64> {
let mib = match std::env::var(CACHE_BUDGET_ENV) {
Ok(raw) => match raw.trim().parse::<u64>() {
Ok(mib) => mib,
Err(_) => {
tracing::warn!(
value = %raw,
"{CACHE_BUDGET_ENV} is not a number; disabling cache budget enforcement"
);
return None;
}
},
Err(_) => DEFAULT_CACHE_BUDGET_MIB,
};
(mib > 0).then_some(mib * 1024 * 1024)
}
pub fn gc_state_path() -> PathBuf {
cache_root().join(CACHE_DIR).join(GC_STATE_FILE)
}
pub fn persist_gc_state(report: &GcReport) {
persist_gc_state_at(&gc_state_path(), report);
}
pub(crate) fn persist_gc_state_at(path: &Path, report: &GcReport) {
let state = GcState {
at_epoch_secs: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
scanned: report.scanned,
removed: report.removed,
bytes_freed: report.bytes_freed,
workspaces_reaped: report.workspaces_reaped,
workspace_bytes_freed: report.workspace_bytes_freed,
workspaces_evicted: report.workspaces_evicted,
evicted_bytes_freed: report.evicted_bytes_freed,
};
let write = serde_json::to_vec_pretty(&state)
.map_err(std::io::Error::other)
.and_then(|bytes| {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)
});
if let Err(error) = write {
tracing::warn!(%error, path = %path.display(), "failed to persist gc-state.json");
}
}
pub fn read_gc_state() -> Option<GcState> {
read_gc_state_at(&gc_state_path())
}
pub(crate) fn read_gc_state_at(path: &Path) -> Option<GcState> {
let bytes = std::fs::read(path).ok()?;
match serde_json::from_slice(&bytes) {
Ok(state) => Some(state),
Err(error) => {
tracing::debug!(%error, path = %path.display(), "gc-state.json unreadable; ignoring");
None
}
}
}
pub fn enforce_cache_budget(budget_bytes: u64) -> Result<EvictReport, GcError> {
enforce_cache_budget_in(
&cache_root().join(CACHE_DIR).join(WORKSPACES_DIR),
&global_blobs_dir(),
budget_bytes,
BUDGET_EVICTION_HOT_FLOOR,
)
}
pub(crate) fn enforce_cache_budget_in(
workspaces_dir: &Path,
blobs_dir: &Path,
budget_bytes: u64,
hot_floor: Duration,
) -> Result<EvictReport, GcError> {
let mut report = EvictReport {
total_bytes_before: dir_size_or_zero(workspaces_dir)? + dir_size_or_zero(blobs_dir)?,
..EvictReport::default()
};
if report.total_bytes_before <= budget_bytes {
return Ok(report);
}
let now = SystemTime::now();
let mut candidates = Vec::new();
if workspaces_dir.exists() {
for entry in read_dir(workspaces_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: workspaces_dir.to_path_buf(),
source,
})?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let activity = workspace_last_activity(&dir);
let idle = now.duration_since(activity).unwrap_or(Duration::ZERO);
if idle < hot_floor {
continue;
}
candidates.push((activity, dir_size(&dir)?, dir));
}
}
candidates.sort_by_key(|(activity, _, _)| *activity);
let mut projected = report.total_bytes_before;
for (_, size, dir) in candidates {
if projected <= budget_bytes {
break;
}
let Ok(lock) = acquire_lock(&dir) else {
tracing::debug!(workspace = %dir.display(), "over-budget workspace is locked; skipping");
continue;
};
std::fs::remove_dir_all(&dir).map_err(|source| GcError::Io {
path: dir.clone(),
source,
})?;
drop(lock);
projected = projected.saturating_sub(size);
report.evicted += 1;
report.bytes_freed += size;
tracing::info!(
workspace = %dir.display(),
bytes = size,
"evicted cold workspace cache to enforce the size budget"
);
}
Ok(report)
}
fn workspace_last_activity(workspace_dir: &Path) -> SystemTime {
let mut newest: Option<SystemTime> = None;
let mut consider = |path: &Path| {
if let Ok(modified) = std::fs::metadata(path).and_then(|m| m.modified())
&& newest.is_none_or(|current| modified > current)
{
newest = Some(modified);
}
};
consider(&workspace_dir.join(WORKSPACE_MARKER_FILE));
let views = workspace_dir.join(crate::store::VIEWS_DIR);
if let Ok(entries) = std::fs::read_dir(&views) {
for entry in entries.flatten() {
consider(&entry.path().join(crate::store::INDEX_FILE));
}
}
newest.unwrap_or_else(|| {
std::fs::metadata(workspace_dir)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
})
}
fn dir_size_or_zero(dir: &Path) -> Result<u64, GcError> {
if dir.exists() { dir_size(dir) } else { Ok(0) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{FileEntry, INDEX_FILE, Index, VIEWS_DIR, ensure_workspace_marker};
use std::fs;
fn seed_workspace_aged(workspaces_dir: &Path, key: &str, stem: &str, age: Duration) -> PathBuf {
let workspace_dir = workspaces_dir.join(key);
let working = workspace_dir.join(VIEWS_DIR).join("working");
fs::create_dir_all(&working).expect("mk workspace view");
let mut index = Index::empty();
index.files.insert(
crate::path::RelPath::from("src/main.rs"),
FileEntry {
hash_hex: stem.to_string(),
language: "rust".to_string(),
size_bytes: 2,
mtime: 0,
},
);
fs::write(
working.join(INDEX_FILE),
rmp_serde::to_vec_named(&index).expect("encode index"),
)
.expect("write index");
ensure_workspace_marker(&workspace_dir, workspaces_dir);
let stamp = SystemTime::now() - age;
for file in [workspace_dir.join(WORKSPACE_MARKER_FILE), working.join(INDEX_FILE)] {
fs::File::options()
.write(true)
.open(&file)
.and_then(|f| f.set_modified(stamp))
.expect("age file");
}
workspace_dir
}
#[test]
fn under_budget_is_a_no_op() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let ws = seed_workspace_aged(&workspaces, "key-a", &"a".repeat(64), Duration::from_secs(9999));
let report =
enforce_cache_budget_in(&workspaces, &blobs, u64::MAX, Duration::ZERO).expect("enforce under budget");
assert_eq!(report.evicted, 0, "under budget must evict nothing");
assert_eq!(report.bytes_freed, 0);
assert!(report.total_bytes_before > 0, "footprint is measured");
assert!(ws.exists(), "workspace untouched");
}
#[test]
fn over_budget_evicts_the_coldest_workspace_first_and_stops_at_budget() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let cold = seed_workspace_aged(
&workspaces,
"key-cold",
&"a".repeat(64),
Duration::from_secs(10 * 24 * 3600),
);
let warm = seed_workspace_aged(
&workspaces,
"key-warm",
&"b".repeat(64),
Duration::from_secs(2 * 24 * 3600),
);
let total = dir_size(&workspaces).expect("size");
let one_ws = dir_size(&cold).expect("size cold");
let budget = total - one_ws / 2;
let report = enforce_cache_budget_in(&workspaces, &blobs, budget, Duration::ZERO).expect("enforce");
assert_eq!(report.evicted, 1, "one eviction suffices to reach the budget");
assert!(!cold.exists(), "the coldest workspace is the one evicted");
assert!(warm.exists(), "the warmer workspace survives");
assert!(report.bytes_freed >= one_ws, "freed bytes cover the evicted tree");
}
#[test]
fn a_workspace_inside_the_hot_floor_is_never_evicted() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let hot = seed_workspace_aged(&workspaces, "key-hot", &"a".repeat(64), Duration::ZERO);
let report = enforce_cache_budget_in(&workspaces, &blobs, 1, Duration::from_secs(24 * 3600))
.expect("enforce with hot floor");
assert_eq!(report.evicted, 0, "a hot workspace is never evicted, even over budget");
assert!(hot.exists());
}
#[test]
fn a_locked_workspace_is_skipped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let locked = seed_workspace_aged(&workspaces, "key-locked", &"a".repeat(64), Duration::from_secs(9999));
let _held = acquire_lock(&locked).expect("hold the workspace lock");
let report = enforce_cache_budget_in(&workspaces, &blobs, 1, Duration::ZERO).expect("enforce");
assert_eq!(report.evicted, 0, "a locked workspace is never evicted");
assert!(locked.exists());
}
#[test]
fn gc_state_round_trips_and_tolerates_absence() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("cache").join(GC_STATE_FILE);
assert!(read_gc_state_at(&path).is_none(), "absent state reads as None");
let report = GcReport {
scanned: 10,
removed: 3,
bytes_freed: 4096,
workspaces_reaped: 1,
workspace_bytes_freed: 2048,
workspaces_evicted: 2,
evicted_bytes_freed: 8192,
};
persist_gc_state_at(&path, &report);
let state = read_gc_state_at(&path).expect("state persisted");
assert!(state.at_epoch_secs > 0, "timestamp recorded");
assert_eq!(state.removed, 3);
assert_eq!(state.bytes_freed, 4096);
assert_eq!(state.workspaces_reaped, 1);
assert_eq!(state.workspaces_evicted, 2);
assert_eq!(state.evicted_bytes_freed, 8192);
fs::write(&path, b"{ not json").expect("corrupt");
assert!(read_gc_state_at(&path).is_none(), "corrupt state degrades to None");
}
#[test]
fn cache_budget_env_parses_default_zero_and_garbage() {
const VAR: &str = CACHE_BUDGET_ENV;
unsafe { std::env::remove_var(VAR) };
assert_eq!(cache_budget_bytes(), Some(DEFAULT_CACHE_BUDGET_MIB * 1024 * 1024));
unsafe { std::env::set_var(VAR, "0") };
assert_eq!(cache_budget_bytes(), None, "0 disables enforcement");
unsafe { std::env::set_var(VAR, "512") };
assert_eq!(cache_budget_bytes(), Some(512 * 1024 * 1024));
unsafe { std::env::set_var(VAR, "not-a-number") };
assert_eq!(cache_budget_bytes(), None, "garbage disables enforcement (logged)");
unsafe { std::env::remove_var(VAR) };
}
}