OXYGENHUB
# dbcb0b813caa oxygenhub / crates / hub / src / maintenance.rs
4.2 KiB · Rust · last changed The nightly gc remembers its day across restarts 19 days ago source blame history raw
1
//! Housekeeping that runs on its own (hub spec §17.2): the nightly
2
//! garbage collection of every repository, one at a time under its
3
//! write lock, so a push never races a repack. `oxygenhub gc` runs the
4
//! same pass by hand.
5
6
use crate::db;
7
use crate::repos::RepoName;
8
use crate::Shared;
9
10
/// What one repository's gc did, for the log and `oxygenhub gc`.
11
#[derive(Clone, Debug)]
12
pub struct Done {
13
    pub repo: String,
14
    pub failed: bool,
15
    pub summary: String,
16
}
17
18
impl std::fmt::Display for Done {
19
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20
        write!(f, "{}: {}", self.repo, self.summary)
21
    }
22
}
23
24
/// Garbage-collect every repository, in turn. A repository that fails is
25
/// reported and the rest still run.
26
pub async fn gc_all(app: &Shared) -> Vec<Done> {
27
    let rows = match db::repos::list(&app.db).await {
28
        Ok(rows) => rows,
29
        Err(e) => {
30
            tracing::warn!("gc: listing repositories: {e}");
31
            return Vec::new();
32
        }
33
    };
34
    let mut done = Vec::with_capacity(rows.len());
35
    for row in rows {
36
        let slug = format!("{}/{}", row.owner, row.name);
37
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
38
        if !app.repos.exists(&name) {
39
            continue;
40
        }
41
        let label = slug.clone();
42
        let started = std::time::Instant::now();
43
        let result =
44
            app.repos.write(&name, move |repo, _| Ok(repo.gc_with(false, &mut |phase| tracing::debug!("gc {label}: {phase}"))?)).await;
45
        let d = match result {
46
            Ok(r) => Done {
47
                summary: format!(
48
                    "{} reachable; pruned {} ({} bytes){}; packed {} into one pack in {:.1} s",
49
                    r.reachable,
50
                    r.pruned.objects,
51
                    r.pruned.bytes,
52
                    if r.pruned.deferred > 0 { format!(", {} deferred by the grace period", r.pruned.deferred) } else { String::new() },
53
                    r.packed.objects,
54
                    started.elapsed().as_secs_f64()
55
                ),
56
                repo: slug,
57
                failed: false,
58
            },
59
            Err(e) => Done { summary: format!("failed: {e}"), repo: slug, failed: true },
60
        };
61
        if d.failed {
62
            tracing::warn!("gc {}", d);
63
        } else {
64
            tracing::info!("gc {}", d);
65
        }
66
        done.push(d);
67
    }
68
    done
69
}
70
71
/// The day the nightly run last ran, kept in the data directory: a restart
72
/// within `gc_hour` (a deploy) does not run it again. On 2026-10-17 one did,
73
/// and metalgear2's requests waited 74 s behind its repack in GCS.
74
fn last_run_path(app: &Shared) -> std::path::PathBuf {
75
    app.config.data.join("gc-last-day")
76
}
77
78
fn last_run(app: &Shared) -> Option<String> {
79
    Some(std::fs::read_to_string(last_run_path(app)).ok()?.trim().to_string())
80
}
81
82
fn record_run(app: &Shared, day: &str) {
83
    if let Err(e) = std::fs::write(last_run_path(app), day) {
84
        tracing::warn!("gc: recording the nightly run: {e}");
85
    }
86
}
87
88
/// A day as the record keeps it: `2026-10-17`.
89
fn day_text(day: time::Date) -> String {
90
    format!("{:04}-{:02}-{:02}", day.year(), u8::from(day.month()), day.day())
91
}
92
93
/// Run [`gc_all`] once a day at `retention.gc_hour` UTC.
94
pub async fn nightly(app: Shared) {
95
    let hour = app.config.retention.gc_hour;
96
    if hour >= 24 {
97
        tracing::info!("gc: the nightly run is off (retention.gc_hour = {hour})");
98
        return;
99
    }
100
    let mut last_day = last_run(&app);
101
    loop {
102
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
103
        let now = time::OffsetDateTime::now_utc();
104
        if u32::from(now.hour()) != hour {
105
            continue;
106
        }
107
        let day = day_text(now.date());
108
        if last_day.as_deref() == Some(day.as_str()) {
109
            continue;
110
        }
111
        record_run(&app, &day);
112
        last_day = Some(day);
113
        let done = gc_all(&app).await;
114
        tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count());
115
    }
116
}
117
118
#[cfg(test)]
119
mod tests {
120
    #[test]
121
    fn a_day_is_written_as_the_record_keeps_it() {
122
        let day = time::Date::from_calendar_date(2026, time::Month::October, 7).unwrap();
123
        assert_eq!(super::day_text(day), "2026-10-07");
124
    }
125
}