OXYGENHUB

A pipeline may lower its jobs' priority, never raise it 53a0fb2e4ac6

Andy's decision for #244: priority "low" or "normal" in pipeline.kdl
(a job's, or the file's for every job) is kept on the job (migration 0074,
both engines) and its rank is the lower of its own and its run's; an
expedited run stays urgent. The queue says 'pipeline: low'. Pins
oxygenflow 2e017f43 (the parser). Spec 13.2, also no longer saying the
organisation level has no form. Test on SQLite and Postgres: a
pipeline-low repository set high waits for another's jobs, and expediting
it still wins.

Closes #244
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-17 13:46 UTC
Signature
✔ signed by Claude Code
Hash
53a0fb2e4ac6ce0fd5f7c4df7bb034fb9ba0c67af0d337454077b5afd2e9971d
Depends on
107eaba52c9d 380996db84e5 3b1bc27fe805 42fa95540468 4a4400c5812d 4cc40ccc6034 5d90e0e045e2 9008bf6c2b8c 93be9ce4653e b644e1757711 d23464c0ac60
Depended on by
e97500b70f9a 026a76d251ce
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
12 atoms, 2 tree operations, 6 files

Browse the tree at this change

added crates/hub/migrations-pg/0074_flow_job_priority.sql +4 −0
1 +
-- A job's own priority from its pipeline (oxygenhub#244): `low` or
2 +
-- `normal` to stand below its repository's level, or '' for none. A
3 +
-- pipeline may only lower its jobs, never raise them.
4 +
ALTER TABLE flow_jobs ADD COLUMN priority TEXT NOT NULL DEFAULT '';
added crates/hub/migrations/0074_flow_job_priority.sql +4 −0
1 +
-- A job's own priority from its pipeline (oxygenhub#244): `low` or
2 +
-- `normal` to stand below its repository's level, or '' for none. A
3 +
-- pipeline may only lower its jobs, never raise them.
4 +
ALTER TABLE flow_jobs ADD COLUMN priority TEXT NOT NULL DEFAULT '';
modified crates/hub/src/db/flow.rs +45 −11
…
33 unchanged lines
34 34
/// The priority levels, most urgent first (oxygenhub#244).
35 35
pub const PRIORITIES: [&str; 4] = ["urgent", "high", "normal", "low"];
36 36
37 -
/// SQL for a queued job's level as a rank (0 urgent … 3 low): the run's
38 -
/// own (expedited), else its repository's, its organisation's, the site's
39 -
/// (`settings.flow_priority`), else normal. Needs `r` (the run) and `rep`
40 -
/// (its repository) in the query.
41 -
pub const PRIORITY_RANK: &str = "CASE COALESCE(NULLIF(r.priority, ''), NULLIF(rep.flow_priority, ''), \
42 -
       NULLIF((SELECT o.flow_priority FROM organisations o WHERE o.name = rep.owner), ''), \
43 -
       NULLIF((SELECT s.value FROM settings s WHERE s.key = 'flow_priority'), ''), 'normal') \
44 -
     WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'low' THEN 3 ELSE 2 END";
37 +
/// SQL for the level a run's jobs get from where they are, as a rank
38 +
/// (0 urgent … 3 low): the run's own (expedited), else its repository's,
39 +
/// its organisation's, the site's (`settings.flow_priority`), else normal.
40 +
macro_rules! level_rank {
41 +
    () => {
42 +
        "CASE COALESCE(NULLIF(r.priority, ''), NULLIF(rep.flow_priority, ''), \
43 +
           NULLIF((SELECT o.flow_priority FROM organisations o WHERE o.name = rep.owner), ''), \
44 +
           NULLIF((SELECT s.value FROM settings s WHERE s.key = 'flow_priority'), ''), 'normal') \
45 +
         WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'low' THEN 3 ELSE 2 END"
46 +
    };
