use std::path::Path;
use std::time::{Duration, SystemTime};
use serde::Serialize;
use thiserror::Error;
use crate::store::{
CACHE_DIR, INDEX_FILE, StoreError, VIEWS_DIR, WORKSPACES_DIR, cache_root, global_blobs_dir, read_index,
};
pub use crate::store_gc_live::LiveBlobs;
use crate::store_gc_live::{SweepLock, collect_workspace_hashes_healing_stale_views};
const BLOB_SUFFIXES: [&str; 4] = [".fm.msgpack", ".doc.msgpack", ".chunk.msgpack", ".rref.msgpack"];
const LEGACY_BLOB_SUFFIXES: [&str; 2] = [".l1.msgpack", ".l2.msgpack"];
const BLOB_GC_GRACE: Duration = Duration::from_secs(6 * 60 * 60);
const TMP_SUFFIX: &str = ".tmp";
pub const BLOB_GC_GRACE_ENV: &str = "BASEMIND_BLOB_GC_GRACE_SECS";
fn blob_gc_grace() -> Duration {
grace_from(std::env::var(BLOB_GC_GRACE_ENV).ok().as_deref())
}
fn grace_from(raw: Option<&str>) -> Duration {
raw.and_then(|raw| raw.trim().parse::<u64>().ok())
.map_or(BLOB_GC_GRACE, Duration::from_secs)
}
pub use crate::store_gc_workspace::{ReapReport, reap_orphaned_workspaces};
pub use crate::store_gc_budget::{
GcState, GcStatus, cache_budget_bytes, enforce_cache_budget, persist_gc_error, persist_gc_state, read_gc_state,
};
pub(crate) use crate::store_cache_admin::dir_size;
pub use crate::store_cache_admin::{CacheComponent, CacheStats, cache_stats, clear_component, clear_single_view};
#[derive(Debug, Error)]
pub enum GcError {
#[error(transparent)]
Store(#[from] StoreError),
#[error("io error on {path}: {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("blob GC task failed to join: {0}")]
Join(String),
#[error("blob GC starved: a rescan held the store lock beyond {0:?}; skipping this cycle")]
Starved(Duration),
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct GcReport {
pub scanned: usize,
pub removed: usize,
pub bytes_freed: u64,
#[serde(default)]
pub workspaces_reaped: usize,
#[serde(default)]
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,
#[serde(default)]
pub protected_workspaces: Vec<crate::store_gc_protect::ProtectedWorkspace>,
}
pub fn collect_referenced_hashes(basemind_dir: &Path) -> Result<LiveBlobs, GcError> {
let mut referenced = LiveBlobs::default();
let views_dir = basemind_dir.join(VIEWS_DIR);
if !views_dir.exists() {
return Ok(referenced);
}
for entry in read_dir(&views_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: views_dir.clone(),
source,
})?;
let view_dir = entry.path();
if !view_dir.is_dir() {
continue;
}
if !view_dir.join(INDEX_FILE).exists() {
tracing::warn!(view = %view_dir.display(), "view has no index.msgpack; skipping");
continue;
}
let index = match read_index(&view_dir) {
Ok(Some(idx)) => idx,
Ok(None) => continue,
Err(e) => return Err(GcError::Store(e)),
};
for entry in index.files.values() {
referenced.code.insert(entry.hash_hex.clone());
}
for entry in index.doc_files.values() {
referenced.docs.insert(entry.hash_hex.clone());
}
}
Ok(referenced)
}
pub fn gc_blobs(referenced: &LiveBlobs) -> Result<GcReport, GcError> {
gc_blobs_in(&global_blobs_dir(), referenced, blob_gc_grace())
}
fn gc_blobs_in(blobs_dir: &Path, referenced: &LiveBlobs, grace: Duration) -> Result<GcReport, GcError> {
let now = SystemTime::now();
let mut report = GcReport::default();
if !blobs_dir.exists() {
return Ok(report);
}
for entry in read_dir(blobs_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: blobs_dir.to_path_buf(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if file_name.ends_with(TMP_SUFFIX) {
report.scanned += 1;
sweep_stray_tmp(&path, now, grace, &mut report)?;
continue;
}
let is_legacy = LEGACY_BLOB_SUFFIXES.iter().any(|suffix| file_name.ends_with(suffix));
let Some(_) = blob_stem(file_name) else {
report.scanned += 1;
if is_legacy {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
std::fs::remove_file(&path).map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
report.removed += 1;
report.bytes_freed += size;
}
continue;
};
report.scanned += 1;
if referenced.keeps(file_name) {
continue;
}
let meta = std::fs::metadata(&path).map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
let age = meta
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.unwrap_or(Duration::ZERO);
if age < grace {
continue;
}
let size = meta.len();
std::fs::remove_file(&path).map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
report.removed += 1;
report.bytes_freed += size;
}
Ok(report)
}
fn sweep_stray_tmp(path: &Path, now: SystemTime, grace: Duration, report: &mut GcReport) -> Result<(), GcError> {
let io = |source| GcError::Io {
path: path.to_path_buf(),
source,
};
let meta = std::fs::metadata(path).map_err(io)?;
let age = meta
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.unwrap_or(Duration::ZERO);
if age < grace.max(BLOB_GC_GRACE) {
return Ok(());
}
std::fs::remove_file(path).map_err(io)?;
report.removed += 1;
report.bytes_freed += meta.len();
Ok(())
}
pub fn collect_referenced_hashes_global() -> Result<LiveBlobs, GcError> {
collect_referenced_hashes_global_in(&cache_root().join(CACHE_DIR).join(WORKSPACES_DIR))
}
pub(crate) fn collect_referenced_hashes_global_in(workspaces_dir: &Path) -> Result<LiveBlobs, GcError> {
let mut referenced = LiveBlobs::default();
if !workspaces_dir.exists() {
return Ok(referenced);
}
for entry in read_dir(workspaces_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: workspaces_dir.to_path_buf(),
source,
})?;
let workspace_dir = entry.path();
if !workspace_dir.is_dir() {
continue;
}
referenced.extend(collect_workspace_hashes_healing_stale_views(&workspace_dir)?);
}
Ok(referenced)
}
pub fn gc_global_blobs() -> Result<GcReport, GcError> {
let cache = cache_root().join(CACHE_DIR);
let Some(_sweep) = SweepLock::try_acquire(&cache) else {
tracing::debug!("global blob sweep already running in another process; skipping");
return Ok(GcReport::default());
};
gc_global_blobs_in(&cache.join(WORKSPACES_DIR), &global_blobs_dir(), blob_gc_grace())
}
pub fn reclaim_displaced_blobs() -> Result<GcReport, GcError> {
let report = gc_global_blobs()?;
persist_gc_state(&report);
Ok(report)
}
pub(crate) fn gc_global_blobs_in(
workspaces_dir: &Path,
blobs_dir: &Path,
grace: Duration,
) -> Result<GcReport, GcError> {
let referenced = collect_referenced_hashes_global_in(workspaces_dir)?;
gc_blobs_in(blobs_dir, &referenced, grace)
}
pub fn reap_and_gc_global() -> Result<GcReport, GcError> {
let reaped = reap_orphaned_workspaces()?;
let mut report = gc_global_blobs()?;
report.workspaces_reaped = reaped.reaped;
report.workspace_bytes_freed = reaped.bytes_freed;
Ok(report)
}
pub fn reap_gc_and_enforce_budget() -> Result<GcReport, GcError> {
let mut report = reap_and_gc_global()?;
if let Some(budget) = cache_budget_bytes() {
let evicted = enforce_cache_budget(budget)?;
report.cache_budget_bytes = Some(budget);
report.cache_bytes_after = Some(evicted.total_bytes_after);
report.hot_workspaces_evicted = evicted.hot_evicted;
report.locked_workspaces_skipped = evicted.locked_skipped;
report.protected_workspaces = evicted.protected;
if evicted.evicted > 0 {
report.workspaces_evicted = evicted.evicted;
report.evicted_bytes_freed = evicted.bytes_freed;
report.removed += evicted.blobs_removed;
report.bytes_freed += evicted.blob_bytes_freed;
}
}
persist_gc_state(&report);
Ok(report)
}
pub fn run_gc(basemind_dir: &Path) -> Result<GcReport, GcError> {
let _ = basemind_dir;
let report = gc_global_blobs()?;
persist_gc_state(&report);
Ok(report)
}
pub fn gc_report_only() -> Result<GcReport, GcError> {
let blobs_dir = global_blobs_dir();
let mut report = GcReport::default();
if !blobs_dir.exists() {
return Ok(report);
}
for entry in read_dir(&blobs_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: blobs_dir.clone(),
source,
})?;
if entry.path().is_file() {
report.scanned += 1;
}
}
Ok(report)
}
pub(crate) fn blob_stem(file_name: &str) -> Option<&str> {
BLOB_SUFFIXES.iter().find_map(|suffix| file_name.strip_suffix(suffix))
}
pub(crate) fn read_dir(dir: &Path) -> Result<std::fs::ReadDir, GcError> {
std::fs::read_dir(dir).map_err(|source| GcError::Io {
path: dir.to_path_buf(),
source,
})
}
#[cfg(test)]
#[path = "store_gc_tier_tests.rs"]
mod tier_tests;
#[cfg(test)]
#[path = "store_gc_tests.rs"]
mod tests;