1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::hash::ContentHash;
6use crate::store::ContentStore;
7
8#[derive(Debug, Default)]
10pub struct GcStats {
11 pub manifests_removed: usize,
12 pub chunks_removed: usize,
13}
14
15pub 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}