47 +
}
48 +
49 +
/// SQL for a queued job's level as a rank (0 urgent … 3 low): the level
50 +
/// its run gets from where it is, lowered by the job's own priority from
51 +
/// its pipeline (`low`, `normal`), never raised by it; an expedited run is
52 +
/// urgent whatever its jobs say (oxygenhub#244). Needs `j` (the job), `r`
53 +
/// (its run) and `rep` (its repository) in the query.
54 +
pub const PRIORITY_RANK: &str = concat!(
55 +
    "CASE WHEN r.priority <> '' THEN ",
56 +
    level_rank!(),
57 +
    " WHEN j.priority = 'low' THEN 3 WHEN j.priority = 'normal' AND (",
58 +
    level_rank!(),
59 +
    ") < 2 THEN 2 ELSE ",
60 +
    level_rank!(),
61 +
    " END"
62 +
);
63 +
64 +
/// SQL for why a job stands where it does, beside [`PRIORITY_RANK`]: an
65 +
/// expedited run's note, else the pipeline's word for a job it lowered.
66 +
pub const PRIORITY_NOTE: &str =
67 +
    "CASE WHEN r.priority <> '' THEN r.priority_note WHEN j.priority <> '' THEN 'pipeline: ' || j.priority ELSE r.priority_note END";
45 68
46 69
/// Raise run `run_id` to urgent, saying who and why; a run already ended
47 70
/// is left alone. Whether it changed.
…
193 unchanged lines
241 264
    .await?;
242 265
    for j in jobs {
243 266
        sqlx::query(
244 -
            "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment) \
245 -
             VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10)",
267 +
            "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment, priority) \
268 +
             VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11)",
246 269
        )
247 270
        .bind(id)
248 271
        .bind(&j.name)
…
5 unchanged lines
254 277
        .bind(j.need_disk_mib)
255 278
        .bind(if needs_approval(&j.spec) { "waiting" } else { "" })
256 279
        .bind(environment_of(&j.spec))
280 +
        .bind(priority_of(&j.spec))
257 281
        .execute(&mut *tx)
258 282
        .await?;
259 283
    }
