OXYGENHUB

Coverage / crates/hub/src/maintenance.rs 62.3%

66 of 106 lines run, 14 of 31 functions · run #519 on main · run missed

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 {
190×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200×
        write!(f, "{}: {}", self.repo, self.summary)
210×
    }
22
}
23
24
/// Garbage-collect every repository, in turn. A repository that fails is
25
/// reported and the rest still run.
263×
pub async fn gc_all(app: &Shared) -> Vec<Done> {
27
    // Report files past retention first (#127), so this run prunes them.
283×
    match crate::reports::expire(&app.db).await {
293×
        Ok(0) => {}
300×
        Ok(n) => tracing::info!("gc: {n} reports' files expired under retention"),
310×
        Err(e) => tracing::warn!("gc: expiring report files: {e}"),
32
    }
33
    // Drops are repositories to keep tidy too (#279).
343×
    let rows = match db::repos::list_with_drops(&app.db).await {
353×
        Ok(rows) => rows,
360×
        Err(e) => {
370×
            tracing::warn!("gc: listing repositories: {e}");
380×
            return Vec::new();
39
        }
40
    };
413×
    let mut done = Vec::with_capacity(rows.len());
424×
    for row in rows {
434×
        let slug = format!("{}/{}", row.owner, row.name);
444×
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
454×
        if !app.repos.exists(&name) {
460×
            continue;
474×
        }
484×
        let label = slug.clone();
49
        // Flow's artifacts and reports' files are named only in the
50
        // database: kept as roots, or gc would prune them after the grace
51
        // period (#126, #127). A listing that fails skips this repository
52
        // rather than risk them.
534×
        let listed = match db::flow::artifact_blobs(&app.db, row.id).await {
544×
            Ok(mut blobs) => crate::reports::file_blobs(&app.db, row.id).await.map(|files| {
554×
                blobs.extend(files);
564×
                blobs
574×
            }),
580×
            Err(e) => Err(e),
59
        };
604×
        let keep: Vec<oxy_core::hash::ObjectRef> = match listed {
615×
            Ok(blobs) => blobs.iter().filter_map(|b| oxy_core::hash::ObjectRef::from_hex(b)).collect(),
620×
            Err(e) => {
630×
                tracing::warn!("gc: {slug}: listing its artifacts: {e}; skipped");
640×
                continue;
65
            }
66
        };
674×
        let started = std::time::Instant::now();
684×
        let result = app
694×
            .repos
7020×
            .write(&name, move |repo, _| Ok(repo.gc_keeping(false, &keep, &mut |phase| tracing::debug!("gc {label}: {phase}"))?))
714×
            .await;
724×
        let d = match result {
734×
            Ok(r) => Done {
744×
                summary: format!(
75
                    "{} reachable; pruned {} ({} bytes){}; packed {} into one pack in {:.1} s",
76
                    r.reachable,
77
                    r.pruned.objects,
78
                    r.pruned.bytes,
794×
                    if r.pruned.deferred > 0 { format!(", {} deferred by the grace period", r.pruned.deferred) } else { String::new() },
80
                    r.packed.objects,
814×
                    started.elapsed().as_secs_f64()
82
                ),
834×
                repo: slug,
84
                failed: false,
85
            },
860×
            Err(e) => Done { summary: format!("failed: {e}"), repo: slug, failed: true },
87
        };
884×
        if d.failed {
890×
            tracing::warn!("gc {}", d);
90
        } else {
914×
            tracing::info!("gc {}", d);
92
        }
934×
        done.push(d);
94
    }
953×
    done
963×
}
97
98
/// The day the nightly run last ran, kept in the data directory: a restart
99
/// within `gc_hour` (a deploy) does not run it again. On 2026-10-17 one did,
100
/// and metalgear2's requests waited 74 s behind its repack in GCS.
101198×
fn last_run_path(app: &Shared) -> std::path::PathBuf {
102198×
    app.config.data.join("gc-last-day")
103198×
}
104
105198×
fn last_run(app: &Shared) -> Option<String> {
106198×
    Some(std::fs::read_to_string(last_run_path(app)).ok()?.trim().to_string())
107198×
}
108
1090×
fn record_run(app: &Shared, day: &str) {
1100×
    if let Err(e) = std::fs::write(last_run_path(app), day) {
1110×
        tracing::warn!("gc: recording the nightly run: {e}");
1120×
    }
1130×
}
114
115
/// A day as the record keeps it: `2026-10-17`.
1161×
fn day_text(day: time::Date) -> String {
1171×
    format!("{:04}-{:02}-{:02}", day.year(), u8::from(day.month()), day.day())
1181×
}
119
120
/// Run [`gc_all`] once a day at `retention.gc_hour` UTC.
121198×
pub async fn nightly(app: Shared) {
122198×
    let hour = app.config.retention.gc_hour;
123198×
    if hour >= 24 {
1240×
        tracing::info!("gc: the nightly run is off (retention.gc_hour = {hour})");
1250×
        return;
126198×
    }
127198×
    let mut last_day = last_run(&app);
128
    loop {
129198×
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1300×
        let now = time::OffsetDateTime::now_utc();
1310×
        if u32::from(now.hour()) != hour {
1320×
            continue;
1330×
        }
1340×
        let day = day_text(now.date());
1350×
        if last_day.as_deref() == Some(day.as_str()) {
1360×
            continue;
1370×
        }
1380×
        record_run(&app, &day);
1390×
        last_day = Some(day);
1400×
        match crate::db::sessions::sweep(&app.db).await {
1410×
            Ok(n) if n > 0 => tracing::info!("sessions: forgot {n} that had ended"),
1420×
            Ok(_) => {}
1430×
            Err(e) => tracing::warn!("sessions: sweep failed: {e}"),
144
        }
1450×
        let done = gc_all(&app).await;
1460×
        tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count());
147
    }
1480×
}
149
150
/// Each hour, archive the cards of columns that archive after some days
151
/// once they have been there longer (#293).
152198×
pub async fn archiver(app: Shared) {
153
    loop {
154198×
        match crate::db::projects::archive_due(&app.db, crate::db::now_ms()).await {
155197×
            Ok(n) if n > 0 => tracing::info!("projects: archived {n} card(s) their columns keep no longer"),
156197×
            Ok(_) => {}
1570×
            Err(e) => tracing::warn!("projects: archiving failed: {e}"),
158
        }
159197×
        tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
160
    }
161
}
162
163
#[cfg(test)]
164
mod tests {
165
    #[test]
1661×
    fn a_day_is_written_as_the_record_keeps_it() {
1671×
        let day = time::Date::from_calendar_date(2026, time::Month::October, 7).unwrap();
1681×
        assert_eq!(super::day_text(day), "2026-10-07");
1691×
    }
170
}