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 |