OXYGENHUB

The hub garbage-collects every repository nightly 8a3334b7a364

oxy gc runs for each repository in turn under its write lock at
retention.gc_hour UTC (default 3; 24 or more turns it off), logging
what each pruned and packed, and oxygenhub gc runs the same pass by
hand. The oxygen repository had 1.45 GB of loose objects on 2026-10-03.

Pins oxygenflow main, which pins oxygen main: the Store trait and
Strata, gc progress, and RemoteOptions.cache_dir (the two test
literals here name it).

closes #94
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 02:23 UTC
Signature
✔ signed by Andy McCall
Hash
8a3334b7a3647e0b0b00cd0848a00a70ed442487f8a27c1ee3e9fe48aed5d7df
Depends on
18e381ea88c2 348f8c4baccf 34beb1a7e376 380996db84e5 3f4b0ea6eabf 42fa95540468 93be9ce4653e d23464c0ac60
Depended on by
439ecd8708a1 166f6e9bf4f1 e57765dee61a 8a48dd830178 dbcb0b813caa c8684d44d549 0024ccb1b050 e8fa53694f68 d7d182467382 f677d6270298 20b8fe85888b a36bab6b9ffc 4597750166e7 4d2e44d79b03 b43a9ab0498f
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 2 tree operations, 8 files

Browse the tree at this change

added crates/hub/src/maintenance.rs +93 −0
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 +
/// Run [`gc_all`] once a day at `retention.gc_hour` UTC.
72 +
pub async fn nightly(app: Shared) {
73 +
    let hour = app.config.retention.gc_hour;
74 +
    if hour >= 24 {
75 +
        tracing::info!("gc: the nightly run is off (retention.gc_hour = {hour})");
76 +
        return;
77 +
    }
78 +
    let mut last_day = None;
79 +
    loop {
80 +
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
81 +
        let now = time::OffsetDateTime::now_utc();
82 +
        if u32::from(now.hour()) != hour {
83 +
            continue;
84 +
        }
85 +
        let day = now.date();
86 +
        if last_day == Some(day) {
87 +
            continue;
88 +
        }
89 +
        last_day = Some(day);
90 +
        let done = gc_all(&app).await;
91 +
        tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count());
92 +
    }
93 +
}
added crates/hub/tests/maintenance.rs +72 −0
1 +
//! The nightly garbage collection (hub spec §17.2), run by hand.
2 +
3 +
use oxy_core::repo::Repo;
4 +
use oxygenhub::config::Config;
5 +
use oxygenhub::db;
6 +
use oxygenhub::repos::RepoName;
7 +
use std::path::Path;
8 +
9 +
fn hermetic() {
10 +
    static ONCE: std::sync::Once = std::sync::Once::new();
11 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
12 +
    ONCE.call_once(|| {
13 +
        let d = tempfile::tempdir().unwrap();
14 +
        std::env::set_var("OXY_HOME", d.path());
15 +
        DIR.set(d).ok();
16 +
    });
17 +
}
18 +
19 +
fn write(root: &Path, rel: &str, content: &[u8]) {
20 +
    let p = root.join(rel);
21 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
22 +
    std::fs::write(p, content).unwrap();
23 +
}
24 +
25 +
#[tokio::test(flavor = "multi_thread")]
26 +
async fn every_repository_is_garbage_collected_in_turn() {
27 +
    hermetic();
28 +
    let dir = tempfile::tempdir().unwrap();
29 +
    let mut config = Config::load(dir.path()).unwrap();
30 +
    config.auth.push_tokens = vec!["site".into()];
31 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
32 +
    let port = listener.local_addr().unwrap().port();
33 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
34 +
    assert_eq!(config.retention.gc_hour, 3, "nightly at 03:00 UTC by default");
35 +
    let app = oxygenhub::build(config).await.unwrap();
36 +
    let router = oxygenhub::router(app.clone());
37 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
38 +
    db::accounts::create_account(&app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
39 +
    for name in ["one", "two"] {
40 +
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
41 +
        db::repos::create(&app.db, "andy", name, "", hub_api::Visibility::Public).await.unwrap();
42 +
    }
43 +
    // A gc folds what the pushes stored into one pack.
44 +
    let work = tempfile::tempdir().unwrap();
45 +
    let mut c = Repo::init(work.path()).unwrap();
46 +
    c.config.set("remote.origin.url", &format!("http://127.0.0.1:{port}/andy/one")).unwrap();
47 +
    c.config.set("remote.origin.token", "site").unwrap();
48 +
    c.save_config().unwrap();
49 +
    for i in 0..3 {
50 +
        write(work.path(), &format!("f{i}.txt"), format!("{i}\n").as_bytes());
51 +
        c.commit(&format!("c{i}"), &[]).unwrap();
52 +
    }
53 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
54 +
    let hosted = app.repos.dir(&RepoName::parse("andy/one").unwrap());
55 +
    let before = Repo::open(&hosted).unwrap();
56 +
    assert!(before.store().loose_count().unwrap() > 0, "the push left loose objects");
57 +
    drop(before);
58 +
59 +
    let done = oxygenhub::maintenance::gc_all(&app).await;
60 +
    assert_eq!(done.len(), 2, "{done:?}");
61 +
    assert!(done.iter().all(|d| !d.failed), "{done:?}");
62 +
    assert!(done[0].summary.contains("packed"), "{}", done[0]);
63 +
    let after = Repo::open(&hosted).unwrap();
64 +
    assert_eq!(after.store().loose_count().unwrap(), 0, "everything is in a pack");
65 +
    assert_eq!(after.store().pack_count(), 1);
66 +
    assert_eq!(after.bookmark_heads().unwrap(), c.bookmark_heads().unwrap(), "and the history is intact");
67 +
    // The clone after the gc sees the same repository.
68 +
    let again = tempfile::tempdir().unwrap();
69 +
    let d = Repo::clone(&format!("http://127.0.0.1:{port}/andy/one"), again.path()).unwrap();
70 +
    assert_eq!(std::fs::read(again.path().join("f2.txt")).unwrap(), b"2\n");
71 +
    let _ = d;
72 +
}
modified crates/hub/src/config.rs +4 −1
…
153 unchanged lines
154 154
pub struct Retention {
155 155
    pub trash_days: u32,
156 156
    pub webhook_log_days: u32,
157 +
    /// The UTC hour at which every repository is garbage collected
158 +
    /// (spec §17.2); 24 or more turns the nightly gc off.
159 +
    pub gc_hour: u32,
157 160
}
158 161
159 162
/// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`.
…
61 unchanged lines
221 224
222 225
impl Default for Retention {
223 226
    fn default() -> Self {
224 -
        Retention { trash_days: 30, webhook_log_days: 30 }
227 +
        Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 }
225 228
    }
226 229
}
227 230
…
140 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
13 unchanged lines
14 14
pub mod keys;
15 15
pub mod live;
16 16
pub mod mail;
17 +
pub mod maintenance;
17 18
pub mod mirror;
18 19
pub mod notify;
19 20
pub mod protocol;
…
47 unchanged lines
67 68
    tokio::spawn(hooks::worker(app.clone()));
