//! Housekeeping that runs on its own (hub spec ยง17.2): the nightly //! garbage collection of every repository, one at a time under its //! write lock, so a push never races a repack. `oxygenhub gc` runs the //! same pass by hand. use crate::db; use crate::repos::RepoName; use crate::Shared; /// What one repository's gc did, for the log and `oxygenhub gc`. #[derive(Clone, Debug)] pub struct Done { pub repo: String, pub failed: bool, pub summary: String, } impl std::fmt::Display for Done { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}: {}", self.repo, self.summary) } } /// Garbage-collect every repository, in turn. A repository that fails is /// reported and the rest still run. pub async fn gc_all(app: &Shared) -> Vec { // Report files past retention first (#127), so this run prunes them. match crate::reports::expire(&app.db).await { Ok(0) => {} Ok(n) => tracing::info!("gc: {n} reports' files expired under retention"), Err(e) => tracing::warn!("gc: expiring report files: {e}"), } // Drops are repositories to keep tidy too (#279). let rows = match db::repos::list_with_drops(&app.db).await { Ok(rows) => rows, Err(e) => { tracing::warn!("gc: listing repositories: {e}"); return Vec::new(); } }; let mut done = Vec::with_capacity(rows.len()); for row in rows { let slug = format!("{}/{}", row.owner, row.name); let Some(name) = RepoName::new(&row.owner, &row.name) else { continue }; if !app.repos.exists(&name) { continue; } let label = slug.clone(); // Flow's artifacts and reports' files are named only in the // database: kept as roots, or gc would prune them after the grace // period (#126, #127). A listing that fails skips this repository // rather than risk them. let listed = match db::flow::artifact_blobs(&app.db, row.id).await { Ok(mut blobs) => crate::reports::file_blobs(&app.db, row.id).await.map(|files| { blobs.extend(files); blobs }), Err(e) => Err(e), }; let keep: Vec = match listed { Ok(blobs) => blobs.iter().filter_map(|b| oxy_core::hash::ObjectRef::from_hex(b)).collect(), Err(e) => { tracing::warn!("gc: {slug}: listing its artifacts: {e}; skipped"); continue; } }; let started = std::time::Instant::now(); let result = app .repos .write(&name, move |repo, _| Ok(repo.gc_keeping(false, &keep, &mut |phase| tracing::debug!("gc {label}: {phase}"))?)) .await; let d = match result { Ok(r) => Done { summary: format!( "{} reachable; pruned {} ({} bytes){}; packed {} into one pack in {:.1} s", r.reachable, r.pruned.objects, r.pruned.bytes, if r.pruned.deferred > 0 { format!(", {} deferred by the grace period", r.pruned.deferred) } else { String::new() }, r.packed.objects, started.elapsed().as_secs_f64() ), repo: slug, failed: false, }, Err(e) => Done { summary: format!("failed: {e}"), repo: slug, failed: true }, }; if d.failed { tracing::warn!("gc {}", d); } else { tracing::info!("gc {}", d); } done.push(d); } done } /// The day the nightly run last ran, kept in the data directory: a restart /// within `gc_hour` (a deploy) does not run it again. On 2026-10-17 one did, /// and metalgear2's requests waited 74 s behind its repack in GCS. fn last_run_path(app: &Shared) -> std::path::PathBuf { app.config.data.join("gc-last-day") } fn last_run(app: &Shared) -> Option { Some(std::fs::read_to_string(last_run_path(app)).ok()?.trim().to_string()) } fn record_run(app: &Shared, day: &str) { if let Err(e) = std::fs::write(last_run_path(app), day) { tracing::warn!("gc: recording the nightly run: {e}"); } } /// A day as the record keeps it: `2026-10-17`. fn day_text(day: time::Date) -> String { format!("{:04}-{:02}-{:02}", day.year(), u8::from(day.month()), day.day()) } /// Run [`gc_all`] once a day at `retention.gc_hour` UTC. pub async fn nightly(app: Shared) { let hour = app.config.retention.gc_hour; if hour >= 24 { tracing::info!("gc: the nightly run is off (retention.gc_hour = {hour})"); return; } let mut last_day = last_run(&app); loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; let now = time::OffsetDateTime::now_utc(); if u32::from(now.hour()) != hour { continue; } let day = day_text(now.date()); if last_day.as_deref() == Some(day.as_str()) { continue; } record_run(&app, &day); last_day = Some(day); match crate::db::sessions::sweep(&app.db).await { Ok(n) if n > 0 => tracing::info!("sessions: forgot {n} that had ended"), Ok(_) => {} Err(e) => tracing::warn!("sessions: sweep failed: {e}"), } let done = gc_all(&app).await; tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count()); } } #[cfg(test)] mod tests { #[test] fn a_day_is_written_as_the_record_keeps_it() { let day = time::Date::from_calendar_date(2026, time::Month::October, 7).unwrap(); assert_eq!(super::day_text(day), "2026-10-07"); } }