OXYGENHUB

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