68 69
    tokio::spawn(flow::reaper(app.clone()));
69 70
    tokio::spawn(flow::scheduler(app.clone()));
71 +
    tokio::spawn(maintenance::nightly(app.clone()));
70 72
    Ok(app)
71 73
}
72 74
…
41 unchanged lines
modified crates/hub/src/main.rs +14 −0
…
36 unchanged lines
37 37
    },
38 38
    /// Verify every repository's objects and signatures
39 39
    Check,
40 +
    /// Garbage-collect every repository now, as the nightly job does
41 +
    Gc,
40 42
}
41 43
42 44
#[derive(Subcommand)]
…
85 unchanged lines
128 130
            let app = oxygenhub::build(config).await?;
129 131
            check(&app).await
130 132
        }
133 +
        Cmd::Gc => {
134 +
            let config = Config::load(&cli.data)?;
135 +
            let app = oxygenhub::build(config).await?;
136 +
            let done = oxygenhub::maintenance::gc_all(&app).await;
137 +
            for d in &done {
138 +
                println!("{d}");
139 +
            }
140 +
            if done.iter().any(|d| d.failed) {
141 +
                anyhow::bail!("a repository could not be garbage collected");
142 +
            }
143 +
            Ok(())
144 +
        }
131 145
    }
132 146
}
133 147
…
150 unchanged lines
modified crates/hub/tests/flow.rs +2 −2
…
209 unchanged lines
210 210
    // The job token reads this repository over the wire, and nothing else.
211 211
    let token = a["token"].as_str().unwrap().to_string();
212 212
    assert!(token.starts_with("flowjob_"));
213 -
    let opts = RemoteOptions { token: Some(token.clone()), ca: None, repo_id: None };
213 +
    let opts = RemoteOptions { token: Some(token.clone()), ca: None, repo_id: None, cache_dir: None };
214 214
    open_remote(&url, &opts).unwrap().refs().unwrap();
215 215
    assert!(open_remote(&format!("{}/andy/secret", h.base), &opts).unwrap().refs().is_err(), "another repository");
216 -
    let bad = RemoteOptions { token: Some("flowjob_00".into()), ca: None, repo_id: None };
216 +
    let bad = RemoteOptions { token: Some("flowjob_00".into()), ca: None, repo_id: None, cache_dir: None };
217 217
    assert!(open_remote(&url, &bad).unwrap().refs().is_err(), "an unknown job token");
218 218
    // … and cannot push.
219 219
    c.config.set("remote.origin.token", &token).unwrap();
…
1414 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
600 unchanged lines
601 601
602 602
### 17.2 Storage
603 603
604 -
SQLite in WAL mode; migrations embedded and applied at start; `PRAGMA user_version` guards downgrades. Repositories are ordinary Oxygen repositories; `oxy gc` runs per repository as a nightly job with the retention the repository settings choose.
604 +
SQLite in WAL mode; migrations embedded and applied at start; `PRAGMA user_version` guards downgrades. Repositories are ordinary Oxygen repositories; `oxy gc` runs per repository as a nightly job, one repository at a time under its write lock, at `retention.gc_hour` UTC (default 3; 24 or more turns it off), and `oxygenhub gc` runs the same pass by hand. Each repository's run is logged with what it pruned and packed.
605 605
606 606
### 17.3 Command line
607 607
…
55 unchanged lines