1use anyhow::{Result, anyhow};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use tracing::{debug, info, warn};
7
8use crate::jobstore::resolve_root;
9use crate::schema::{GcData, GcJobResult, JobState, JobStatus, Response};
10
11const DEFAULT_OLDER_THAN: &str = "30d";
12const DEFAULT_AUTO_SCAN_LIMIT: usize = 200;
13const DEFAULT_AUTO_DELETE_LIMIT: usize = 20;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GcMode {
17 Manual,
18 Automatic,
19}
20
21#[derive(Debug, Clone)]
22pub struct GcPolicy {
23 pub older_than: String,
24 pub max_jobs: Option<usize>,
25 pub max_bytes: Option<u64>,
26 pub dry_run: bool,
27 pub mode: GcMode,
28 pub scan_limit: Option<usize>,
29 pub delete_limit: Option<usize>,
30}
31
32#[derive(Debug)]
33pub struct GcOpts<'a> {
34 pub root: Option<&'a str>,
35 pub older_than: Option<&'a str>,
36 pub max_jobs: Option<u64>,
37 pub max_bytes: Option<u64>,
38 pub dry_run: bool,
39}
40
41#[derive(Debug, Clone)]
42struct Candidate {
43 job_id: String,
44 path: PathBuf,
45 status: JobStatus,
46 gc_ts: String,
47 bytes: u64,
48 reasons: Vec<&'static str>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AutoGcConfig {
53 pub enabled: bool,
54 pub older_than: String,
55 pub max_jobs: Option<usize>,
56 pub max_bytes: Option<u64>,
57 pub scan_limit: usize,
58 pub delete_limit: usize,
59}
60
61impl Default for AutoGcConfig {
62 fn default() -> Self {
63 Self {
64 enabled: true,
65 older_than: DEFAULT_OLDER_THAN.to_string(),
66 max_jobs: None,
67 max_bytes: None,
68 scan_limit: DEFAULT_AUTO_SCAN_LIMIT,
69 delete_limit: DEFAULT_AUTO_DELETE_LIMIT,
70 }
71 }
72}
73
74pub fn execute(opts: GcOpts) -> Result<()> {
75 let root = resolve_root(opts.root);
76 let root_str = root.display().to_string();
77
78 let (older_than_str, older_than_source) = match opts.older_than {
79 Some(s) => (s.to_string(), "flag".to_string()),
80 None => (DEFAULT_OLDER_THAN.to_string(), "default".to_string()),
81 };
82
83 let max_jobs = opts
84 .max_jobs
85 .map(|v| usize::try_from(v).map_err(|_| anyhow!("invalid --max-jobs: {v}")))
86 .transpose()?;
87
88 let policy = GcPolicy {
89 older_than: older_than_str.clone(),
90 max_jobs,
91 max_bytes: opts.max_bytes,
92 dry_run: opts.dry_run,
93 mode: GcMode::Manual,
94 scan_limit: None,
95 delete_limit: None,
96 };
97
98 let outcome = run_gc(&root, &policy)?;
99
100 Response::new(
101 "gc",
102 GcData {
103 root: root_str,
104 dry_run: opts.dry_run,
105 older_than: older_than_str,
106 older_than_source,
107 deleted: outcome.deleted,
108 skipped: outcome.skipped,
109 out_of_scope: outcome.out_of_scope,
110 failed: outcome.failed,
111 freed_bytes: outcome.freed_bytes,
112 scanned_dirs: outcome.scanned_dirs,
113 candidate_count: outcome.candidate_count,
114 jobs: outcome.jobs,
115 },
116 )
117 .print();
118
119 Ok(())
120}
121
122pub fn maybe_run_auto_gc(root: &Path, cfg: &AutoGcConfig) {
123 if !cfg.enabled {
124 debug!("auto-gc disabled");
125 return;
126 }
127
128 let policy = GcPolicy {
129 older_than: cfg.older_than.clone(),
130 max_jobs: cfg.max_jobs,
131 max_bytes: cfg.max_bytes,
132 dry_run: false,
133 mode: GcMode::Automatic,
134 scan_limit: Some(cfg.scan_limit),
135 delete_limit: Some(cfg.delete_limit),
136 };
137
138 if let Err(e) = run_gc_with_lock(root, &policy) {
139 warn!(error = %e, "auto-gc failed (best-effort)");
140 }
141}
142
143#[derive(Debug)]
144struct GcOutcome {
145 deleted: u64,
146 skipped: u64,
147 out_of_scope: u64,
148 failed: u64,
149 freed_bytes: u64,
150 scanned_dirs: u64,
151 candidate_count: u64,
152 jobs: Vec<GcJobResult>,
153}
154
155fn run_gc_with_lock(root: &Path, policy: &GcPolicy) -> Result<GcOutcome> {
156 if policy.mode == GcMode::Manual {
157 return run_gc(root, policy);
158 }
159
160 let lock_path = root.join(".gc.lock");
161 let lock = std::fs::OpenOptions::new()
162 .write(true)
163 .create_new(true)
164 .open(&lock_path);
165
166 let lock_file = match lock {
167 Ok(f) => f,
168 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
169 debug!(path = %lock_path.display(), "auto-gc lock already held; skipping");
170 return Ok(empty_outcome());
171 }
172 Err(e) => return Err(anyhow!("create auto-gc lock {}: {e}", lock_path.display())),
173 };
174
175 let result = run_gc(root, policy);
176 drop(lock_file);
177 let _ = std::fs::remove_file(&lock_path);
178 result
179}
180
181fn empty_outcome() -> GcOutcome {
182 GcOutcome {
183 deleted: 0,
184 skipped: 0,
185 out_of_scope: 0,
186 failed: 0,
187 freed_bytes: 0,
188 scanned_dirs: 0,
189 candidate_count: 0,
190 jobs: vec![],
191 }
192}
193
194fn run_gc(root: &Path, policy: &GcPolicy) -> Result<GcOutcome> {
195 if !root.exists() {
196 return Ok(empty_outcome());
197 }
198
199 let retention_secs = parse_duration(&policy.older_than).ok_or_else(|| {
200 anyhow!(
201 "invalid duration: {}; expected formats: 30d, 24h, 60m, 3600s",
202 policy.older_than
203 )
204 })?;
205
206 let now_secs = std::time::SystemTime::now()
207 .duration_since(std::time::UNIX_EPOCH)
208 .unwrap_or_default()
209 .as_secs();
210 let cutoff_secs = now_secs.saturating_sub(retention_secs);
211 let cutoff = format_rfc3339(cutoff_secs);
212
213 let mut scanned_dirs = 0u64;
214 let mut out_of_scope = 0u64;
215 let mut skipped = 0u64;
216 let mut failed = 0u64;
217
218 let mut results = Vec::new();
219 let mut candidates = Vec::<Candidate>::new();
220
221 let read_dir = std::fs::read_dir(root)
222 .map_err(|e| anyhow!("failed to read root directory {}: {}", root.display(), e))?;
223
224 for entry in read_dir {
225 let entry = match entry {
226 Ok(v) => v,
227 Err(e) => {
228 skipped += 1;
229 failed += 1;
230 warn!(error = %e, "gc: failed to read directory entry");
231 continue;
232 }
233 };
234
235 let path = entry.path();
236 if !path.is_dir() {
237 continue;
238 }
239
240 scanned_dirs += 1;
241 if let Some(limit) = policy.scan_limit
242 && scanned_dirs as usize > limit
243 {
244 break;
245 }
246
247 let job_id = match path.file_name().and_then(|n| n.to_str()) {
248 Some(s) => s.to_string(),
249 None => {
250 skipped += 1;
251 out_of_scope += 1;
252 continue;
253 }
254 };
255
256 let state_path = path.join("state.json");
257 let state = match std::fs::read(&state_path)
258 .ok()
259 .and_then(|b| serde_json::from_slice::<JobState>(&b).ok())
260 {
261 Some(s) => s,
262 None => {
263 skipped += 1;
264 out_of_scope += 1;
265 results.push(GcJobResult {
266 job_id,
267 state: "unknown".to_string(),
268 action: "skipped".to_string(),
269 reason: "state_unreadable".to_string(),
270 bytes: 0,
271 });
272 continue;
273 }
274 };
275
276 let status = state.status().clone();
277 if matches!(status, JobStatus::Running | JobStatus::Created) {
278 skipped += 1;
279 out_of_scope += 1;
280 results.push(GcJobResult {
281 job_id,
282 state: status.as_str().to_string(),
283 action: "skipped".to_string(),
284 reason: "active_job".to_string(),
285 bytes: 0,
286 });
287 continue;
288 }
289
290 if !matches!(
291 status,
292 JobStatus::Exited | JobStatus::Killed | JobStatus::Failed
293 ) {
294 skipped += 1;
295 out_of_scope += 1;
296 results.push(GcJobResult {
297 job_id,
298 state: status.as_str().to_string(),
299 action: "skipped".to_string(),
300 reason: "non_terminal_status".to_string(),
301 bytes: 0,
302 });
303 continue;
304 }
305
306 let gc_ts = state
307 .finished_at
308 .as_deref()
309 .or(Some(state.updated_at.as_str()))
310 .unwrap_or_default()
311 .to_string();
312
313 if gc_ts.is_empty() {
314 skipped += 1;
315 out_of_scope += 1;
316 results.push(GcJobResult {
317 job_id,
318 state: status.as_str().to_string(),
319 action: "skipped".to_string(),
320 reason: "no_timestamp".to_string(),
321 bytes: 0,
322 });
323 continue;
324 }
325
326 if !is_older_than(&gc_ts, &cutoff) {
327 skipped += 1;
328 out_of_scope += 1;
329 results.push(GcJobResult {
330 job_id,
331 state: status.as_str().to_string(),
332 action: "skipped".to_string(),
333 reason: "too_recent".to_string(),
334 bytes: 0,
335 });
336 continue;
337 }
338
339 let bytes = dir_size_bytes(&path);
340 candidates.push(Candidate {
341 job_id,
342 path,
343 status,
344 gc_ts,
345 bytes,
346 reasons: vec!["older_than"],
347 });
348 }
349
350 candidates.sort_by(|a, b| a.gc_ts.cmp(&b.gc_ts)); if let Some(max_jobs) = policy.max_jobs
353 && candidates.len() > max_jobs
354 {
355 let cut = candidates.len() - max_jobs;
357 for c in &mut candidates[..cut] {
358 c.reasons.push("max_jobs");
359 }
360 for c in &mut candidates[cut..] {
361 c.reasons.retain(|r| *r != "older_than");
362 }
363 }
364
365 if let Some(max_bytes) = policy.max_bytes {
366 let mut all_terminal_total = candidates.iter().map(|c| c.bytes).sum::<u64>();
367 if all_terminal_total > max_bytes {
368 for c in &mut candidates {
369 if all_terminal_total <= max_bytes {
370 break;
371 }
372 if !c.reasons.contains(&"max_bytes") {
373 c.reasons.push("max_bytes");
374 }
375 all_terminal_total = all_terminal_total.saturating_sub(c.bytes);
376 }
377 }
378 }
379
380 let mut selected = Vec::new();
381 for c in candidates {
382 if c.reasons.is_empty() {
383 skipped += 1;
384 out_of_scope += 1;
385 results.push(GcJobResult {
386 job_id: c.job_id,
387 state: c.status.as_str().to_string(),
388 action: "skipped".to_string(),
389 reason: "policy_not_matched".to_string(),
390 bytes: c.bytes,
391 });
392 continue;
393 }
394 selected.push(c);
395 }
396
397 let candidate_count = selected.len() as u64;
398 let mut deleted = 0u64;
399 let mut freed_bytes = 0u64;
400 let mut deletions = 0usize;
401
402 for c in selected {
403 if let Some(limit) = policy.delete_limit
404 && deletions >= limit
405 {
406 skipped += 1;
407 out_of_scope += 1;
408 results.push(GcJobResult {
409 job_id: c.job_id,
410 state: c.status.as_str().to_string(),
411 action: "skipped".to_string(),
412 reason: "delete_budget_exhausted".to_string(),
413 bytes: c.bytes,
414 });
415 continue;
416 }
417
418 let reason = c.reasons.join("+");
419
420 if policy.dry_run {
421 freed_bytes = freed_bytes.saturating_add(c.bytes);
422 results.push(GcJobResult {
423 job_id: c.job_id,
424 state: c.status.as_str().to_string(),
425 action: "would_delete".to_string(),
426 reason,
427 bytes: c.bytes,
428 });
429 continue;
430 }
431
432 match std::fs::remove_dir_all(&c.path) {
433 Ok(()) => {
434 if c.path.exists() {
435 skipped += 1;
436 failed += 1;
437 results.push(GcJobResult {
438 job_id: c.job_id,
439 state: c.status.as_str().to_string(),
440 action: "skipped".to_string(),
441 reason: "post_delete_check_failed".to_string(),
442 bytes: c.bytes,
443 });
444 } else {
445 deletions += 1;
446 deleted += 1;
447 freed_bytes = freed_bytes.saturating_add(c.bytes);
448 results.push(GcJobResult {
449 job_id: c.job_id,
450 state: c.status.as_str().to_string(),
451 action: "deleted".to_string(),
452 reason,
453 bytes: c.bytes,
454 });
455 }
456 }
457 Err(e) => {
458 skipped += 1;
459 failed += 1;
460 results.push(GcJobResult {
461 job_id: c.job_id,
462 state: c.status.as_str().to_string(),
463 action: "skipped".to_string(),
464 reason: format!("delete_failed: {e}"),
465 bytes: c.bytes,
466 });
467 }
468 }
469 }
470
471 info!(
472 mode = ?policy.mode,
473 deleted,
474 skipped,
475 out_of_scope,
476 failed,
477 freed_bytes,
478 scanned_dirs,
479 candidate_count,
480 "gc complete"
481 );
482
483 Ok(GcOutcome {
484 deleted,
485 skipped,
486 out_of_scope,
487 failed,
488 freed_bytes,
489 scanned_dirs,
490 candidate_count,
491 jobs: results,
492 })
493}
494
495pub fn parse_duration(s: &str) -> Option<u64> {
496 let s = s.trim();
497 if let Some(n) = s.strip_suffix('d') {
498 n.parse::<u64>().ok().map(|v| v * 86_400)
499 } else if let Some(n) = s.strip_suffix('h') {
500 n.parse::<u64>().ok().map(|v| v * 3_600)
501 } else if let Some(n) = s.strip_suffix('m') {
502 n.parse::<u64>().ok().map(|v| v * 60)
503 } else if let Some(n) = s.strip_suffix('s') {
504 n.parse::<u64>().ok()
505 } else {
506 s.parse::<u64>().ok()
507 }
508}
509
510fn is_older_than(ts: &str, cutoff: &str) -> bool {
511 let ts_prefix = &ts[..ts.len().min(19)];
512 let cutoff_prefix = &cutoff[..cutoff.len().min(19)];
513 ts_prefix < cutoff_prefix
514}
515
516pub fn dir_size_bytes(path: &Path) -> u64 {
517 let mut total = 0u64;
518 let Ok(entries) = std::fs::read_dir(path) else {
519 return 0;
520 };
521 for entry in entries.flatten() {
522 let p = entry.path();
523 if let Ok(meta) = p.metadata() {
524 if meta.is_file() {
525 total += meta.len();
526 } else if meta.is_dir() {
527 total += dir_size_bytes(&p);
528 }
529 }
530 }
531 total
532}
533
534fn format_rfc3339(secs: u64) -> String {
535 let mut s = secs;
536 let seconds = s % 60;
537 s /= 60;
538 let minutes = s % 60;
539 s /= 60;
540 let hours = s % 24;
541 s /= 24;
542
543 let mut days = s;
544 let mut year = 1970u64;
545 loop {
546 let days_in_year = if is_leap(year) { 366 } else { 365 };
547 if days < days_in_year {
548 break;
549 }
550 days -= days_in_year;
551 year += 1;
552 }
553
554 let leap = is_leap(year);
555 let month_days: [u64; 12] = [
556 31,
557 if leap { 29 } else { 28 },
558 31,
559 30,
560 31,
561 30,
562 31,
563 31,
564 30,
565 31,
566 30,
567 31,
568 ];
569 let mut month = 0usize;
570 for (i, &d) in month_days.iter().enumerate() {
571 if days < d {
572 month = i;
573 break;
574 }
575 days -= d;
576 }
577 let day = days + 1;
578
579 format!(
580 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
581 year,
582 month + 1,
583 day,
584 hours,
585 minutes,
586 seconds
587 )
588}
589
590fn is_leap(year: u64) -> bool {
591 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn parse_duration_days() {
600 assert_eq!(parse_duration("30d"), Some(30 * 86_400));
601 }
602
603 #[test]
604 fn parse_duration_invalid() {
605 assert!(parse_duration("abc").is_none());
606 }
607
608 #[test]
609 fn older_than_logic() {
610 assert!(is_older_than(
611 "2020-01-01T00:00:00Z",
612 "2024-01-01T00:00:00Z"
613 ));
614 assert!(!is_older_than(
615 "2024-01-01T00:00:00Z",
616 "2024-01-01T00:00:00Z"
617 ));
618 }
619}