//! 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 { let rows = match db::repos::list(&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(); let started = std::time::Instant::now(); let result = app.repos.write(&name, move |repo, _| Ok(repo.gc_with(false, &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); 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"); } }