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 |