…
4 unchanged lines
264 288
/// The environment a compiled job deploys to, or empty (oxygenflow#59).
265 289
fn environment_of(spec: &str) -> String {
266 290
    serde_json::from_str::<serde_json::Value>(spec).ok().and_then(|v| v["environment"].as_str().map(String::from)).unwrap_or_default()
291 +
}
292 +
293 +
/// A compiled job's own priority (`low`, `normal`), or empty; anything
294 +
/// else is not one a pipeline may give (oxygenhub#244).
295 +
fn priority_of(spec: &str) -> String {
296 +
    serde_json::from_str::<serde_json::Value>(spec)
297 +
        .ok()
298 +
        .and_then(|v| v["priority"].as_str().map(String::from))
299 +
        .filter(|p| p == "low" || p == "normal")
300 +
        .unwrap_or_default()
267 301
}
268 302
269 303
/// Whether a compiled job asks for approval (oxygenflow#57).
…
78 unchanged lines
348 382
pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> {
349 383
    sqlx::query_as(&format!(
350 384
        "SELECT j.id AS job_id, j.run_id, r.repo_id, rep.owner, rep.name, rep.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox, j.started_ms, \
351 -
           CAST({PRIORITY_RANK} AS BIGINT) AS priority_rank, r.priority_note \
385 +
           CAST({PRIORITY_RANK} AS BIGINT) AS priority_rank, {PRIORITY_NOTE} AS priority_note \
352 386
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \
353 387
         WHERE j.status IN ('queued', 'running') AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \
354 388
         ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, \
…
844 unchanged lines
modified crates/hub/tests/flow.rs +36 −0
…
4628 unchanged lines
4629 4629
    assert_eq!(order, ["andy/busy", "andy/quiet", "andy/busy"]);
4630 4630
}
4631 4631
4632 +
/// A pipeline lowers its jobs, never raises them (oxygenhub#244): a
4633 +
/// repository whose pipeline says `priority "low"` waits for another's
4634 +
/// everyday jobs, even set high itself; the queue says why; an expedited
4635 +
/// run is still urgent.
4636 +
#[tokio::test(flavor = "multi_thread")]
4637 +
async fn a_pipeline_lowers_its_jobs_and_expediting_still_wins() {
4638 +
    let h = hub().await;
4639 +
    for (name, extra) in [("nightly", "priority \"low\"\n"), ("everyday", "")] {
4640 +
        create(&h.app, "andy", name).await;
4641 +
        let work = tempfile::tempdir().unwrap();
4642 +
        let c = client(&format!("{}/andy/{name}", h.base), work.path());
4643 +
        write(work.path(), ".flow/pipeline.kdl", &pipeline(extra));
4644 +
        write(work.path(), "test.sh", "echo ok\n");
4645 +
        c.snapshot("test").unwrap();
4646 +
        c.commit("Add a pipeline", &[]).unwrap();
4647 +
        assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
4648 +
        assert_eq!(runs_of(&h.app, "andy", name).await.len(), 1);
4649 +
    }
4650 +
    let nightly = db::repos::get(&h.app.db, "andy", "nightly").await.unwrap().unwrap();
4651 +
    db::repos::set_flow_priority(&h.app.db, nightly.id, "high").await.unwrap();
4652 +
    let q = db::flow::queue(&h.app.db).await.unwrap();
4653 +
    assert!(
4654 +
        q.iter().filter(|e| e.name == "nightly").all(|e| e.priority() == "low" && e.priority_note == "pipeline: low"),
4655 +
        "{:?}",
4656 +
        q.iter().map(|e| (&e.name, e.priority(), &e.priority_note)).collect::<Vec<_>>()
4657 +
    );
4658 +
    let repo_of = |c: serde_json::Value| c["repo"].as_str().unwrap().to_string();
4659 +
    // nightly pushed first and its repository is high, but its pipeline
4660 +
    // says low: everyday's jobs go first.
4661 +
    assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/everyday");
4662 +
    assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/everyday");
4663 +
    let run = &runs_of(&h.app, "andy", "nightly").await[0];
4664 +
    assert!(db::flow::expedite(&h.app.db, run.id, "expedited by andy: needed now").await.unwrap());
4665 +
    assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/nightly", "an expedited run is urgent whatever its jobs say");
4666 +
}
4667 +
4632 4668
/// Priority comes before taking turns (oxygenhub#244): a low repository's
4633 4669
/// jobs wait for a normal one's, and an expedited run goes first of all.
4634 4670
#[tokio::test(flavor = "multi_thread")]
…
34 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
481 unchanged lines
482 482
483 483
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) `job_timeout_minutes` (60) and `drain_after_errors` (5, 0 for never: see Alerts).
484 484
- **Alerts** (oxygenflow#30). A run that ends failed tells its pusher and the repository's watchers, in their inboxes and by email as each chose (`flow.failed`, from the `oxygenflow` account, which cannot sign in), naming the jobs that did not pass. A runner whose last `drain_after_errors` jobs since its state last changed all ended in an infrastructure error (status `error`: it could not run them, not a failing test) is set to draining and the site administrators are told (`flow.runner_drained`, with the last error); making it active again on Admin › Runners starts the count afresh (migration 0064, `state_ms`).
485 -
- **Priority and turns** (oxygenhub#244). Runners take queued jobs by level first (`urgent`, `high`, `normal`, `low`), then by turn: a job's place in its own repository's queue plus that repository's running jobs, oldest first among equals, so repositories take turns and one repository's burst can't hold up the rest. A run's level is its own if it was expedited, else its repository's (Settings › OxygenFlow priority; maintainers choose low, normal or the default, site administrators also high), else its organisation's (`organisations.flow_priority`, no form yet), else the site's (Admin › Runners › Settings), else normal. A maintainer expedites a queued or running run to urgent on its page with a reason; it is audited (`flow.expedite`), shown on the run and in the queue, and lapses when the run ends. Nothing running is stopped for a more urgent job (migration 0073).
485 +
- **Priority and turns** (oxygenhub#244). Runners take queued jobs by level first (`urgent`, `high`, `normal`, `low`), then by turn: a job's place in its own repository's queue plus that repository's running jobs, oldest first among equals, so repositories take turns and one repository's burst can't hold up the rest. A run's level is its own if it was expedited, else its repository's (Settings › OxygenFlow priority; maintainers choose low, normal or the default, site administrators also high), else its organisation's (its settings page, by its owners), else the site's (Admin › Runners › Settings), else normal. A maintainer expedites a queued or running run to urgent on its page with a reason; it is audited (`flow.expedite`), shown on the run and in the queue, and lapses when the run ends. A pipeline may lower its jobs, never raise them: `priority "low"` (or `"normal"`) on a job or for the whole file (OxygenFlow spec) makes a job's level the lower of its own and its run's, except that an expedited run stays urgent; the queue says `pipeline: low` (migration 0074). Nothing running is stopped for a more urgent job (migration 0073).
486 486
- **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart.
487 487
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** runs a committed state without a push:
488 488
  - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state.
…
272 unchanged lines