1use std::collections::BTreeSet;
24use std::path::{Path, PathBuf};
25
26use anyhow::Result;
27use tracing::{info, warn};
28
29use crate::reconciler::static_asset::WORKLOAD_KIND;
30use crate::ServiceConfig;
31
32#[derive(Debug, Default)]
35pub struct DeriveCacheLiveHashes {
36 pub fetch: BTreeSet<String>,
38 pub transform: BTreeSet<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct DerivePruneCandidate {
46 pub path: PathBuf,
48 pub cache_kind: &'static str,
50 pub hash: String,
52 pub size: u64,
54}
55
56pub fn collect_live_derive_hashes<'a>(
63 workspace_root: &Path,
64 services: impl IntoIterator<Item = &'a ServiceConfig>,
65) -> Result<DeriveCacheLiveHashes> {
66 let mut live = DeriveCacheLiveHashes::default();
67
68 for svc in services {
69 for component in &svc.components {
70 if component.kind != WORKLOAD_KIND {
71 continue;
72 }
73 let workload_path = workspace_root.join(&component.path).join("workload.toml");
74 let src = match std::fs::read_to_string(&workload_path) {
75 Ok(s) => s,
76 Err(e) => {
77 warn!(
78 path = %workload_path.display(),
79 error = %e,
80 "derive-cache prune: skipping unreadable workload",
81 );
82 continue;
83 }
84 };
85 let envelope: workload_spec::Workload = match toml::from_str(&src) {
86 Ok(w) => w,
87 Err(e) => {
88 warn!(
89 path = %workload_path.display(),
90 error = %e,
91 "derive-cache prune: skipping unparseable workload",
92 );
93 continue;
94 }
95 };
96 let workload_spec::Workload::StaticAsset(workload) = envelope else {
97 continue;
98 };
99 for entry in &workload.assets {
100 let Some(derive) = &entry.derive else {
101 continue;
102 };
103 live.fetch.insert(derive.fetch.blake3.0.clone());
104 if derive.transform.is_some() {
105 live.transform.insert(entry.blake3.0.clone());
106 }
107 }
108 }
109 }
110
111 Ok(live)
112}
113
114pub fn compute_derive_cache_candidates(
120 derive_cache_root: &Path,
121 live: &DeriveCacheLiveHashes,
122) -> Result<Vec<DerivePruneCandidate>> {
123 let mut candidates = Vec::new();
124
125 for (subdir, live_set) in [("fetch", &live.fetch), ("transform", &live.transform)] {
126 let dir = derive_cache_root.join(subdir);
127 let entries = match std::fs::read_dir(&dir) {
128 Ok(e) => e,
129 Err(_) => continue, };
131 for entry in entries.flatten() {
132 let path = entry.path();
133 let ext = path.extension().and_then(|e| e.to_str());
134 if ext != Some("bin") {
135 continue; }
137 let Some(hash) = path
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .map(|s| s.to_string())
141 else {
142 continue;
143 };
144 if live_set.contains(hash.as_str()) {
145 continue;
146 }
147 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
148 candidates.push(DerivePruneCandidate {
149 path,
150 cache_kind: if subdir == "fetch" {
151 "fetch"
152 } else {
153 "transform"
154 },
155 hash,
156 size,
157 });
158 }
159 }
160
161 candidates.sort_by(|a, b| a.path.cmp(&b.path));
163 Ok(candidates)
164}
165
166pub fn execute_derive_cache_prune(candidates: &[DerivePruneCandidate]) -> (usize, u64, usize) {
168 let mut deleted = 0usize;
169 let mut bytes = 0u64;
170 let mut errors = 0usize;
171
172 for c in candidates {
173 match std::fs::remove_file(&c.path) {
174 Ok(()) => {
175 info!(path = %c.path.display(), "pruned derive cache entry");
176 deleted += 1;
177 bytes += c.size;
178 }
179 Err(e) => {
180 warn!(path = %c.path.display(), error = %e, "failed to prune derive cache entry");
181 errors += 1;
182 }
183 }
184 }
185
186 (deleted, bytes, errors)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::config::{ServiceComponent, ServiceConfig};
193 use tempfile::TempDir;
194
195 fn svc(name: &str, components: Vec<ServiceComponent>) -> ServiceConfig {
196 ServiceConfig {
197 schema_version: 1,
198 name: name.into(),
199 domain: String::new(),
200 components,
201 db: crate::DbCatalog::default(),
202 }
203 }
204
205 fn static_asset_component(id: &str, path: &str) -> ServiceComponent {
206 ServiceComponent {
207 mount: None,
208 id: id.into(),
209 kind: "static-asset".into(),
210 path: path.into(),
211 role: String::new(),
212 publishes: None,
213 wave: 0,
214 git: None,
215 deploy: Default::default(),
216 }
217 }
218
219 fn write_workload(dir: &Path, toml: &str) {
220 if let Some(p) = dir.parent() {
221 std::fs::create_dir_all(p).unwrap();
222 }
223 std::fs::create_dir_all(dir).unwrap();
224 std::fs::write(dir.join("workload.toml"), toml).unwrap();
225 }
226
227 const FETCH_HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
228 const OUTPUT_HASH: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
229
230 fn workload_with_fetch_only(fetch_hash: &str, output_hash: &str) -> String {
231 format!(
232 r#"kind = "static-asset"
233schema_version = "V1"
234
235[[asset]]
236filename = "model.bin"
237blake3 = "{output_hash}"
238
239[asset.derive.fetch]
240url = "https://example.com/model.bin"
241blake3 = "{fetch_hash}"
242license = "mit"
243"#
244 )
245 }
246
247 fn workload_with_fetch_and_transform(fetch_hash: &str, output_hash: &str) -> String {
248 format!(
249 r#"kind = "static-asset"
250schema_version = "V1"
251
252[[asset]]
253filename = "model.bin"
254blake3 = "{output_hash}"
255
256[asset.derive.fetch]
257url = "https://example.com/model.bin"
258blake3 = "{fetch_hash}"
259license = "mit"
260
261[asset.derive.transform]
262recipe = "quantize"
263"#
264 )
265 }
266
267 #[test]
268 fn collect_live_hashes_fetch_only() {
269 let tmp = TempDir::new().unwrap();
270 let workload_dir = tmp.path().join("app/web");
271 write_workload(
272 &workload_dir,
273 &workload_with_fetch_only(FETCH_HASH, OUTPUT_HASH),
274 );
275
276 let svc = svc("my-svc", vec![static_asset_component("site", "app/web")]);
277 let live = collect_live_derive_hashes(tmp.path(), [&svc]).unwrap();
278
279 assert!(live.fetch.contains(FETCH_HASH), "fetch hash must be live");
280 assert!(
281 live.transform.is_empty(),
282 "fetch-only asset has no transform cache entry"
283 );
284 }
285
286 #[test]
287 fn collect_live_hashes_fetch_and_transform() {
288 let tmp = TempDir::new().unwrap();
289 let workload_dir = tmp.path().join("app/web");
290 write_workload(
291 &workload_dir,
292 &workload_with_fetch_and_transform(FETCH_HASH, OUTPUT_HASH),
293 );
294
295 let svc = svc("my-svc", vec![static_asset_component("site", "app/web")]);
296 let live = collect_live_derive_hashes(tmp.path(), [&svc]).unwrap();
297
298 assert!(live.fetch.contains(FETCH_HASH));
299 assert!(
300 live.transform.contains(OUTPUT_HASH),
301 "transform cache entry keyed by output blake3"
302 );
303 }
304
305 #[test]
306 fn collect_live_hashes_missing_workload_skips_silently() {
307 let tmp = TempDir::new().unwrap();
308 let svc = svc("my-svc", vec![static_asset_component("site", "app/web")]);
310 let live = collect_live_derive_hashes(tmp.path(), [&svc]).unwrap();
311 assert!(live.fetch.is_empty());
312 assert!(live.transform.is_empty());
313 }
314
315 #[test]
316 fn collect_live_hashes_ignores_non_static_asset_components() {
317 let tmp = TempDir::new().unwrap();
318 let svc = svc(
319 "my-svc",
320 vec![ServiceComponent {
321 mount: None,
322 id: "api".into(),
323 kind: "container".into(),
324 path: "app/api".into(),
325 role: String::new(),
326 publishes: None,
327 wave: 0,
328 git: None,
329 deploy: Default::default(),
330 }],
331 );
332 let live = collect_live_derive_hashes(tmp.path(), [&svc]).unwrap();
333 assert!(live.fetch.is_empty());
334 }
335
336 #[test]
337 fn candidates_exclude_live_hashes() {
338 let tmp = TempDir::new().unwrap();
339 let fetch_dir = tmp.path().join("fetch");
340 std::fs::create_dir_all(&fetch_dir).unwrap();
341
342 std::fs::write(fetch_dir.join(format!("{FETCH_HASH}.bin")), b"live").unwrap();
344 let orphan = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
346 std::fs::write(fetch_dir.join(format!("{orphan}.bin")), b"orphan").unwrap();
347 std::fs::write(fetch_dir.join(format!("{orphan}.partial")), b"partial").unwrap();
349
350 let mut live = DeriveCacheLiveHashes::default();
351 live.fetch.insert(FETCH_HASH.to_string());
352
353 let candidates = compute_derive_cache_candidates(tmp.path(), &live).unwrap();
354 assert_eq!(candidates.len(), 1, "exactly one orphan");
355 assert_eq!(candidates[0].hash, orphan);
356 assert_eq!(candidates[0].cache_kind, "fetch");
357 }
358
359 #[test]
360 fn execute_prune_deletes_candidates() {
361 let tmp = TempDir::new().unwrap();
362 let fetch_dir = tmp.path().join("fetch");
363 std::fs::create_dir_all(&fetch_dir).unwrap();
364
365 let file = fetch_dir.join("dead.bin");
366 std::fs::write(&file, b"orphan bytes").unwrap();
367 assert!(file.exists());
368
369 let candidate = DerivePruneCandidate {
370 path: file.clone(),
371 cache_kind: "fetch",
372 hash: "dead".into(),
373 size: 12,
374 };
375 let (deleted, bytes, errors) = execute_derive_cache_prune(&[candidate]);
376 assert_eq!(deleted, 1);
377 assert_eq!(bytes, 12);
378 assert_eq!(errors, 0);
379 assert!(!file.exists(), "file must be deleted");
380 }
381}