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::CacheError;
9use crate::StorageBackend;
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::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
96            .put_narinfo("aaa", &make_narinfo("aaa", 100))
97            .await
98            .unwrap();
99        storage
100            .put_narinfo("bbb", &make_narinfo("bbb", 200))
101            .await
102            .unwrap();
103
104        let roots = vec!["aaa".to_string(), "bbb".to_string()];
105        let result = collect_garbage(&storage, &roots).await.unwrap();
106
107        assert_eq!(result.paths_deleted, 0);
108        // Both should still exist.
109        assert!(storage.get_narinfo("aaa").await.unwrap().is_some());
110        assert!(storage.get_narinfo("bbb").await.unwrap().is_some());
111    }
112
113    #[tokio::test]
114    async fn gc_deletes_non_roots() {
115        let dir = tempfile::tempdir().unwrap();
116        let storage = LocalStorage::new(dir.path());
117
118        storage
119            .put_narinfo("keep", &make_narinfo("keep", 100))
120            .await
121            .unwrap();
122        storage
123            .put_narinfo("drop", &make_narinfo("drop", 500))
124            .await
125            .unwrap();
126        storage
127            .put_nar("nar/drop.nar.xz", b"nar data")
128            .await
129            .unwrap();
130
131        let roots = vec!["keep".to_string()];
132        let result = collect_garbage(&storage, &roots).await.unwrap();
133
134        assert_eq!(result.paths_deleted, 1);
135        assert!(result.bytes_freed > 0);
136
137        // "keep" should still exist, "drop" should be gone.
138        assert!(storage.get_narinfo("keep").await.unwrap().is_some());
139        assert!(storage.get_narinfo("drop").await.unwrap().is_none());
140    }
141
142    #[tokio::test]
143    async fn gc_deletes_all_when_no_roots() {
144        let dir = tempfile::tempdir().unwrap();
145        let storage = LocalStorage::new(dir.path());
146
147        storage
148            .put_narinfo("aaa", &make_narinfo("aaa", 100))
149            .await
150            .unwrap();
151        storage
152            .put_narinfo("bbb", &make_narinfo("bbb", 200))
153            .await
154            .unwrap();
155        storage
156            .put_narinfo("ccc", &make_narinfo("ccc", 300))
157            .await
158            .unwrap();
159
160        let result = collect_garbage(&storage, &[]).await.unwrap();
161
162        assert_eq!(result.paths_deleted, 3);
163        assert!(storage.list_narinfos().await.unwrap().is_empty());
164    }
165
166    #[tokio::test]
167    async fn gc_result_display() {
168        let result = GcResult {
169            paths_deleted: 5,
170            bytes_freed: 1024,
171        };
172        let s = format!("{result}");
173        assert!(s.contains("5"));
174        assert!(s.contains("1024"));
175    }
176
177    #[tokio::test]
178    async fn gc_accounts_for_file_size_and_narinfo_text() {
179        let dir = tempfile::tempdir().unwrap();
180        let storage = LocalStorage::new(dir.path());
181
182        let narinfo_text = make_narinfo("abc", 1000);
183        storage.put_narinfo("abc", &narinfo_text).await.unwrap();
184
185        let result = collect_garbage(&storage, &[]).await.unwrap();
186        // Should include FileSize (1000) + narinfo text length.
187        assert_eq!(result.bytes_freed, 1000 + narinfo_text.len() as u64);
188    }
189}