Skip to main content

sui_spec/
gc.rs

1//! Typed border for the nix store garbage collector.
2//!
3//! cppnix's `nix-collect-garbage` walks the GC root set (profiles,
4//! `/nix/var/nix/gcroots`, `--keep` paths, indirect roots), computes
5//! the live set as the transitive references from those roots, and
6//! deletes everything in `/nix/store` outside the live set.  Various
7//! flags control the policy: `--delete-older-than`, `--max-freed`,
8//! `--dry-run`.
9//!
10//! Today sui-store has an implementation; this module names the
11//! algorithm as a typed Lisp spec so the impl rides on an explicit
12//! contract.  Future GC variants (concurrent / lazy / mark-and-sweep
13//! optimised) hang off this typed border.
14//!
15//! ## Authoring surface
16//!
17//! ```lisp
18//! (defgc-algorithm
19//!   :name "cppnix-stop-the-world"
20//!   :phases ((:kind LockStore)
21//!            (:kind CollectGcRoots :bind "roots")
22//!            (:kind ComputeLiveSet :from "roots" :bind "live")
23//!            (:kind ScanStore :bind "all-paths")
24//!            (:kind ComputeDeadSet :from "all-paths")
25//!            (:kind FilterByAgeAndSize)
26//!            (:kind DeleteDeadPaths)
27//!            (:kind UnlockStore)
28//!            (:kind EmitReport)))
29//! ```
30
31use serde::{Deserialize, Serialize};
32use tatara_lisp::DeriveTataraDomain;
33
34use crate::SpecError;
35
36// ── Typed border ───────────────────────────────────────────────────
37
38/// One garbage-collection algorithm.
39#[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/// One phase of a GC pipeline.
47#[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/// Closed set of GC phases.
57#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum GcPhaseKind {
59    /// Acquire the global store lock to prevent concurrent builds
60    /// from racing with the dead-set computation.
61    LockStore,
62    /// Collect every GC root: profile generation links, gcroots
63    /// directory, indirect roots, builder roots, `--keep` paths.
64    CollectGcRoots,
65    /// Compute the live set as the transitive closure of references
66    /// reachable from the GC roots.
67    ComputeLiveSet,
68    /// Enumerate every path in /nix/store.
69    ScanStore,
70    /// Subtract live set from all paths to derive the dead set.
71    ComputeDeadSet,
72    /// Apply policy filters: `--delete-older-than`, `--max-freed`,
73    /// `--keep-going`.  Some dead paths may survive a single GC if
74    /// the operator's policy caps the deletion rate.
75    FilterByAgeAndSize,
76    /// Delete dead paths from /nix/store.  Recursively `chmod -R u+w`
77    /// store entries that have read-only bits set, then unlink.
78    DeleteDeadPaths,
79    /// Release the store lock.
80    UnlockStore,
81    /// Emit a typed `GcReport` (paths deleted, bytes freed, runtime)
82    /// for operator surfacing.
83    EmitReport,
84    /// Optional: attest the GC run to the OutcomeChain so audit
85    /// trails carry the deletion event.  Skipped on hosts without
86    /// the attestation layer.
87    AttestRunToChain,
88}
89
90// ── Spec interpreter (M3.0 minimal) ────────────────────────────────
91
92/// Inputs to a GC run.
93pub struct GcArgs {
94    /// Drop paths older than this many days.  `None` = no age filter.
95    pub delete_older_than_days: Option<u32>,
96    /// Cap on bytes freed in one run.  `None` = unbounded.
97    pub max_freed_bytes: Option<u64>,
98    /// Compute the dead set but DON'T actually delete.
99    pub dry_run: bool,
100}
101
102/// Result of a GC run.
103#[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/// Path metadata the GC env returns per /nix/store entry.
115#[derive(Debug, Clone, Default)]
116pub struct StorePathInfo {
117    pub path: String,
118    /// Direct dependencies (other store paths this one references).
119    pub references: Vec<String>,
120    /// Size in bytes.
121    pub size: u64,
122    /// Last-accessed age in days (for the
123    /// `--delete-older-than-days` filter).
124    pub age_days: u32,
125}
126
127/// Abstract IO for the garbage collector.  Pattern parallel to
128/// FetcherEnvironment / SubstituterEnvironment.
129pub trait GcEnvironment {
130    /// Acquire the global store lock.  Blocks builds from running
131    /// concurrently with the dead-set computation.
132    fn lock_store(&self) -> Result<(), String>;
133
134    /// Release the global store lock.
135    fn unlock_store(&self) -> Result<(), String>;
136
137    /// Collect every GC root: profile generation links, gcroots
138    /// directory, indirect roots, builder roots, `--keep` paths.
139    /// Returns store-path references each root holds alive.
140    fn collect_gc_roots(&self) -> Result<Vec<String>, String>;
141
142    /// Enumerate every path in /nix/store with its metadata.
143    fn scan_store(&self) -> Result<Vec<StorePathInfo>, String>;
144
145    /// Delete a store path, returning its actual freed-byte count
146    /// (which may differ from `StorePathInfo::size` after hard-link
147    /// deduplication).
148    fn delete_path(&self, path: &str) -> Result<u64, String>;
149
150    /// Attest the GC run to the OutcomeChain (optional — used by
151    /// the `*-attested` variants).  Default impl is a no-op,
152    /// returning `None`.
153    fn attest_run(&self, _deleted: &[String], _freed: u64) -> Result<Option<String>, String> {
154        Ok(None)
155    }
156}
157
158/// Apply a GC algorithm.  M3.0 walks the authored phase pipeline,
159/// dispatching to the env trait for each side-effecting step.
160///
161/// # Errors
162///
163/// Phase-specific `SpecError::Interp` variants per env-trait
164/// failure.  `dependency-cycle` if the references graph from
165/// `scan_store` contains a cycle while computing the live set
166/// (the cppnix store shouldn't have cycles, but the substrate
167/// surfaces them defensively).
168pub 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                // BFS from each root over the references graph.
194                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                    // Paths in roots that AREN'T in the scan get
211                    // marked live but contribute no refs — that's
212                    // fine; they're untouched by the GC anyway.
213                }
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                // Apply --delete-older-than-days: keep only paths
232                // older than the threshold (in the dead set).
233                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                // Apply --max-freed-bytes: cap the to_delete set so
243                // its cumulative size stays under the cap.
244                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                // Report struct is already accumulating; nothing
279                // additional to do here.
280            }
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
295// ── Canonical spec ─────────────────────────────────────────────────
296
297pub const CANONICAL_GC_LISP: &str = include_str!("../specs/gc.lisp");
298
299/// Compile every authored GC algorithm.
300///
301/// # Errors
302///
303/// Returns an error if the Lisp source fails to parse.
304pub fn load_canonical() -> Result<Vec<GcAlgorithm>, SpecError> {
305    crate::loader::load_all::<GcAlgorithm>(CANONICAL_GC_LISP)
306}
307
308/// Return the GC algorithm whose `name` matches.
309///
310/// # Errors
311///
312/// Returns an error if the spec fails to parse or `name` is missing.
313pub 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            // Must lock, must unlock, lock must come before delete,
343            // unlock must come after delete.
344            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    // ── M3.0 gc interpreter tests ──────────────────────────────
382
383    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        // The LOCK marker appears before any DELETE, the UNLOCK
464        // appears after.  Mock log records phase order.
465        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        // The transitive chain root→dep→leaf is kept alive; only
490        // the orphan is deleted.
491        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        // delete_path was NEVER called.
509        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)   // 3 days
517            .with_path("/nix/store/old-dead", &[], 100, 30);    // 30 days
518        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        // Only the 30-day-old path crosses the threshold.
528        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),  // a + b would be 300 > cap; only a fits
543                dry_run: false,
544            },
545            &env,
546        ).unwrap();
547        // Cap at 250: only the first ≤250B path fits.
548        // Order depends on BTreeSet iteration order in our mock,
549        // but cumulative size must not exceed cap.
550        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        // The attested algorithm calls attest_run after deletion.
562        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}