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, gc_global_blobs_in, 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);
const BUDGET_ORPHAN_GRACE: Duration = Duration::ZERO;
pub const GC_STATE_FILE: &str = "gc-state.json";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GcStatus {
#[default]
Completed,
OverBudget,
Starved,
Failed,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GcState {
pub at_epoch_secs: u64,
#[serde(default)]
pub last_attempt_epoch_secs: u64,
#[serde(default)]
pub status: GcStatus,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub consecutive_degraded_cycles: u32,
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,
#[serde(default)]
pub cache_budget_bytes: Option<u64>,
#[serde(default)]
pub cache_bytes_after: Option<u64>,
#[serde(default)]
pub hot_workspaces_evicted: usize,
#[serde(default)]
pub locked_workspaces_skipped: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct EvictReport {
pub total_bytes_before: u64,
pub total_bytes_after: u64,
pub evicted: usize,
pub bytes_freed: u64,
pub blobs_removed: usize,
pub blob_bytes_freed: u64,
pub hot_evicted: usize,
pub locked_skipped: usize,
}
struct EvictionCandidate {
activity: SystemTime,
hot: bool,
dir: PathBuf,
}
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 now = epoch_secs();
let over_budget = report
.cache_budget_bytes
.zip(report.cache_bytes_after)
.is_some_and(|(budget, after)| after > budget);
let previous_degraded_cycles = read_gc_state_at(path)
.map(|state| state.consecutive_degraded_cycles)
.unwrap_or(0);
let state = GcState {
at_epoch_secs: now,
last_attempt_epoch_secs: now,
status: if over_budget {
GcStatus::OverBudget
} else {
GcStatus::Completed
},
detail: over_budget.then(|| over_budget_detail(report)),
consecutive_degraded_cycles: if over_budget {
previous_degraded_cycles.saturating_add(1)
} else {
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,
cache_budget_bytes: report.cache_budget_bytes,
cache_bytes_after: report.cache_bytes_after,
hot_workspaces_evicted: report.hot_workspaces_evicted,
locked_workspaces_skipped: report.locked_workspaces_skipped,
};
write_gc_state(path, &state);
}
pub fn persist_gc_error(error: &GcError) {
persist_gc_error_at(&gc_state_path(), error);
}
fn persist_gc_error_at(path: &Path, error: &GcError) {
let mut state = read_gc_state_at(path).unwrap_or_default();
state.last_attempt_epoch_secs = epoch_secs();
state.status = match error {
GcError::Starved(_) => GcStatus::Starved,
_ => GcStatus::Failed,
};
state.detail = Some(error.to_string());
state.consecutive_degraded_cycles = state.consecutive_degraded_cycles.saturating_add(1);
write_gc_state(path, &state);
}
fn over_budget_detail(report: &GcReport) -> String {
let budget = report.cache_budget_bytes.unwrap_or(0);
let after = report.cache_bytes_after.unwrap_or(budget);
format!(
"cache remains {} bytes over budget after enforcement; {} workspace(s) were locked; retrying next cycle",
after.saturating_sub(budget),
report.locked_workspaces_skipped
)
}
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn write_gc_state(path: &Path, state: &GcState) {
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 total = cache_footprint(workspaces_dir, blobs_dir)?;
let mut report = EvictReport {
total_bytes_before: total,
total_bytes_after: total,
..EvictReport::default()
};
if report.total_bytes_before <= budget_bytes {
return Ok(report);
}
for candidate in eviction_candidates(workspaces_dir, hot_floor)? {
if report.total_bytes_after <= budget_bytes {
break;
}
let Some(size) = evict_workspace(&candidate.dir)? else {
report.locked_skipped += 1;
continue;
};
let reclaimed = gc_global_blobs_in(workspaces_dir, blobs_dir, BUDGET_ORPHAN_GRACE)?;
report.evicted += 1;
report.bytes_freed += size;
report.blobs_removed += reclaimed.removed;
report.blob_bytes_freed += reclaimed.bytes_freed;
report.hot_evicted += usize::from(candidate.hot);
report.total_bytes_after = cache_footprint(workspaces_dir, blobs_dir)?;
tracing::info!(
workspace = %candidate.dir.display(),
workspace_bytes = size,
blob_bytes = reclaimed.bytes_freed,
total_bytes = report.total_bytes_after,
"evicted workspace cache to enforce the size budget"
);
}
Ok(report)
}
fn eviction_candidates(workspaces_dir: &Path, hot_floor: Duration) -> Result<Vec<EvictionCandidate>, GcError> {
let mut candidates = Vec::new();
let now = SystemTime::now();
if !workspaces_dir.exists() {
return Ok(candidates);
}
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);
candidates.push(EvictionCandidate {
activity,
hot: idle < hot_floor,
dir,
});
}
candidates.sort_by_key(|candidate| (candidate.hot, candidate.activity));
Ok(candidates)
}
fn evict_workspace(dir: &Path) -> Result<Option<u64>, GcError> {
let Ok(lock) = acquire_lock(dir) else {
tracing::debug!(workspace = %dir.display(), "over-budget workspace is locked; skipping");
return Ok(None);
};
let size = dir_size(dir)?;
std::fs::remove_dir_all(dir).map_err(|source| GcError::Io {
path: dir.to_path_buf(),
source,
})?;
drop(lock);
Ok(Some(size))
}
fn cache_footprint(workspaces_dir: &Path, blobs_dir: &Path) -> Result<u64, GcError> {
Ok(dir_size_or_zero(workspaces_dir)? + dir_size_or_zero(blobs_dir)?)
}
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_eq!(report.total_bytes_after, report.total_bytes_before);
assert!(ws.exists(), "workspace untouched");
}
#[test]
fn over_budget_prefers_a_cold_workspace_and_stops_before_a_hot_one() {
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(60));
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::from_secs(24 * 3600)).expect("enforce");
assert_eq!(report.evicted, 1, "one eviction suffices to reach the budget");
assert_eq!(report.hot_evicted, 0, "the cold candidate is preferred");
assert!(report.total_bytes_after <= budget, "the measured footprint converged");
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_hot_workspace_is_evicted_when_it_is_the_only_way_to_converge() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&workspaces).expect("mk workspaces");
fs::create_dir_all(&blobs).expect("mk blobs");
let empty_footprint = cache_footprint(&workspaces, &blobs).expect("measure empty cache roots");
let hot = seed_workspace_aged(&workspaces, "key-hot", &"a".repeat(64), Duration::ZERO);
let report = enforce_cache_budget_in(&workspaces, &blobs, empty_footprint, Duration::from_secs(24 * 3600))
.expect("enforce with hot floor");
assert_eq!(report.evicted, 1, "the hot floor is a preference, not a budget veto");
assert_eq!(report.hot_evicted, 1);
assert!(report.total_bytes_after <= empty_footprint);
assert!(!hot.exists());
}
#[test]
fn reclaiming_an_evicted_workspaces_blobs_prevents_an_extra_eviction() {
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_stem = "c".repeat(64);
let cold = seed_workspace_aged(&workspaces, "key-cold", &cold_stem, Duration::from_secs(10 * 24 * 3600));
let warm = seed_workspace_aged(
&workspaces,
"key-warm",
&"d".repeat(64),
Duration::from_secs(2 * 24 * 3600),
);
let cold_blob = blobs.join(format!("{cold_stem}.fm.msgpack"));
fs::write(&cold_blob, vec![7_u8; 64 * 1024]).expect("write cold workspace blob");
let total = dir_size(&workspaces).unwrap() + dir_size(&blobs).unwrap();
let reclaimable = dir_size(&cold).unwrap() + fs::metadata(&cold_blob).unwrap().len();
let budget = total - reclaimable + 1;
let report = enforce_cache_budget_in(&workspaces, &blobs, budget, Duration::ZERO).expect("enforce");
assert_eq!(
report.evicted, 1,
"blob reclamation should make a second eviction unnecessary"
);
assert_eq!(report.blobs_removed, 1);
assert_eq!(report.blob_bytes_freed, 64 * 1024);
assert!(report.total_bytes_after <= budget);
assert!(!cold.exists());
assert!(
!cold_blob.exists(),
"the evicted workspace's orphan blob is reclaimed in the same pass"
);
assert!(
warm.exists(),
"the warmer workspace survives once the measured footprint converges"
);
}
#[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_eq!(report.locked_skipped, 1);
assert!(report.total_bytes_after > 1, "the report exposes non-convergence");
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,
..GcReport::default()
};
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);
assert_eq!(state.status, GcStatus::Completed);
assert_eq!(state.last_attempt_epoch_secs, state.at_epoch_secs);
assert_eq!(state.consecutive_degraded_cycles, 0);
fs::write(&path, b"{ not json").expect("corrupt");
assert!(read_gc_state_at(&path).is_none(), "corrupt state degrades to None");
}
#[test]
fn an_over_budget_sweep_is_persisted_as_degraded_until_a_healthy_sweep_completes() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(GC_STATE_FILE);
let constrained = GcReport {
cache_budget_bytes: Some(100),
cache_bytes_after: Some(180),
locked_workspaces_skipped: 2,
hot_workspaces_evicted: 1,
..GcReport::default()
};
persist_gc_state_at(&path, &constrained);
let degraded = read_gc_state_at(&path).expect("degraded state persisted");
assert_eq!(degraded.status, GcStatus::OverBudget);
assert_eq!(degraded.consecutive_degraded_cycles, 1);
assert_eq!(degraded.cache_budget_bytes, Some(100));
assert_eq!(degraded.cache_bytes_after, Some(180));
assert_eq!(degraded.locked_workspaces_skipped, 2);
assert_eq!(degraded.hot_workspaces_evicted, 1);
assert!(
degraded
.detail
.as_deref()
.is_some_and(|detail| detail.contains("80 bytes over budget")),
"the persisted diagnosis is actionable: {:?}",
degraded.detail
);
persist_gc_state_at(&path, &GcReport::default());
let recovered = read_gc_state_at(&path).expect("healthy state persisted");
assert_eq!(recovered.status, GcStatus::Completed);
assert_eq!(recovered.consecutive_degraded_cycles, 0);
assert_eq!(recovered.detail, None);
}
#[test]
fn failed_attempts_preserve_the_last_success_and_accumulate_until_recovery() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(GC_STATE_FILE);
let completed = GcReport {
scanned: 12,
removed: 4,
bytes_freed: 4096,
..GcReport::default()
};
persist_gc_state_at(&path, &completed);
let completed_at = read_gc_state_at(&path).expect("completed state").at_epoch_secs;
persist_gc_error_at(&path, &GcError::Starved(Duration::from_secs(300)));
persist_gc_error_at(&path, &GcError::Join("maintenance worker panicked".to_owned()));
let failed = read_gc_state_at(&path).expect("failed state persisted");
assert_eq!(failed.status, GcStatus::Failed);
assert_eq!(failed.consecutive_degraded_cycles, 2);
assert_eq!(
failed.at_epoch_secs, completed_at,
"last successful timestamp is preserved"
);
assert_eq!(failed.scanned, 12, "last successful counters are preserved");
assert_eq!(failed.removed, 4);
assert_eq!(failed.bytes_freed, 4096);
assert!(
failed
.detail
.as_deref()
.is_some_and(|detail| detail.contains("maintenance worker panicked")),
"the latest failure replaces the prior diagnosis: {:?}",
failed.detail
);
}
#[test]
fn legacy_gc_state_defaults_attempt_health_fields() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(GC_STATE_FILE);
fs::write(
&path,
br#"{
"at_epoch_secs": 42,
"scanned": 5,
"removed": 1,
"bytes_freed": 128,
"workspaces_reaped": 0,
"workspace_bytes_freed": 0
}"#,
)
.expect("write legacy state");
let state = read_gc_state_at(&path).expect("legacy state remains readable");
assert_eq!(state.at_epoch_secs, 42);
assert_eq!(state.status, GcStatus::Completed);
assert_eq!(state.last_attempt_epoch_secs, 0);
assert_eq!(state.consecutive_degraded_cycles, 0);
assert_eq!(state.detail, 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) };
}
}