Skip to main content

hd_cas/
gc.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::hash::ContentHash;
6use crate::store::ContentStore;
7
8/// Garbage collection statistics.
9#[derive(Debug, Default)]
10pub struct GcStats {
11    pub manifests_removed: usize,
12    pub chunks_removed: usize,
13}
14
15/// Reference-counting garbage collector for the CAS.
16/// Ref counts are stored as simple files: refs/<shard>/<hash> contains the count as a u64.
17pub struct GarbageCollector {
18    refs_dir: PathBuf,
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum GcError {
23    #[error("I/O error: {0}")]
24    Io(#[from] std::io::Error),
25    #[error("store error: {0}")]
26    Store(#[from] crate::store::StoreError),
27}
28
29impl GarbageCollector {
30    pub fn new(cas_root: &Path) -> Result<Self, GcError> {
31        let refs_dir = cas_root.join("refs");
32        fs::create_dir_all(&refs_dir)?;
33        Ok(GarbageCollector { refs_dir })
34    }
35
36    pub fn add_ref(&self, manifest_hash: &ContentHash) -> Result<(), GcError> {
37        let count = self.ref_count(manifest_hash).unwrap_or(0);
38        self.write_ref_count(manifest_hash, count + 1)
39    }
40
41    pub fn remove_ref(&self, manifest_hash: &ContentHash) -> Result<(), GcError> {
42        let count = self.ref_count(manifest_hash).unwrap_or(0);
43        if count <= 1 {
44            let path = self.ref_path(manifest_hash);
45            if path.exists() {
46                fs::remove_file(&path)?;
47            }
48        } else {
49            self.write_ref_count(manifest_hash, count - 1)?;
50        }
51        Ok(())
52    }
53
54    pub fn ref_count(&self, manifest_hash: &ContentHash) -> Result<u64, GcError> {
55        let path = self.ref_path(manifest_hash);
56        if !path.exists() {
57            return Ok(0);
58        }
59        let bytes = fs::read(&path)?;
60        let count = u64::from_le_bytes(bytes.try_into().unwrap_or([0; 8]));
61        Ok(count)
62    }
63
64    pub fn collect(&self, store: &ContentStore) -> Result<GcStats, GcError> {
65        let mut stats = GcStats::default();
66        let referenced_manifests = self.all_referenced_manifests()?;
67        let mut referenced_chunks = HashSet::new();
68        let mut manifests_to_remove = Vec::new();
69
70        for manifest_hash in store.list_manifests()? {
71            if referenced_manifests.contains(&manifest_hash) {
72                if let Ok(manifest) = store.get_manifest(&manifest_hash) {
73                    for chunk_hash in &manifest.chunks {
74                        referenced_chunks.insert(*chunk_hash);
75                    }
76                }
77            } else {
78                manifests_to_remove.push(manifest_hash);
79            }
80        }
81
82        for mhash in &manifests_to_remove {
83            store.remove_manifest(mhash)?;
84            stats.manifests_removed += 1;
85        }
86
87        for chunk_hash in store.list_chunks()? {
88            if !referenced_chunks.contains(&chunk_hash) {
89                store.remove_chunk(&chunk_hash)?;
90                stats.chunks_removed += 1;
91            }
92        }
93
94        Ok(stats)
95    }
96
97    fn ref_path(&self, hash: &ContentHash) -> PathBuf {
98        let hex = hash.to_hex();
99        self.refs_dir.join(&hex[..2]).join(&hex[2..])
100    }
101
102    fn write_ref_count(&self, hash: &ContentHash, count: u64) -> Result<(), GcError> {
103        let path = self.ref_path(hash);
104        fs::create_dir_all(path.parent().unwrap())?;
105        fs::write(&path, count.to_le_bytes())?;
106        Ok(())
107    }
108
109    fn all_referenced_manifests(&self) -> Result<HashSet<ContentHash>, GcError> {
110        let mut set = HashSet::new();
111        if !self.refs_dir.exists() {
112            return Ok(set);
113        }
114        for shard_entry in fs::read_dir(&self.refs_dir)? {
115            let shard_entry = shard_entry?;
116            if !shard_entry.file_type()?.is_dir() {
117                continue;
118            }
119            let shard = shard_entry.file_name().to_string_lossy().to_string();
120            for entry in fs::read_dir(shard_entry.path())? {
121                let entry = entry?;
122                let rest = entry.file_name().to_string_lossy().to_string();
123                let hex = format!("{}{}", shard, rest);
124                if let Ok(hash) = ContentHash::from_hex(&hex) {
125                    let bytes = fs::read(entry.path())?;
126                    let count = u64::from_le_bytes(bytes.try_into().unwrap_or([0; 8]));
127                    if count > 0 {
128                        set.insert(hash);
129                    }
130                }
131            }
132        }
133        Ok(set)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::store::ContentStore;
141    use tempfile::TempDir;
142
143    fn test_gc() -> (GarbageCollector, ContentStore, TempDir) {
144        let dir = TempDir::new().unwrap();
145        let store = ContentStore::open(dir.path()).unwrap();
146        let gc = GarbageCollector::new(dir.path()).unwrap();
147        (gc, store, dir)
148    }
149
150    #[test]
151    fn ref_count_increment_and_decrement() {
152        let (gc, store, _dir) = test_gc();
153        let hash = store.put_chunk(b"data").unwrap();
154        let manifest = crate::manifest::Manifest::new(vec![hash], 4, 0o644);
155        let mhash = store.put_manifest(&manifest).unwrap();
156
157        gc.add_ref(&mhash).unwrap();
158        assert_eq!(gc.ref_count(&mhash).unwrap(), 1);
159
160        gc.add_ref(&mhash).unwrap();
161        assert_eq!(gc.ref_count(&mhash).unwrap(), 2);
162
163        gc.remove_ref(&mhash).unwrap();
164        assert_eq!(gc.ref_count(&mhash).unwrap(), 1);
165    }
166
167    #[test]
168    fn gc_removes_unreferenced_manifests_and_chunks() {
169        let (gc, store, _dir) = test_gc();
170        let hash = store.put_chunk(b"orphan data").unwrap();
171        let manifest = crate::manifest::Manifest::new(vec![hash], 11, 0o644);
172        let _mhash = store.put_manifest(&manifest).unwrap();
173
174        assert!(store.has_chunk(&hash));
175        let stats = gc.collect(&store).unwrap();
176        assert_eq!(stats.manifests_removed, 1);
177        assert_eq!(stats.chunks_removed, 1);
178        assert!(!store.has_chunk(&hash));
179    }
180
181    #[test]
182    fn gc_preserves_referenced_data() {
183        let (gc, store, _dir) = test_gc();
184        let hash = store.put_chunk(b"keep me").unwrap();
185        let manifest = crate::manifest::Manifest::new(vec![hash], 7, 0o644);
186        let mhash = store.put_manifest(&manifest).unwrap();
187
188        gc.add_ref(&mhash).unwrap();
189        let stats = gc.collect(&store).unwrap();
190        assert_eq!(stats.manifests_removed, 0);
191        assert_eq!(stats.chunks_removed, 0);
192        assert!(store.has_chunk(&hash));
193    }
194
195    #[test]
196    fn gc_shared_chunks_preserved() {
197        let (gc, store, _dir) = test_gc();
198        let shared_chunk = store.put_chunk(b"shared").unwrap();
199
200        let m1 = crate::manifest::Manifest::new(vec![shared_chunk], 6, 0o644);
201        let mh1 = store.put_manifest(&m1).unwrap();
202        gc.add_ref(&mh1).unwrap();
203
204        let unique_chunk = store.put_chunk(b"unique").unwrap();
205        let m2 = crate::manifest::Manifest::new(vec![shared_chunk, unique_chunk], 12, 0o644);
206        let _mh2 = store.put_manifest(&m2).unwrap();
207
208        let stats = gc.collect(&store).unwrap();
209        assert_eq!(stats.manifests_removed, 1);
210        assert_eq!(stats.chunks_removed, 1);
211        assert!(store.has_chunk(&shared_chunk));
212        assert!(!store.has_chunk(&unique_chunk));
213    }
214}