Skip to main content

sui_cache/
gc.rs

1//! Garbage collection for the binary cache.
2//!
3//! Walks all narinfo entries, keeps those in the roots set,
4//! and deletes the rest.
5
6use std::collections::HashSet;
7
8use crate::storage::StorageBackend;
9use crate::CacheError;
10
11/// Result of a garbage collection run.
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct GcResult {
14    /// Number of store paths deleted.
15    pub paths_deleted: usize,
16    /// Total bytes freed (estimated from narinfo FileSize + narinfo text).
17    pub bytes_freed: u64,
18}
19
20impl std::fmt::Display for GcResult {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(
23            f,
24            "GC: {} paths deleted, {} bytes freed",
25            self.paths_deleted, self.bytes_freed
26        )
27    }
28}
29
30/// Run garbage collection on the cache.
31///
32/// Enumerates all narinfo hashes in the storage backend, keeps
33/// those present in `roots`, and deletes the rest.
34///
35/// `roots` contains the 32-character store path hashes that should be kept.
36pub async fn collect_garbage(
37    storage: &dyn StorageBackend,
38    roots: &[String],
39) -> Result<GcResult, CacheError> {
40    let all = storage.list_narinfos().await?;
41    let keep: HashSet<&str> = roots.iter().map(String::as_str).collect();
42
43    let mut result = GcResult::default();
44
45    for hash in &all {
46        if !keep.contains(hash.as_str()) {
47            // Try to read the narinfo to estimate freed bytes.
48            if let Ok(Some(content)) = storage.get_narinfo(hash).await {
49                if let Ok(info) = sui_compat::narinfo::NarInfo::parse(&content) {
50                    result.bytes_freed += info.file_size;
51                }
52                // Also account for the narinfo text itself.
53                result.bytes_freed += content.len() as u64;
54            }
55            storage.delete(hash).await?;
56            result.paths_deleted += 1;
57        }
58    }
59
60    Ok(result)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::storage::local::LocalStorage;
67
68    fn make_narinfo(hash: &str, file_size: u64) -> String {
69        format!(
70            "StorePath: /nix/store/{hash}-pkg\n\
71             URL: nar/{hash}.nar.xz\n\
72             Compression: xz\n\
73             FileHash: sha256:aaaa\n\
74             FileSize: {file_size}\n\
75             NarHash: sha256:bbbb\n\
76             NarSize: 5000\n\
77             References: \n"
78        )
79    }
80
81    #[tokio::test]
82    async fn gc_empty_cache() {
83        let dir = tempfile::tempdir().unwrap();
84        let storage = LocalStorage::new(dir.path());
85        let result = collect_garbage(&storage, &[]).await.unwrap();
86        assert_eq!(result.paths_deleted, 0);
87        assert_eq!(result.bytes_freed, 0);
88    }
89
90    #[tokio::test]
91    async fn gc_keeps_roots() {
92        let dir = tempfile::tempdir().unwrap();
93        let storage = LocalStorage::new(dir.path());
94
95        storage.put_narinfo("aaa", &make_narinfo("aaa", 100)).await.unwrap();
96        storage.put_narinfo("bbb", &make_narinfo("bbb", 200)).await.unwrap();
97
98        let roots = vec!["aaa".to_string(), "bbb".to_string()];
99        let result = collect_garbage(&storage, &roots).await.unwrap();
100
101        assert_eq!(result.paths_deleted, 0);
102        // Both should still exist.
103        assert!(storage.get_narinfo("aaa").await.unwrap().is_some());
104        assert!(storage.get_narinfo("bbb").await.unwrap().is_some());
105    }
106
107    #[tokio::test]
108    async fn gc_deletes_non_roots() {
109        let dir = tempfile::tempdir().unwrap();
110        let storage = LocalStorage::new(dir.path());
111
112        storage.put_narinfo("keep", &make_narinfo("keep", 100)).await.unwrap();
113        storage.put_narinfo("drop", &make_narinfo("drop", 500)).await.unwrap();
114        storage.put_nar("nar/drop.nar.xz", b"nar data").await.unwrap();
115
116        let roots = vec!["keep".to_string()];
117        let result = collect_garbage(&storage, &roots).await.unwrap();
118
119        assert_eq!(result.paths_deleted, 1);
120        assert!(result.bytes_freed > 0);
121
122        // "keep" should still exist, "drop" should be gone.
123        assert!(storage.get_narinfo("keep").await.unwrap().is_some());
124        assert!(storage.get_narinfo("drop").await.unwrap().is_none());
125    }
126
127    #[tokio::test]
128    async fn gc_deletes_all_when_no_roots() {
129        let dir = tempfile::tempdir().unwrap();
130        let storage = LocalStorage::new(dir.path());
131
132        storage.put_narinfo("aaa", &make_narinfo("aaa", 100)).await.unwrap();
133        storage.put_narinfo("bbb", &make_narinfo("bbb", 200)).await.unwrap();
134        storage.put_narinfo("ccc", &make_narinfo("ccc", 300)).await.unwrap();
135
136        let result = collect_garbage(&storage, &[]).await.unwrap();
137
138        assert_eq!(result.paths_deleted, 3);
139        assert!(storage.list_narinfos().await.unwrap().is_empty());
140    }
141
142    #[tokio::test]
143    async fn gc_result_display() {
144        let result = GcResult {
145            paths_deleted: 5,
146            bytes_freed: 1024,
147        };
148        let s = format!("{result}");
149        assert!(s.contains("5"));
150        assert!(s.contains("1024"));
151    }
152
153    #[tokio::test]
154    async fn gc_accounts_for_file_size_and_narinfo_text() {
155        let dir = tempfile::tempdir().unwrap();
156        let storage = LocalStorage::new(dir.path());
157
158        let narinfo_text = make_narinfo("abc", 1000);
159        storage.put_narinfo("abc", &narinfo_text).await.unwrap();
160
161        let result = collect_garbage(&storage, &[]).await.unwrap();
162        // Should include FileSize (1000) + narinfo text length.
163        assert_eq!(result.bytes_freed, 1000 + narinfo_text.len() as u64);
164    }
165}