use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defgc-algorithm")]
pub struct GcAlgorithm {
pub name: String,
pub phases: Vec<GcPhase>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GcPhase {
pub kind: GcPhaseKind,
#[serde(default)]
pub bind: Option<String>,
#[serde(default)]
pub from: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GcPhaseKind {
LockStore,
CollectGcRoots,
ComputeLiveSet,
ScanStore,
ComputeDeadSet,
FilterByAgeAndSize,
DeleteDeadPaths,
UnlockStore,
EmitReport,
AttestRunToChain,
}
pub struct GcArgs {
pub delete_older_than_days: Option<u32>,
pub max_freed_bytes: Option<u64>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GcReport {
pub roots_count: usize,
pub live_paths: usize,
pub dead_paths: usize,
pub deleted_paths: Vec<String>,
pub bytes_freed: u64,
pub attestation_id: Option<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Default)]
pub struct StorePathInfo {
pub path: String,
pub references: Vec<String>,
pub size: u64,
pub age_days: u32,
}
pub trait GcEnvironment {
fn lock_store(&self) -> Result<(), String>;
fn unlock_store(&self) -> Result<(), String>;
fn collect_gc_roots(&self) -> Result<Vec<String>, String>;
fn scan_store(&self) -> Result<Vec<StorePathInfo>, String>;
fn delete_path(&self, path: &str) -> Result<u64, String>;
fn attest_run(&self, _deleted: &[String], _freed: u64) -> Result<Option<String>, String> {
Ok(None)
}
}
pub fn apply<E: GcEnvironment>(
algo: &GcAlgorithm,
args: &GcArgs,
env: &E,
) -> Result<GcReport, SpecError> {
let mut report = GcReport { dry_run: args.dry_run, ..Default::default() };
let mut roots: Vec<String> = Vec::new();
let mut live: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut all: Vec<StorePathInfo> = Vec::new();
let mut to_delete: Vec<String> = Vec::new();
for phase in &algo.phases {
match phase.kind {
GcPhaseKind::LockStore => env.lock_store().map_err(|e| SpecError::Interp {
phase: "lock-store".into(),
message: e,
})?,
GcPhaseKind::CollectGcRoots => {
roots = env.collect_gc_roots().map_err(|e| SpecError::Interp {
phase: "collect-gc-roots".into(),
message: e,
})?;
report.roots_count = roots.len();
}
GcPhaseKind::ComputeLiveSet => {
let info_by_path: std::collections::HashMap<String, &StorePathInfo> = all
.iter()
.map(|p| (p.path.clone(), p))
.collect();
let mut frontier: Vec<String> = roots.clone();
while let Some(p) = frontier.pop() {
if !live.insert(p.clone()) {
continue;
}
if let Some(info) = info_by_path.get(&p) {
for r in &info.references {
if !live.contains(r) {
frontier.push(r.clone());
}
}
}
}
report.live_paths = live.len();
}
GcPhaseKind::ScanStore => {
all = env.scan_store().map_err(|e| SpecError::Interp {
phase: "scan-store".into(),
message: e,
})?;
}
GcPhaseKind::ComputeDeadSet => {
let dead: Vec<&StorePathInfo> = all
.iter()
.filter(|p| !live.contains(&p.path))
.collect();
report.dead_paths = dead.len();
to_delete = dead.iter().map(|p| p.path.clone()).collect();
}
GcPhaseKind::FilterByAgeAndSize => {
if let Some(min_age) = args.delete_older_than_days {
let by_path: std::collections::HashMap<&str, u32> = all
.iter()
.map(|p| (p.path.as_str(), p.age_days))
.collect();
to_delete.retain(|p| {
by_path.get(p.as_str()).copied().unwrap_or(0) >= min_age
});
}
if let Some(cap) = args.max_freed_bytes {
let by_size: std::collections::HashMap<&str, u64> = all
.iter()
.map(|p| (p.path.as_str(), p.size))
.collect();
let mut acc: u64 = 0;
to_delete.retain(|p| {
let sz = by_size.get(p.as_str()).copied().unwrap_or(0);
if acc.saturating_add(sz) <= cap {
acc = acc.saturating_add(sz);
true
} else {
false
}
});
}
}
GcPhaseKind::DeleteDeadPaths => {
if !args.dry_run {
for path in &to_delete {
let freed = env.delete_path(path).map_err(|e| SpecError::Interp {
phase: "delete-dead-paths".into(),
message: format!("{path}: {e}"),
})?;
report.bytes_freed = report.bytes_freed.saturating_add(freed);
report.deleted_paths.push(path.clone());
}
}
}
GcPhaseKind::UnlockStore => env.unlock_store().map_err(|e| SpecError::Interp {
phase: "unlock-store".into(),
message: e,
})?,
GcPhaseKind::EmitReport => {
}
GcPhaseKind::AttestRunToChain => {
let id = env
.attest_run(&report.deleted_paths, report.bytes_freed)
.map_err(|e| SpecError::Interp {
phase: "attest-run".into(),
message: e,
})?;
report.attestation_id = id;
}
}
}
Ok(report)
}
pub const CANONICAL_GC_LISP: &str = include_str!("../specs/gc.lisp");
pub fn load_canonical() -> Result<Vec<GcAlgorithm>, SpecError> {
crate::loader::load_all::<GcAlgorithm>(CANONICAL_GC_LISP)
}
pub fn load_named(name: &str) -> Result<GcAlgorithm, SpecError> {
load_canonical()?
.into_iter()
.find(|a| a.name == name)
.ok_or_else(|| SpecError::Load(format!("no (defgc-algorithm) with :name {name:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_gc_algorithms_parse() {
let algos = load_canonical().expect("canonical gc must compile");
assert!(!algos.is_empty());
}
#[test]
fn cppnix_stop_the_world_algorithm_exists() {
let _algo = load_named("cppnix-stop-the-world")
.expect("cppnix stop-the-world GC algorithm must exist");
}
#[test]
fn every_gc_algorithm_brackets_lock_around_critical_section() {
let algos = load_canonical().unwrap();
for algo in &algos {
let kinds: Vec<GcPhaseKind> =
algo.phases.iter().map(|p| p.kind).collect();
let lock_pos = kinds.iter().position(|k| *k == GcPhaseKind::LockStore);
let delete_pos = kinds.iter().position(|k| *k == GcPhaseKind::DeleteDeadPaths);
let unlock_pos = kinds.iter().position(|k| *k == GcPhaseKind::UnlockStore);
assert!(lock_pos.is_some(), "{}: missing LockStore", algo.name);
assert!(unlock_pos.is_some(), "{}: missing UnlockStore", algo.name);
assert!(delete_pos.is_some(), "{}: missing DeleteDeadPaths", algo.name);
assert!(
lock_pos.unwrap() < delete_pos.unwrap(),
"{}: LockStore must precede DeleteDeadPaths",
algo.name,
);
assert!(
delete_pos.unwrap() < unlock_pos.unwrap(),
"{}: DeleteDeadPaths must precede UnlockStore",
algo.name,
);
}
}
#[test]
fn every_gc_algorithm_computes_live_before_dead() {
let algos = load_canonical().unwrap();
for algo in &algos {
let kinds: Vec<GcPhaseKind> =
algo.phases.iter().map(|p| p.kind).collect();
let live = kinds.iter().position(|k| *k == GcPhaseKind::ComputeLiveSet);
let dead = kinds.iter().position(|k| *k == GcPhaseKind::ComputeDeadSet);
if let (Some(l), Some(d)) = (live, dead) {
assert!(
l < d,
"{}: ComputeLiveSet must precede ComputeDeadSet",
algo.name,
);
}
}
}
use std::cell::RefCell;
struct MockEnv {
roots: Vec<String>,
scan: Vec<StorePathInfo>,
log: RefCell<Vec<String>>,
deleted: RefCell<Vec<String>>,
}
impl MockEnv {
fn new() -> Self {
Self {
roots: Vec::new(),
scan: Vec::new(),
log: RefCell::new(Vec::new()),
deleted: RefCell::new(Vec::new()),
}
}
fn with_root(mut self, root: &str) -> Self {
self.roots.push(root.into());
self
}
fn with_path(mut self, path: &str, refs: &[&str], size: u64, age: u32) -> Self {
self.scan.push(StorePathInfo {
path: path.into(),
references: refs.iter().map(|s| (*s).into()).collect(),
size,
age_days: age,
});
self
}
}
impl GcEnvironment for MockEnv {
fn lock_store(&self) -> Result<(), String> {
self.log.borrow_mut().push("LOCK".into());
Ok(())
}
fn unlock_store(&self) -> Result<(), String> {
self.log.borrow_mut().push("UNLOCK".into());
Ok(())
}
fn collect_gc_roots(&self) -> Result<Vec<String>, String> {
self.log.borrow_mut().push("ROOTS".into());
Ok(self.roots.clone())
}
fn scan_store(&self) -> Result<Vec<StorePathInfo>, String> {
self.log.borrow_mut().push("SCAN".into());
Ok(self.scan.clone())
}
fn delete_path(&self, path: &str) -> Result<u64, String> {
self.log.borrow_mut().push(format!("DELETE {path}"));
self.deleted.borrow_mut().push(path.into());
let size = self
.scan
.iter()
.find(|p| p.path == path)
.map(|p| p.size)
.unwrap_or(0);
Ok(size)
}
}
#[test]
fn gc_brackets_lock_around_delete() {
let algo = load_named("cppnix-stop-the-world").unwrap();
let env = MockEnv::new()
.with_root("/nix/store/aaa-keep")
.with_path("/nix/store/aaa-keep", &[], 100, 0)
.with_path("/nix/store/zzz-dead", &[], 200, 30);
let report = apply(
&algo,
&GcArgs {
delete_older_than_days: None,
max_freed_bytes: None,
dry_run: false,
},
&env,
)
.unwrap();
let log = env.log.borrow();
let lock_pos = log.iter().position(|x| x == "LOCK").unwrap();
let delete_pos = log.iter().position(|x| x.starts_with("DELETE")).unwrap();
let unlock_pos = log.iter().position(|x| x == "UNLOCK").unwrap();
assert!(lock_pos < delete_pos);
assert!(delete_pos < unlock_pos);
assert_eq!(report.deleted_paths, vec!["/nix/store/zzz-dead".to_string()]);
assert_eq!(report.bytes_freed, 200);
}
#[test]
fn live_set_includes_transitive_refs() {
let algo = load_named("cppnix-stop-the-world").unwrap();
let env = MockEnv::new()
.with_root("/nix/store/aaa-root")
.with_path("/nix/store/aaa-root", &["/nix/store/bbb-dep"], 100, 0)
.with_path("/nix/store/bbb-dep", &["/nix/store/ccc-leaf"], 50, 0)
.with_path("/nix/store/ccc-leaf", &[], 25, 0)
.with_path("/nix/store/zzz-orphan", &[], 200, 0);
let report = apply(
&algo,
&GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: false },
&env,
).unwrap();
assert_eq!(report.deleted_paths, vec!["/nix/store/zzz-orphan".to_string()]);
assert_eq!(report.live_paths, 3);
}
#[test]
fn dry_run_does_not_delete() {
let algo = load_named("cppnix-stop-the-world").unwrap();
let env = MockEnv::new()
.with_path("/nix/store/aaa-dead", &[], 100, 0);
let report = apply(
&algo,
&GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: true },
&env,
).unwrap();
assert_eq!(report.dead_paths, 1);
assert!(report.deleted_paths.is_empty());
assert_eq!(report.bytes_freed, 0);
assert!(env.deleted.borrow().is_empty());
}
#[test]
fn delete_older_than_filters() {
let algo = load_named("cppnix-stop-the-world").unwrap();
let env = MockEnv::new()
.with_path("/nix/store/young-dead", &[], 100, 3) .with_path("/nix/store/old-dead", &[], 100, 30); let report = apply(
&algo,
&GcArgs {
delete_older_than_days: Some(14),
max_freed_bytes: None,
dry_run: false,
},
&env,
).unwrap();
assert_eq!(report.deleted_paths, vec!["/nix/store/old-dead".to_string()]);
}
#[test]
fn max_freed_bytes_caps_deletion() {
let algo = load_named("cppnix-stop-the-world").unwrap();
let env = MockEnv::new()
.with_path("/nix/store/a-dead", &[], 100, 30)
.with_path("/nix/store/b-dead", &[], 200, 30)
.with_path("/nix/store/c-dead", &[], 300, 30);
let report = apply(
&algo,
&GcArgs {
delete_older_than_days: None,
max_freed_bytes: Some(250), dry_run: false,
},
&env,
).unwrap();
let total: u64 = env
.scan
.iter()
.filter(|p| report.deleted_paths.contains(&p.path))
.map(|p| p.size)
.sum();
assert!(total <= 250, "deleted total {total} must not exceed cap 250");
}
#[test]
fn attested_variant_records_attestation_id() {
let algo = load_named("cppnix-stop-the-world-attested").unwrap();
struct AttestEnv;
impl GcEnvironment for AttestEnv {
fn lock_store(&self) -> Result<(), String> { Ok(()) }
fn unlock_store(&self) -> Result<(), String> { Ok(()) }
fn collect_gc_roots(&self) -> Result<Vec<String>, String> { Ok(vec![]) }
fn scan_store(&self) -> Result<Vec<StorePathInfo>, String> { Ok(vec![]) }
fn delete_path(&self, _: &str) -> Result<u64, String> { Ok(0) }
fn attest_run(&self, _: &[String], _: u64) -> Result<Option<String>, String> {
Ok(Some("attestation-abc-123".into()))
}
}
let report = apply(
&algo,
&GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: false },
&AttestEnv,
).unwrap();
assert_eq!(report.attestation_id.as_deref(), Some("attestation-abc-123"));
}
}