1use serde::{Deserialize, Serialize};
32use tatara_lisp::DeriveTataraDomain;
33
34use crate::SpecError;
35
36#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
40#[tatara(keyword = "defgc-algorithm")]
41pub struct GcAlgorithm {
42 pub name: String,
43 pub phases: Vec<GcPhase>,
44}
45
46#[derive(Serialize, Deserialize, Debug, Clone)]
48pub struct GcPhase {
49 pub kind: GcPhaseKind,
50 #[serde(default)]
51 pub bind: Option<String>,
52 #[serde(default)]
53 pub from: Option<String>,
54}
55
56#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum GcPhaseKind {
59 LockStore,
62 CollectGcRoots,
65 ComputeLiveSet,
68 ScanStore,
70 ComputeDeadSet,
72 FilterByAgeAndSize,
76 DeleteDeadPaths,
79 UnlockStore,
81 EmitReport,
84 AttestRunToChain,
88}
89
90pub struct GcArgs {
94 pub delete_older_than_days: Option<u32>,
96 pub max_freed_bytes: Option<u64>,
98 pub dry_run: bool,
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct GcReport {
105 pub roots_count: usize,
106 pub live_paths: usize,
107 pub dead_paths: usize,
108 pub deleted_paths: Vec<String>,
109 pub bytes_freed: u64,
110 pub attestation_id: Option<String>,
111 pub dry_run: bool,
112}
113
114#[derive(Debug, Clone, Default)]
116pub struct StorePathInfo {
117 pub path: String,
118 pub references: Vec<String>,
120 pub size: u64,
122 pub age_days: u32,
125}
126
127pub trait GcEnvironment {
130 fn lock_store(&self) -> Result<(), String>;
133
134 fn unlock_store(&self) -> Result<(), String>;
136
137 fn collect_gc_roots(&self) -> Result<Vec<String>, String>;
141
142 fn scan_store(&self) -> Result<Vec<StorePathInfo>, String>;
144
145 fn delete_path(&self, path: &str) -> Result<u64, String>;
149
150 fn attest_run(&self, _deleted: &[String], _freed: u64) -> Result<Option<String>, String> {
154 Ok(None)
155 }
156}
157
158pub fn apply<E: GcEnvironment>(
169 algo: &GcAlgorithm,
170 args: &GcArgs,
171 env: &E,
172) -> Result<GcReport, SpecError> {
173 let mut report = GcReport { dry_run: args.dry_run, ..Default::default() };
174 let mut roots: Vec<String> = Vec::new();
175 let mut live: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
176 let mut all: Vec<StorePathInfo> = Vec::new();
177 let mut to_delete: Vec<String> = Vec::new();
178
179 for phase in &algo.phases {
180 match phase.kind {
181 GcPhaseKind::LockStore => env.lock_store().map_err(|e| SpecError::Interp {
182 phase: "lock-store".into(),
183 message: e,
184 })?,
185 GcPhaseKind::CollectGcRoots => {
186 roots = env.collect_gc_roots().map_err(|e| SpecError::Interp {
187 phase: "collect-gc-roots".into(),
188 message: e,
189 })?;
190 report.roots_count = roots.len();
191 }
192 GcPhaseKind::ComputeLiveSet => {
193 let info_by_path: std::collections::HashMap<String, &StorePathInfo> = all
195 .iter()
196 .map(|p| (p.path.clone(), p))
197 .collect();
198 let mut frontier: Vec<String> = roots.clone();
199 while let Some(p) = frontier.pop() {
200 if !live.insert(p.clone()) {
201 continue;
202 }
203 if let Some(info) = info_by_path.get(&p) {
204 for r in &info.references {
205 if !live.contains(r) {
206 frontier.push(r.clone());
207 }
208 }
209 }
210 }
214 report.live_paths = live.len();
215 }
216 GcPhaseKind::ScanStore => {
217 all = env.scan_store().map_err(|e| SpecError::Interp {
218 phase: "scan-store".into(),
219 message: e,
220 })?;
221 }
222 GcPhaseKind::ComputeDeadSet => {
223 let dead: Vec<&StorePathInfo> = all
224 .iter()
225 .filter(|p| !live.contains(&p.path))
226 .collect();
227 report.dead_paths = dead.len();
228 to_delete = dead.iter().map(|p| p.path.clone()).collect();
229 }
230 GcPhaseKind::FilterByAgeAndSize => {
231 if let Some(min_age) = args.delete_older_than_days {
234 let by_path: std::collections::HashMap<&str, u32> = all
235 .iter()
236 .map(|p| (p.path.as_str(), p.age_days))
237 .collect();
238 to_delete.retain(|p| {
239 by_path.get(p.as_str()).copied().unwrap_or(0) >= min_age
240 });
241 }
242 if let Some(cap) = args.max_freed_bytes {
245 let by_size: std::collections::HashMap<&str, u64> = all
246 .iter()
247 .map(|p| (p.path.as_str(), p.size))
248 .collect();
249 let mut acc: u64 = 0;
250 to_delete.retain(|p| {
251 let sz = by_size.get(p.as_str()).copied().unwrap_or(0);
252 if acc.saturating_add(sz) <= cap {
253 acc = acc.saturating_add(sz);
254 true
255 } else {
256 false
257 }
258 });
259 }
260 }
261 GcPhaseKind::DeleteDeadPaths => {
262 if !args.dry_run {
263 for path in &to_delete {
264 let freed = env.delete_path(path).map_err(|e| SpecError::Interp {
265 phase: "delete-dead-paths".into(),
266 message: format!("{path}: {e}"),
267 })?;
268 report.bytes_freed = report.bytes_freed.saturating_add(freed);
269 report.deleted_paths.push(path.clone());
270 }
271 }
272 }
273 GcPhaseKind::UnlockStore => env.unlock_store().map_err(|e| SpecError::Interp {
274 phase: "unlock-store".into(),
275 message: e,
276 })?,
277 GcPhaseKind::EmitReport => {
278 }
281 GcPhaseKind::AttestRunToChain => {
282 let id = env
283 .attest_run(&report.deleted_paths, report.bytes_freed)
284 .map_err(|e| SpecError::Interp {
285 phase: "attest-run".into(),
286 message: e,
287 })?;
288 report.attestation_id = id;
289 }
290 }
291 }
292 Ok(report)
293}
294
295pub const CANONICAL_GC_LISP: &str = include_str!("../specs/gc.lisp");
298
299pub fn load_canonical() -> Result<Vec<GcAlgorithm>, SpecError> {
305 crate::loader::load_all::<GcAlgorithm>(CANONICAL_GC_LISP)
306}
307
308pub fn load_named(name: &str) -> Result<GcAlgorithm, SpecError> {
314 load_canonical()?
315 .into_iter()
316 .find(|a| a.name == name)
317 .ok_or_else(|| SpecError::Load(format!("no (defgc-algorithm) with :name {name:?}")))
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn canonical_gc_algorithms_parse() {
326 let algos = load_canonical().expect("canonical gc must compile");
327 assert!(!algos.is_empty());
328 }
329
330 #[test]
331 fn cppnix_stop_the_world_algorithm_exists() {
332 let _algo = load_named("cppnix-stop-the-world")
333 .expect("cppnix stop-the-world GC algorithm must exist");
334 }
335
336 #[test]
337 fn every_gc_algorithm_brackets_lock_around_critical_section() {
338 let algos = load_canonical().unwrap();
339 for algo in &algos {
340 let kinds: Vec<GcPhaseKind> =
341 algo.phases.iter().map(|p| p.kind).collect();
342 let lock_pos = kinds.iter().position(|k| *k == GcPhaseKind::LockStore);
345 let delete_pos = kinds.iter().position(|k| *k == GcPhaseKind::DeleteDeadPaths);
346 let unlock_pos = kinds.iter().position(|k| *k == GcPhaseKind::UnlockStore);
347 assert!(lock_pos.is_some(), "{}: missing LockStore", algo.name);
348 assert!(unlock_pos.is_some(), "{}: missing UnlockStore", algo.name);
349 assert!(delete_pos.is_some(), "{}: missing DeleteDeadPaths", algo.name);
350 assert!(
351 lock_pos.unwrap() < delete_pos.unwrap(),
352 "{}: LockStore must precede DeleteDeadPaths",
353 algo.name,
354 );
355 assert!(
356 delete_pos.unwrap() < unlock_pos.unwrap(),
357 "{}: DeleteDeadPaths must precede UnlockStore",
358 algo.name,
359 );
360 }
361 }
362
363 #[test]
364 fn every_gc_algorithm_computes_live_before_dead() {
365 let algos = load_canonical().unwrap();
366 for algo in &algos {
367 let kinds: Vec<GcPhaseKind> =
368 algo.phases.iter().map(|p| p.kind).collect();
369 let live = kinds.iter().position(|k| *k == GcPhaseKind::ComputeLiveSet);
370 let dead = kinds.iter().position(|k| *k == GcPhaseKind::ComputeDeadSet);
371 if let (Some(l), Some(d)) = (live, dead) {
372 assert!(
373 l < d,
374 "{}: ComputeLiveSet must precede ComputeDeadSet",
375 algo.name,
376 );
377 }
378 }
379 }
380
381 use std::cell::RefCell;
384
385 struct MockEnv {
386 roots: Vec<String>,
387 scan: Vec<StorePathInfo>,
388 log: RefCell<Vec<String>>,
389 deleted: RefCell<Vec<String>>,
390 }
391
392 impl MockEnv {
393 fn new() -> Self {
394 Self {
395 roots: Vec::new(),
396 scan: Vec::new(),
397 log: RefCell::new(Vec::new()),
398 deleted: RefCell::new(Vec::new()),
399 }
400 }
401 fn with_root(mut self, root: &str) -> Self {
402 self.roots.push(root.into());
403 self
404 }
405 fn with_path(mut self, path: &str, refs: &[&str], size: u64, age: u32) -> Self {
406 self.scan.push(StorePathInfo {
407 path: path.into(),
408 references: refs.iter().map(|s| (*s).into()).collect(),
409 size,
410 age_days: age,
411 });
412 self
413 }
414 }
415
416 impl GcEnvironment for MockEnv {
417 fn lock_store(&self) -> Result<(), String> {
418 self.log.borrow_mut().push("LOCK".into());
419 Ok(())
420 }
421 fn unlock_store(&self) -> Result<(), String> {
422 self.log.borrow_mut().push("UNLOCK".into());
423 Ok(())
424 }
425 fn collect_gc_roots(&self) -> Result<Vec<String>, String> {
426 self.log.borrow_mut().push("ROOTS".into());
427 Ok(self.roots.clone())
428 }
429 fn scan_store(&self) -> Result<Vec<StorePathInfo>, String> {
430 self.log.borrow_mut().push("SCAN".into());
431 Ok(self.scan.clone())
432 }
433 fn delete_path(&self, path: &str) -> Result<u64, String> {
434 self.log.borrow_mut().push(format!("DELETE {path}"));
435 self.deleted.borrow_mut().push(path.into());
436 let size = self
437 .scan
438 .iter()
439 .find(|p| p.path == path)
440 .map(|p| p.size)
441 .unwrap_or(0);
442 Ok(size)
443 }
444 }
445
446 #[test]
447 fn gc_brackets_lock_around_delete() {
448 let algo = load_named("cppnix-stop-the-world").unwrap();
449 let env = MockEnv::new()
450 .with_root("/nix/store/aaa-keep")
451 .with_path("/nix/store/aaa-keep", &[], 100, 0)
452 .with_path("/nix/store/zzz-dead", &[], 200, 30);
453 let report = apply(
454 &algo,
455 &GcArgs {
456 delete_older_than_days: None,
457 max_freed_bytes: None,
458 dry_run: false,
459 },
460 &env,
461 )
462 .unwrap();
463 let log = env.log.borrow();
466 let lock_pos = log.iter().position(|x| x == "LOCK").unwrap();
467 let delete_pos = log.iter().position(|x| x.starts_with("DELETE")).unwrap();
468 let unlock_pos = log.iter().position(|x| x == "UNLOCK").unwrap();
469 assert!(lock_pos < delete_pos);
470 assert!(delete_pos < unlock_pos);
471 assert_eq!(report.deleted_paths, vec!["/nix/store/zzz-dead".to_string()]);
472 assert_eq!(report.bytes_freed, 200);
473 }
474
475 #[test]
476 fn live_set_includes_transitive_refs() {
477 let algo = load_named("cppnix-stop-the-world").unwrap();
478 let env = MockEnv::new()
479 .with_root("/nix/store/aaa-root")
480 .with_path("/nix/store/aaa-root", &["/nix/store/bbb-dep"], 100, 0)
481 .with_path("/nix/store/bbb-dep", &["/nix/store/ccc-leaf"], 50, 0)
482 .with_path("/nix/store/ccc-leaf", &[], 25, 0)
483 .with_path("/nix/store/zzz-orphan", &[], 200, 0);
484 let report = apply(
485 &algo,
486 &GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: false },
487 &env,
488 ).unwrap();
489 assert_eq!(report.deleted_paths, vec!["/nix/store/zzz-orphan".to_string()]);
492 assert_eq!(report.live_paths, 3);
493 }
494
495 #[test]
496 fn dry_run_does_not_delete() {
497 let algo = load_named("cppnix-stop-the-world").unwrap();
498 let env = MockEnv::new()
499 .with_path("/nix/store/aaa-dead", &[], 100, 0);
500 let report = apply(
501 &algo,
502 &GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: true },
503 &env,
504 ).unwrap();
505 assert_eq!(report.dead_paths, 1);
506 assert!(report.deleted_paths.is_empty());
507 assert_eq!(report.bytes_freed, 0);
508 assert!(env.deleted.borrow().is_empty());
510 }
511
512 #[test]
513 fn delete_older_than_filters() {
514 let algo = load_named("cppnix-stop-the-world").unwrap();
515 let env = MockEnv::new()
516 .with_path("/nix/store/young-dead", &[], 100, 3) .with_path("/nix/store/old-dead", &[], 100, 30); let report = apply(
519 &algo,
520 &GcArgs {
521 delete_older_than_days: Some(14),
522 max_freed_bytes: None,
523 dry_run: false,
524 },
525 &env,
526 ).unwrap();
527 assert_eq!(report.deleted_paths, vec!["/nix/store/old-dead".to_string()]);
529 }
530
531 #[test]
532 fn max_freed_bytes_caps_deletion() {
533 let algo = load_named("cppnix-stop-the-world").unwrap();
534 let env = MockEnv::new()
535 .with_path("/nix/store/a-dead", &[], 100, 30)
536 .with_path("/nix/store/b-dead", &[], 200, 30)
537 .with_path("/nix/store/c-dead", &[], 300, 30);
538 let report = apply(
539 &algo,
540 &GcArgs {
541 delete_older_than_days: None,
542 max_freed_bytes: Some(250), dry_run: false,
544 },
545 &env,
546 ).unwrap();
547 let total: u64 = env
551 .scan
552 .iter()
553 .filter(|p| report.deleted_paths.contains(&p.path))
554 .map(|p| p.size)
555 .sum();
556 assert!(total <= 250, "deleted total {total} must not exceed cap 250");
557 }
558
559 #[test]
560 fn attested_variant_records_attestation_id() {
561 let algo = load_named("cppnix-stop-the-world-attested").unwrap();
563 struct AttestEnv;
564 impl GcEnvironment for AttestEnv {
565 fn lock_store(&self) -> Result<(), String> { Ok(()) }
566 fn unlock_store(&self) -> Result<(), String> { Ok(()) }
567 fn collect_gc_roots(&self) -> Result<Vec<String>, String> { Ok(vec![]) }
568 fn scan_store(&self) -> Result<Vec<StorePathInfo>, String> { Ok(vec![]) }
569 fn delete_path(&self, _: &str) -> Result<u64, String> { Ok(0) }
570 fn attest_run(&self, _: &[String], _: u64) -> Result<Option<String>, String> {
571 Ok(Some("attestation-abc-123".into()))
572 }
573 }
574 let report = apply(
575 &algo,
576 &GcArgs { delete_older_than_days: None, max_freed_bytes: None, dry_run: false },
577 &AttestEnv,
578 ).unwrap();
579 assert_eq!(report.attestation_id.as_deref(), Some("attestation-abc-123"));
580 }
581}