1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/*
* Mitts Build — Rust-first build and package tooling with shared artifact cache
* Copyright (c) 2026 Alexander Panasenko
*
* Contact: alex@prod.codes
* Author: https://prod.codes/about/
* Project: https://github.com/alex09x/mitts
* SPDX-License-Identifier: MIT OR Apache-2.0
*/
use super::action_cache::ActionCache;
use super::cas::ContentAddressedStore;
use super::lock::FileLock;
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
/// Report generated after running garbage collection on the cache.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GcReport {
pub objects_examined: usize,
pub objects_pruned: usize,
pub bytes_freed: u64,
pub bytes_retained: u64,
}
/// Options configuring cache garbage collection and storage quotas.
#[derive(Debug, Clone, Default)]
pub struct GcOptions {
/// Maximum cache size in bytes. Oldest CAS objects are pruned until total size is under quota.
pub max_bytes: Option<u64>,
/// Prune CAS objects that have not been modified or accessed within this duration.
pub max_age: Option<Duration>,
}
pub struct CacheGc<'a> {
cas: &'a ContentAddressedStore,
ac: &'a ActionCache,
}
impl<'a> CacheGc<'a> {
pub fn new(cas: &'a ContentAddressedStore, ac: &'a ActionCache) -> Self {
Self { cas, ac }
}
/// Identifies active build locks to ensure active builds are never touched during GC.
fn active_locks(&self) -> HashSet<String> {
let mut active = HashSet::new();
let locks_dir = self.ac.locks_dir();
if let Ok(entries) = fs::read_dir(locks_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "lock").unwrap_or(false) {
// If we cannot acquire lock exclusively, it is actively held by a running build
if matches!(FileLock::try_acquire_exclusive(&path), Ok(None)) {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
active.insert(stem.to_string());
}
}
}
}
}
active
}
/// Gathers all CAS hashes currently referenced by active action keys.
fn active_cas_hashes(&self, active_keys: &HashSet<String>) -> HashSet<String> {
let mut active_hashes = HashSet::new();
for key in active_keys {
if let Ok(Some(entry)) = self.ac.get(key) {
for out in entry.outputs {
active_hashes.insert(out.cas_hash);
}
}
}
active_hashes
}
/// Runs garbage collection respecting storage quotas, age limits, and active locks.
pub fn prune(&self, opts: &GcOptions) -> Result<GcReport> {
let objects_dir = self.cas.root().join("objects");
if !objects_dir.is_dir() {
return Ok(GcReport::default());
}
let active_keys = self.active_locks();
let protected_hashes = self.active_cas_hashes(&active_keys);
struct CasObjectMeta {
path: PathBuf,
hash: String,
size: u64,
mtime: SystemTime,
}
let mut objects: Vec<CasObjectMeta> = Vec::new();
let mut total_bytes: u64 = 0;
for entry in fs::read_dir(&objects_dir)
.with_context(|| format!("Failed to read CAS objects dir {:?}", objects_dir))?
.flatten()
{
let path = entry.path();
if !path.is_file() {
continue;
}
let hash = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if hash.is_empty() {
continue;
}
if let Ok(meta) = fs::metadata(&path) {
let size = meta.len();
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
total_bytes += size;
objects.push(CasObjectMeta {
path,
hash,
size,
mtime,
});
}
}
let objects_examined = objects.len();
// Sort by mtime ascending (oldest first for LRU eviction)
objects.sort_by_key(|o| o.mtime);
let now = SystemTime::now();
let mut pruned_hashes: HashSet<String> = HashSet::new();
let mut bytes_freed: u64 = 0;
// 1. Age-based eviction
if let Some(max_age) = opts.max_age {
for obj in &objects {
if protected_hashes.contains(&obj.hash) {
continue;
}
if let Ok(age) = now.duration_since(obj.mtime) {
if age > max_age {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
fs::set_permissions(&obj.path, fs::Permissions::from_mode(0o644));
}
if fs::remove_file(&obj.path).is_ok() {
pruned_hashes.insert(obj.hash.clone());
bytes_freed += obj.size;
total_bytes = total_bytes.saturating_sub(obj.size);
}
}
}
}
}
// 2. Storage quota-based eviction (LRU until under max_bytes)
if let Some(max_bytes) = opts.max_bytes {
for obj in &objects {
if total_bytes <= max_bytes {
break;
}
if pruned_hashes.contains(&obj.hash) || protected_hashes.contains(&obj.hash) {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&obj.path, fs::Permissions::from_mode(0o644));
}
if fs::remove_file(&obj.path).is_ok() {
pruned_hashes.insert(obj.hash.clone());
bytes_freed += obj.size;
total_bytes = total_bytes.saturating_sub(obj.size);
}
}
}
// 3. Clean orphaned artifacts links and dead action cache entries
if !pruned_hashes.is_empty() {
let artifacts_dir = self.cas.root().join("artifacts");
if let Ok(entries) = fs::read_dir(&artifacts_dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
for pruned in &pruned_hashes {
let short = if pruned.len() > 16 {
&pruned[..16]
} else {
pruned
};
if name.contains(short) {
let _ = fs::remove_file(&path);
}
}
}
}
// Prune invalid AC entries
if let Ok(entries) = fs::read_dir(self.ac.root()) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().map(|e| e == "json").unwrap_or(false) {
if let Some(key) = p.file_stem().and_then(|s| s.to_str()) {
if let Ok(Some(ac_entry)) = self.ac.get(key) {
let has_pruned = ac_entry
.outputs
.iter()
.any(|out| pruned_hashes.contains(&out.cas_hash));
if has_pruned {
let _ = fs::remove_file(&p);
}
}
}
}
}
}
}
// 4. Prune build_scripts entries older than max_age
if let Some(max_age) = opts.max_age {
let build_scripts_dir = self.cas.root().join("build_scripts");
if let Ok(base_entries) = fs::read_dir(&build_scripts_dir) {
for base_entry in base_entries.flatten() {
let base_path = base_entry.path();
if !base_path.is_dir() {
continue;
}
if let Ok(exec_entries) = fs::read_dir(&base_path) {
let mut remaining_entries = 0;
for exec_entry in exec_entries.flatten() {
let exec_path = exec_entry.path();
let file_name =
exec_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// If leftover from an interrupted GC, clean it up
if file_name.starts_with(".gc.") {
let _ = fs::remove_dir_all(&exec_path);
continue;
}
// Clean up abandoned .tmp.* staging directories older than 1 hour
if file_name.starts_with(".tmp.") {
if let Ok(meta) = fs::metadata(&exec_path) {
if let Ok(mtime) = meta.modified() {
if let Ok(age) = now.duration_since(mtime) {
if age > Duration::from_secs(3600) {
let _ = fs::remove_dir_all(&exec_path);
}
}
}
}
continue;
}
if file_name.starts_with('.') || !exec_path.is_dir() {
continue;
}
// P3: Use .last_used marker if present, fallback to directory modified time
let last_used = fs::metadata(exec_path.join(".last_used"))
.and_then(|m| m.modified())
.ok();
let effective_time = last_used
.or_else(|| {
fs::metadata(&exec_path).and_then(|m| m.modified()).ok()
})
.unwrap_or(SystemTime::UNIX_EPOCH);
if let Ok(age) = now.duration_since(effective_time) {
if age > max_age {
// P2: Atomically unpublish first by renaming to .gc.<exec_key>.<pid>
// so concurrent readers (which skip dot-files) never observe partial removals.
let pid = std::process::id();
let gc_temp =
base_path.join(format!(".gc.{}.{}", file_name, pid));
if fs::rename(&exec_path, &gc_temp).is_ok() {
let _ = fs::remove_dir_all(&gc_temp);
continue;
}
// If rename failed (e.g. concurrent reader/writer race),
// do not delete in place — preserve entry and retry on next GC pass.
}
}
remaining_entries += 1;
}
if remaining_entries == 0 {
let _ = fs::remove_dir(&base_path);
}
}
}
}
}
Ok(GcReport {
objects_examined,
objects_pruned: pruned_hashes.len(),
bytes_freed,
bytes_retained: total_bytes,
})
}
}