Requirements learned from each job's past runs 44a3d2ebaddd
The hub keeps what each job took, as its runner's finish reports (peak disk and memory; migration 0054), and gives a new job of the same repository and name a disk need of the most its last five runs took, plus a quarter and 64 MiB. A runner is offered it only when its claim reports that much free disk, and the waiting reason says so: 'has 8.8 GiB free; the job needs ~9.8 GiB (its recent runs took up to 7.8 GiB)'. Bumps deps/oxygenflow for the finish's peaks. refs andymccall/oxygenflow#41
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 22:17 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
44a3d2ebaddd0f8771fd041d870e6491c22f3b820eb480a5ad658ca6c0788bcd- Depends on
- 2fb70d019851 380996db84e5 42fa95540468 4d2e44d79b03 76bc5a058351 92a797b20912 93be9ce4653e b0612e7dce30 c889911bec12 cc19697f8871 d23464c0ac60 d7ce41acf22c
- Depended on by
- 91c306aa4706 0468394734e9 951aed50c2f5 5588bb3631d2 f0f6c2d96b1c 5d90e0e045e2 417184c73f4e a46d15e72101 501d8b268dd4
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 21 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/migrations/0054_job_needs.sql +6 −0
| 1 | + | -- Requirements learned from past runs (oxygenflow#41): what each job took, |
|
| 2 | + | -- as its runner reported, and the disk a new job needs from the most its |
|
| 3 | + | -- recent runs of the same repository and name took, plus headroom. |
|
| 4 | + | ALTER TABLE flow_jobs ADD COLUMN peak_disk_mib INTEGER; |
|
| 5 | + | ALTER TABLE flow_jobs ADD COLUMN peak_memory_mib INTEGER; |
|
| 6 | + | ALTER TABLE flow_jobs ADD COLUMN need_disk_mib INTEGER; |
modified crates/hub/src/db/flow.rs +57 −2
| … | 89 unchanged lines |
||
| 90 | 90 | /// Why a queued job no runner can take waits (spec §11.4), from the |
|
| 91 | 91 | /// last placement pass; empty when it is only waiting its turn. |
|
| 92 | 92 | pub waiting: String, |
|
| 93 | + | /// What it took, as its runner reported (oxygenflow#41), in MiB. |
|
| 94 | + | pub peak_disk_mib: Option<i64>, |
|
| 95 | + | pub peak_memory_mib: Option<i64>, |
|
| 96 | + | /// The free disk it needs, learned from past runs of the same job. |
|
| 97 | + | pub need_disk_mib: Option<i64>, |
|
| 93 | 98 | } |
|
| 94 | 99 | ||
| 95 | 100 | impl Job { |
|
| … | 22 unchanged lines |
||
| 118 | 123 | pub spec: String, |
|
| 119 | 124 | pub sandbox: String, |
|
| 120 | 125 | pub runtime: String, |
|
| 126 | + | /// The free disk it needs, from [`learned_disk_need`]. |
|
| 127 | + | pub need_disk_mib: Option<i64>, |
|
| 121 | 128 | } |
|
| 122 | 129 | ||
| 130 | + | /// Runs of a job whose peaks set its next need. |
|
| 131 | + | const NEED_FROM_RUNS: i64 = 5; |
|
| 132 | + | ||
| 133 | + | /// The free disk a new run of job `name` in repository `repo_id` needs |
|
| 134 | + | /// (oxygenflow#41): the most its last runs took, plus a quarter; `None` |
|
| 135 | + | /// for a job that has not run here yet. |
|
| 136 | + | pub async fn learned_disk_need(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Option<i64>> { |
|
| 137 | + | let peak: Option<i64> = sqlx::query_scalar( |
|
| 138 | + | "SELECT MAX(peak_disk_mib) FROM (SELECT j.peak_disk_mib FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 139 | + | WHERE r.repo_id = ? AND j.name = ? AND j.peak_disk_mib IS NOT NULL ORDER BY j.id DESC LIMIT ?)", |
|
| 140 | + | ) |
|
| 141 | + | .bind(repo_id) |
|
| 142 | + | .bind(name) |
|
| 143 | + | .bind(NEED_FROM_RUNS) |
|
| 144 | + | .fetch_one(pool) |
|
| 145 | + | .await?; |
|
| 146 | + | Ok(peak.map(|p| p + p / 4 + 64)) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | /// Keep what a job took, as its runner reported. |
|
| 150 | + | pub async fn set_peaks(pool: &Pool, job_id: i64, disk_mib: Option<u64>, memory_mib: Option<u64>) -> sqlx::Result<()> { |
|
| 151 | + | sqlx::query("UPDATE flow_jobs SET peak_disk_mib = ?, peak_memory_mib = ? WHERE id = ?") |
|
| 152 | + | .bind(disk_mib.map(|v| v.min(i64::MAX as u64) as i64)) |
|
| 153 | + | .bind(memory_mib.map(|v| v.min(i64::MAX as u64) as i64)) |
|
| 154 | + | .bind(job_id) |
|
| 155 | + | .execute(pool) |
|
| 156 | + | .await?; |
|
| 157 | + | Ok(()) |
|
| 158 | + | } |
|
| 159 | + | ||
| 123 | 160 | pub struct NewRun<'a> { |
|
| 124 | 161 | pub repo_id: i64, |
|
| 125 | 162 | pub bookmark: &'a str, |
|
| … | 31 unchanged lines |
||
| 157 | 194 | .await?; |
|
| 158 | 195 | for j in jobs { |
|
| 159 | 196 | sqlx::query( |
|
| 160 | - | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')", |
|
| 197 | + | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?)", |
|
| 161 | 198 | ) |
|
| 162 | 199 | .bind(id) |
|
| 163 | 200 | .bind(&j.name) |
|
| … | 2 unchanged lines |
||
| 166 | 203 | .bind(&j.spec) |
|
| 167 | 204 | .bind(&j.sandbox) |
|
| 168 | 205 | .bind(&j.runtime) |
|
| 206 | + | .bind(j.need_disk_mib) |
|
| 169 | 207 | .execute(&mut *tx) |
|
| 170 | 208 | .await?; |
|
| 171 | 209 | } |
|
| … | 166 unchanged lines |
||
| 338 | 376 | /// Images the hub holds that this runner would fetch when it lacks |
|
| 339 | 377 | /// them (oxygenflow#39); empty for a runner that does not fetch. |
|
| 340 | 378 | pub fetchable: std::collections::HashSet<String>, |
|
| 379 | + | /// Its free disk now, from its claim's facts (oxygenflow#40); `None` |
|
| 380 | + | /// when it does not say. |
|
| 381 | + | pub free_disk_mib: Option<u64>, |
|
| 382 | + | } |
|
| 383 | + | ||
| 384 | + | impl Room { |
|
| 385 | + | /// Whether a job needing `need` MiB of disk fits in its free disk. |
|
| 386 | + | pub fn disk_fits(&self, need: Option<i64>) -> bool { |
|
| 387 | + | match (need, self.free_disk_mib) { |
|
| 388 | + | (Some(n), Some(free)) => n.max(0) as u64 <= free, |
|
| 389 | + | _ => true, |
|
| 390 | + | } |
|
| 391 | + | } |
|
| 341 | 392 | } |
|
| 342 | 393 | ||
| 343 | 394 | impl Room { |
|
| … | 67 unchanged lines |
||
| 411 | 462 | .fetch_all(&mut *tx) |
|
| 412 | 463 | .await?; |
|
| 413 | 464 | for j in queued { |
|
| 414 | - | if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) || room.declined.contains(&j.id) { |
|
| 465 | + | if !runtimes.iter().any(|r| r == &j.runtime) |
|
| 466 | + | || !room.fits(&j.sandbox) |
|
| 467 | + | || !room.disk_fits(j.need_disk_mib) |
|
| 468 | + | || room.declined.contains(&j.id) |
|
| 469 | + | { |
|
| 415 | 470 | continue; |
|
| 416 | 471 | } |
|
| 417 | 472 | // Every label the job and its sandbox ask for (`runs-on`). |
|
| … | 468 unchanged lines |
modified crates/hub/src/flow.rs +28 −1
| … | 352 unchanged lines |
||
| 353 | 353 | } |
|
| 354 | 354 | keep.extend(needed); |
|
| 355 | 355 | } |
|
| 356 | - | let jobs: Vec<fdb::NewJob> = p |
|
| 356 | + | let mut jobs: Vec<fdb::NewJob> = p |
|
| 357 | 357 | .order() |
|
| 358 | 358 | .into_iter() |
|
| 359 | 359 | .filter(|j| keep.contains(&j.name)) |
|
| … | 6 unchanged lines |
||
| 366 | 366 | spec: serde_json::to_string(j).unwrap_or_default(), |
|
| 367 | 367 | sandbox: serde_json::to_string(sb).unwrap_or_default(), |
|
| 368 | 368 | runtime: sb.runtime.as_str().into(), |
|
| 369 | + | need_disk_mib: None, |
|
| 369 | 370 | } |
|
| 370 | 371 | }) |
|
| 371 | 372 | .collect(); |
|
| 373 | + | // What each job needs, learned from its past runs (oxygenflow#41). |
|
| 374 | + | for j in &mut jobs { |
|
| 375 | + | j.need_disk_mib = fdb::learned_disk_need(&app.db, row.id, &j.name).await.map_err(|e| e.to_string())?; |
|
| 376 | + | } |
|
| 372 | 377 | if jobs.is_empty() { |
|
| 373 | 378 | // Nothing this event starts (or this push touched). |
|
| 374 | 379 | return Ok(None); |
|
| … | 123 unchanged lines |
||
| 498 | 503 | arch, |
|
| 499 | 504 | image_archs: fdb::image_archs(&app.db).await?, |
|
| 500 | 505 | fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() }, |
|
| 506 | + | free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib), |
|
| 501 | 507 | }; |
|
| 502 | 508 | // The labels it reports now, or those it registered with, and those |
|
| 503 | 509 | // an administrator gave it (#98). |
|
| … | 221 unchanged lines |
||
| 725 | 731 | } |
|
| 726 | 732 | if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) { |
|
| 727 | 733 | return Some(format!("has {c} CPUs; the job needs {}", need("cpus"))); |
|
| 734 | + | } |
|
| 735 | + | // The disk it learned it needs (oxygenflow#41). |
|
| 736 | + | if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) { |
|
| 737 | + | if free < n { |
|
| 738 | + | return Some(format!( |
|
| 739 | + | "has {} free; the job needs ~{} (its recent runs took up to {})", |
|
| 740 | + | mib(free), |
|
| 741 | + | mib(n), |
|
| 742 | + | mib((n - 64) * 4 / 5) |
|
| 743 | + | )); |
|
| 744 | + | } |
|
| 728 | 745 | } |
|
| 729 | 746 | if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) { |
|
| 730 | 747 | return Some(format!("declined it: {}", d.reason)); |
|
| … | 18 unchanged lines |
||
| 749 | 766 | text.push_str(&format!("; and {more} more")); |
|
| 750 | 767 | } |
|
| 751 | 768 | text |
|
| 769 | + | } |
|
| 770 | + | ||
| 771 | + | /// A size in MiB as people say it: 9.4 GiB, 740 MiB. |
|
| 772 | + | fn mib(m: i64) -> String { |
|
| 773 | + | if m >= 1024 { |
|
| 774 | + | format!("{:.1} GiB", m as f64 / 1024.0) |
|
| 775 | + | } else { |
|
| 776 | + | format!("{m} MiB") |
|
| 777 | + | } |
|
| 752 | 778 | } |
|
| 753 | 779 | ||
| 754 | 780 | /// One placement pass: say why each ready queued job waits. |
|
| … | 559 unchanged lines |
||
| 1314 | 1340 | None => (String::new(), String::new()), |
|
| 1315 | 1341 | }; |
|
| 1316 | 1342 | let error = f.error.clone().unwrap_or_default(); |
|
| 1343 | + | fdb::set_peaks(&app.db, job.id, f.peak_disk_mib, f.peak_memory_mib).await?; |
|
| 1317 | 1344 | let changed = fdb::finish( |
|
| 1318 | 1345 | &app.db, |
|
| 1319 | 1346 | job.id, |
|
| … | 413 unchanged lines |
modified crates/hub/tests/flow.rs +45 −0
| … | 2741 unchanged lines |
||
| 2742 | 2742 | let row = db::runners::list(&h.app.db).await.unwrap().remove(0); |
|
| 2743 | 2743 | assert_eq!(row.facts_line(), "speed 100 (class 3) · load 1.12 · 41 GiB free"); |
|
| 2744 | 2744 | } |
|
| 2745 | + | ||
| 2746 | + | /// oxygenflow#41: a job's peaks set what its next run needs; a runner |
|
| 2747 | + | /// without that much free disk is not offered it, and the reason says so. |
|
| 2748 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2749 | + | async fn a_jobs_next_run_needs_the_disk_its_last_took() { |
|
| 2750 | + | let h = hub().await; |
|
| 2751 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 2752 | + | create(&h.app, "andy", "demo").await; |
|
| 2753 | + | let url = format!("{}/andy/demo", h.base); |
|
| 2754 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2755 | + | let c = client(&url, work.path()); |
|
| 2756 | + | write( |
|
| 2757 | + | work.path(), |
|
| 2758 | + | ".flow/pipeline.kdl", |
|
| 2759 | + | &format!("schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n sandbox \"alpine\"\n exec \"make\"\n}}\n"), |
|
| 2760 | + | ); |
|
| 2761 | + | c.commit("Build", &[]).unwrap(); |
|
| 2762 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2763 | + | runs_of(&h.app, "andy", "demo").await; |
|
| 2764 | + | let claim_with = |free: u64| format!(r#"{{"runner":"test-1","runtimes":["microvm"],"facts":{{"free_disk_mib":{free}}}}}"#); |
|
| 2765 | + | ||
| 2766 | + | // The first run has nothing to learn from: any runner takes it. |
|
| 2767 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(claim_with(500))).await; |
|
| 2768 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 2769 | + | let id = serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap(); |
|
| 2770 | + | let done = serde_json::json!({ "exit_code": 0, "duration_ms": 10, "peak_disk_mib": 8000, "peak_memory_mib": 900 }); |
|
| 2771 | + | assert_eq!(finish(&h.app, id, done).await, StatusCode::NO_CONTENT); |
|
| 2772 | + | let job = db::flow::job(&h.app.db, id).await.unwrap().unwrap(); |
|
| 2773 | + | assert_eq!((job.peak_disk_mib, job.peak_memory_mib), (Some(8000), Some(900))); |
|
| 2774 | + | ||
| 2775 | + | // The next run needs 8000 MiB and a quarter more. |
|
| 2776 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 2777 | + | assert_eq!(st, StatusCode::CREATED); |
|
| 2778 | + | let repo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 2779 | + | let run2 = db::flow::run(&h.app.db, repo.id, 2).await.unwrap().unwrap(); |
|
| 2780 | + | let next = db::flow::jobs(&h.app.db, run2.id).await.unwrap().remove(0); |
|
| 2781 | + | assert_eq!(next.need_disk_mib, Some(8000 + 2000 + 64)); |
|
| 2782 | + | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(claim_with(9000))).await; |
|
| 2783 | + | assert_eq!(st, StatusCode::NO_CONTENT, "9000 MiB free is not enough"); |
|
| 2784 | + | oxygenhub::flow::explain_waits(&h.app).await.unwrap(); |
|
| 2785 | + | let waiting = db::flow::job(&h.app.db, next.id).await.unwrap().unwrap().waiting; |
|
| 2786 | + | assert!(waiting.contains("8.8 GiB free; the job needs ~9.8 GiB"), "{waiting}"); |
|
| 2787 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(claim_with(20_000))).await; |
|
| 2788 | + | assert_eq!(st, StatusCode::OK, "a roomier runner takes it: {body}"); |
|
| 2789 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −1
| … | 514 unchanged lines |
||
| 515 | 515 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 516 | 516 | ||
| 517 | 517 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 518 | - | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 518 | + | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 519 | 519 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 520 | 520 | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 521 | 521 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| … | 231 unchanged lines |