Alerts: a failed run tells its pusher and watchers; an erroring runner is drained 4a4400c5812d
oxygenflow#30. earth-1 once failed every job on the rust image for three hours and nobody was told. A run that ends failed now 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 [flow] drain_after_errors jobs (5; 0 never) since its state last changed all ended in an infrastructure error (status error, not a failing test) is set to draining and the site administrators are told with the last error (flow.runner_drained). Making it active again starts the count afresh: runners keep when their state changed (migration 0064). Tested: the pusher and a watcher are told of a failed run, with its jobs; five errors in a row drain the runner and tell the administrator only; made active again, the old errors no longer count. refs andymccall/oxygenflow#30
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-14 04:33 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4a4400c5812d3e9fccd65dadec5258ab21a8d8fadacf9c848515360435981738- Depends on
- 070a7b64b368 42fa95540468 9008bf6c2b8c 93be9ce4653e b6b7e4cf6b24 d23464c0ac60 e8fa53694f68
- Depended on by
- b1860d6090ec 53a0fb2e4ac6 107eaba52c9d 3b1bc27fe805 ea14c4ec5c3f 5d90e0e045e2 dc59d1b56c3d 1870e4fa9808 f7bcee49bf24 bff9106edcba
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready 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
- 13 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/migrations/0064_runner_state_ms.sql +4 −0
| 1 | + | -- Alerts (oxygenflow#30): when a runner's state last changed, so the |
|
| 2 | + | -- infrastructure errors that drain it are counted only since an |
|
| 3 | + | -- administrator last made it active. |
|
| 4 | + | ALTER TABLE flow_runners ADD COLUMN state_ms INTEGER NOT NULL DEFAULT 0; |
modified crates/hub/src/config.rs +5 −1
| … | 85 unchanged lines |
||
| 86 | 86 | /// `owner/*` or `owner/name`: repositories whose pushes start runs. |
|
| 87 | 87 | pub repos: Vec<String>, |
|
| 88 | 88 | pub job_timeout_minutes: u64, |
|
| 89 | + | /// A runner whose last this many jobs all ended in an infrastructure |
|
| 90 | + | /// error (status `error`, not `failed`) is drained and the |
|
| 91 | + | /// administrators told (oxygenflow#30); 0 never drains one. |
|
| 92 | + | pub drain_after_errors: u32, |
|
| 89 | 93 | } |
|
| 90 | 94 | ||
| 91 | 95 | impl Default for Flow { |
|
| 92 | 96 | fn default() -> Self { |
|
| 93 | - | Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60 } |
|
| 97 | + | Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 } |
|
| 94 | 98 | } |
|
| 95 | 99 | } |
|
| 96 | 100 | ||
| … | 304 unchanged lines |
modified crates/hub/src/db/runners.rs +24 −1
| … | 242 unchanged lines |
||
| 243 | 243 | } |
|
| 244 | 244 | ||
| 245 | 245 | pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| 246 | - | sqlx::query("UPDATE flow_runners SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; |
|
| 246 | + | sqlx::query("UPDATE flow_runners SET state = ?, state_ms = ? WHERE id = ?") |
|
| 247 | + | .bind(state) |
|
| 248 | + | .bind(super::now_ms()) |
|
| 249 | + | .bind(id) |
|
| 250 | + | .execute(pool) |
|
| 251 | + | .await?; |
|
| 247 | 252 | Ok(()) |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | /// Whether runner `id`'s last `n` jobs since its state last changed all |
|
| 256 | + | /// ended in an infrastructure error (oxygenflow#30), with the last one's. |
|
| 257 | + | pub async fn erroring(pool: &Pool, id: i64, n: u32) -> sqlx::Result<Option<String>> { |
|
| 258 | + | if n == 0 { |
|
| 259 | + | return Ok(None); |
|
| 260 | + | } |
|
| 261 | + | let last: Vec<(String, String)> = sqlx::query_as( |
|
| 262 | + | "SELECT j.status, j.error FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id |
|
| 263 | + | WHERE j.runner_id = ? AND j.finished_ms IS NOT NULL AND j.finished_ms >= r.state_ms |
|
| 264 | + | ORDER BY j.finished_ms DESC, j.id DESC LIMIT ?", |
|
| 265 | + | ) |
|
| 266 | + | .bind(id) |
|
| 267 | + | .bind(n as i64) |
|
| 268 | + | .fetch_all(pool) |
|
| 269 | + | .await?; |
|
| 270 | + | Ok((last.len() == n as usize && last.iter().all(|(s, _)| s == "error")).then(|| last[0].1.clone())) |
|
| 248 | 271 | } |
|
| 249 | 272 | ||
| 250 | 273 | /// Forget a runner: its credential stops working. Its jobs keep its name. |
|
| … | 176 unchanged lines |
modified crates/hub/src/flow.rs +84 −0
| … | 1600 unchanged lines |
||
| 1601 | 1601 | let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? else { |
|
| 1602 | 1602 | return Ok(()); |
|
| 1603 | 1603 | }; |
|
| 1604 | + | let mut errored_on: Vec<i64> = Vec::new(); |
|
| 1604 | 1605 | for j in changed { |
|
| 1605 | 1606 | let summary = match j.status.as_str() { |
|
| 1606 | 1607 | "passed" => format!("passed in {}", duration(&j)), |
|
| … | 3 unchanged lines |
||
| 1610 | 1611 | _ => format!("could not run: {}", j.error), |
|
| 1611 | 1612 | }; |
|
| 1612 | 1613 | report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| 1614 | + | if j.status == "error" { |
|
| 1615 | + | if let Some(r) = runner_of(app, j.id).await { |
|
| 1616 | + | errored_on.push(r); |
|
| 1617 | + | } |
|
| 1618 | + | } |
|
| 1619 | + | } |
|
| 1620 | + | errored_on.dedup(); |
|
| 1621 | + | for r in errored_on { |
|
| 1622 | + | drain_if_erroring(app, r).await; |
|
| 1623 | + | } |
|
| 1624 | + | if let Some(done) = fdb::run_by_id(&app.db, run_id).await? { |
|
| 1625 | + | if done.status == "failed" && !done.live() { |
|
| 1626 | + | tell_failed(app, &row, &done).await; |
|
| 1627 | + | } |
|
| 1613 | 1628 | } |
|
| 1614 | 1629 | // A passing run of an upstream's default bookmark: consumers following |
|
| 1615 | 1630 | // it when green are bumped (Diffusion, #148). |
|
| … | 10 unchanged lines |
||
| 1626 | 1641 | } |
|
| 1627 | 1642 | } |
|
| 1628 | 1643 | Ok(()) |
|
| 1644 | + | } |
|
| 1645 | + | ||
| 1646 | + | /// The account OxygenFlow's notices come from, made on first use with no |
|
| 1647 | + | /// way to sign in. |
|
| 1648 | + | pub const ACCOUNT: &str = "oxygenflow"; |
|
| 1649 | + | ||
| 1650 | + | async fn flow_account(pool: &db::Pool) -> Option<db::accounts::Account> { |
|
| 1651 | + | if let Ok(Some(a)) = db::accounts::account_by_name(pool, ACCOUNT).await { |
|
| 1652 | + | return Some(a); |
|
| 1653 | + | } |
|
| 1654 | + | db::accounts::create_account(pool, ACCOUNT, "oxygenflow@localhost", "!", "", false).await.ok() |
|
| 1655 | + | } |
|
| 1656 | + | ||
| 1657 | + | async fn runner_of(app: &Shared, job_id: i64) -> Option<i64> { |
|
| 1658 | + | sqlx::query_scalar("SELECT runner_id FROM flow_jobs WHERE id = ?").bind(job_id).fetch_optional(&app.db).await.ok().flatten().flatten() |
|
| 1659 | + | } |
|
| 1660 | + | ||
| 1661 | + | /// A failed run (oxygenflow#30): its pusher and the repository's watchers |
|
| 1662 | + | /// are told, in their inboxes and by email as they chose, naming the jobs |
|
| 1663 | + | /// that did not pass. |
|
| 1664 | + | async fn tell_failed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) { |
|
| 1665 | + | let Some(me) = flow_account(&app.db).await else { return }; |
|
| 1666 | + | let jobs = fdb::jobs(&app.db, run.id).await.unwrap_or_default(); |
|
| 1667 | + | let broken: Vec<String> = jobs.iter().filter(|j| matches!(j.status.as_str(), "failed" | "error")).map(|j| j.name.clone()).collect(); |
|
| 1668 | + | let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten(); |
|
| 1669 | + | let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) }; |
|
| 1670 | + | crate::notify::deliver( |
|
| 1671 | + | app, |
|
| 1672 | + | crate::notify::Event { |
|
| 1673 | + | kind: "flow.failed", |
|
| 1674 | + | title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")), |
|
| 1675 | + | url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number), |
|
| 1676 | + | actor: &me, |
|
| 1677 | + | repo_id: row.id, |
|
| 1678 | + | thread: None, |
|
| 1679 | + | mention_text: "", |
|
| 1680 | + | direct: pusher.into_iter().map(|a| a.id).collect(), |
|
| 1681 | + | }, |
|
| 1682 | + | ) |
|
| 1683 | + | .await; |
|
| 1684 | + | } |
|
| 1685 | + | ||
| 1686 | + | /// A runner whose last jobs all ended in infrastructure errors is drained, |
|
| 1687 | + | /// so it stops taking jobs it fails before running them, and the |
|
| 1688 | + | /// administrators are told (oxygenflow#30). |
|
| 1689 | + | async fn drain_if_erroring(app: &Shared, runner_id: i64) { |
|
| 1690 | + | let n = app.config.flow.drain_after_errors; |
|
| 1691 | + | let Ok(Some(last)) = db::runners::erroring(&app.db, runner_id, n).await else { return }; |
|
| 1692 | + | let Ok(Some(r)) = db::runners::get(&app.db, runner_id).await else { return }; |
|
| 1693 | + | if r.state != "active" || db::runners::set_state(&app.db, runner_id, "draining").await.is_err() { |
|
| 1694 | + | return; |
|
| 1695 | + | } |
|
| 1696 | + | tracing::warn!("flow: runner {} drained after {n} errors in a row (last: {last})", r.name); |
|
| 1697 | + | let Some(me) = flow_account(&app.db).await else { return }; |
|
| 1698 | + | let admins: Vec<i64> = sqlx::query_scalar("SELECT id FROM accounts WHERE is_admin = 1").fetch_all(&app.db).await.unwrap_or_default(); |
|
| 1699 | + | crate::notify::deliver( |
|
| 1700 | + | app, |
|
| 1701 | + | crate::notify::Event { |
|
| 1702 | + | kind: "flow.runner_drained", |
|
| 1703 | + | title: format!("Runner {} drained: its last {n} jobs ended in errors (last: {last})", r.name), |
|
| 1704 | + | url: format!("/admin/runners/{runner_id}"), |
|
| 1705 | + | actor: &me, |
|
| 1706 | + | repo_id: 0, |
|
| 1707 | + | thread: None, |
|
| 1708 | + | mention_text: "", |
|
| 1709 | + | direct: admins, |
|
| 1710 | + | }, |
|
| 1711 | + | ) |
|
| 1712 | + | .await; |
|
| 1629 | 1713 | } |
|
| 1630 | 1714 | ||
| 1631 | 1715 | /// The site's job time limits (oxygenhub#89): set on Admin → Runners → |
|
| … | 370 unchanged lines |
modified crates/hub/tests/flow.rs +78 −0
| … | 3992 unchanged lines |
||
| 3993 | 3993 | call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/deploy/rerun", run.number), Some("site"), None).await; |
|
| 3994 | 3994 | assert_eq!(st, StatusCode::CONFLICT, "live again, and no such job either way"); |
|
| 3995 | 3995 | } |
|
| 3996 | + | ||
| 3997 | + | /// oxygenflow#30: a failed run tells its pusher and the repository's |
|
| 3998 | + | /// watchers; a runner whose last five jobs ended in infrastructure errors |
|
| 3999 | + | /// is drained and the administrators told, counting only since an |
|
| 4000 | + | /// administrator last made it active. |
|
| 4001 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4002 | + | async fn failed_runs_are_told_and_an_erroring_runner_is_drained() { |
|
| 4003 | + | let h = hub().await; |
|
| 4004 | + | let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 4005 | + | let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 4006 | + | create(&h.app, "andy", "alerts").await; |
|
| 4007 | + | let row = db::repos::get(&h.app.db, "andy", "alerts").await.unwrap().unwrap(); |
|
| 4008 | + | db::notify::set_watch(&h.app.db, bob.id, row.id, true).await.unwrap(); |
|
| 4009 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4010 | + | let c = client(&format!("{}/andy/alerts", h.base), work.path()); |
|
| 4011 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 4012 | + | write(work.path(), "test.sh", "exit 1\n"); |
|
| 4013 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 4014 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4015 | + | let run = runs_of(&h.app, "andy", "alerts").await[0].clone(); |
|
| 4016 | + | sqlx::query("UPDATE flow_runs SET actor = 'andy' WHERE id = ?").bind(run.id).execute(&h.app.db).await.unwrap(); |
|
| 4017 | + | while let Some(j) = claim(&h.app).await { |
|
| 4018 | + | let id = j["job_id"].as_i64().unwrap(); |
|
| 4019 | + | let (name, ph) = (j["job"]["name"].as_str().unwrap().to_string(), j["pipeline_hash"].as_str().unwrap().to_string()); |
|
| 4020 | + | let exit = if name == "test" { 1 } else { 0 }; |
|
| 4021 | + | finish(&h.app, id, serde_json::json!({ "exit_code": exit, "duration_ms": 10, "receipt": receipt(&name, &ph, exit as u32) })).await; |
|
| 4022 | + | } |
|
| 4023 | + | let told = |id: i64| { |
|
| 4024 | + | let db = h.app.db.clone(); |
|
| 4025 | + | async move { db::notify::inbox(&db, id, 20).await.unwrap().into_iter().filter(|n| n.kind == "flow.failed").collect::<Vec<_>>() } |
|
| 4026 | + | }; |
|
| 4027 | + | let (for_andy, for_bob) = (told(andy.id).await, told(bob.id).await); |
|
| 4028 | + | assert_eq!((for_andy.len(), for_bob.len()), (1, 1), "the pusher and a watcher"); |
|
| 4029 | + | assert!(for_andy[0].title.contains(&format!("run #{} on main failed (test)", run.number)), "{}", for_andy[0].title); |
|
| 4030 | + | assert_eq!(for_andy[0].url, format!("/andy/alerts/flow/{}", run.number)); |
|
| 4031 | + | ||
| 4032 | + | // Five infrastructure errors in a row drain the runner. |
|
| 4033 | + | let runner_id = || { |
|
| 4034 | + | let db = h.app.db.clone(); |
|
| 4035 | + | async move { sqlx::query_scalar::<_, i64>("SELECT id FROM flow_runners WHERE name = 'test-1'").fetch_one(&db).await.unwrap() } |
|
| 4036 | + | }; |
|
| 4037 | + | let mut errors = 0; |
|
| 4038 | + | while errors < 5 { |
|
| 4039 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/alerts/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 4040 | + | assert_eq!(st, StatusCode::CREATED); |
|
| 4041 | + | while let Some(j) = claim(&h.app).await { |
|
| 4042 | + | let id = j["job_id"].as_i64().unwrap(); |
|
| 4043 | + | let st = finish( |
|
| 4044 | + | &h.app, |
|
| 4045 | + | id, |
|
| 4046 | + | serde_json::json!({ "exit_code": null, "duration_ms": 10, "error": "the microVM did not start: /dev/kvm busy" }), |
|
| 4047 | + | ) |
|
| 4048 | + | .await; |
|
| 4049 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 4050 | + | errors += 1; |
|
| 4051 | + | if errors == 5 { |
|
| 4052 | + | break; |
|
| 4053 | + | } |
|
| 4054 | + | } |
|
| 4055 | + | } |
|
| 4056 | + | let state = |id: i64| { |
|
| 4057 | + | let db = h.app.db.clone(); |
|
| 4058 | + | async move { db::runners::get(&db, id).await.unwrap().unwrap().state } |
|
| 4059 | + | }; |
|
| 4060 | + | let id = runner_id().await; |
|
| 4061 | + | assert_eq!(state(id).await, "draining"); |
|
| 4062 | + | let drained: Vec<_> = |
|
| 4063 | + | db::notify::inbox(&h.app.db, andy.id, 50).await.unwrap().into_iter().filter(|n| n.kind == "flow.runner_drained").collect(); |
|
| 4064 | + | assert_eq!(drained.len(), 1, "the administrator is told"); |
|
| 4065 | + | assert!(drained[0].title.contains("/dev/kvm busy"), "{}", drained[0].title); |
|
| 4066 | + | assert!( |
|
| 4067 | + | !told(bob.id).await.is_empty() |
|
| 4068 | + | && db::notify::inbox(&h.app.db, bob.id, 50).await.unwrap().iter().all(|n| n.kind != "flow.runner_drained") |
|
| 4069 | + | ); |
|
| 4070 | + | // Made active again, past errors no longer count. |
|
| 4071 | + | db::runners::set_state(&h.app.db, id, "active").await.unwrap(); |
|
| 4072 | + | assert_eq!(db::runners::erroring(&h.app.db, id, 5).await.unwrap(), None); |
|
| 4073 | + | } |
modified docs/spec.md +2 −1
| … | 477 unchanged lines |
||
| 478 | 478 | ||
| 479 | 479 | Pipelines run in **OxygenFlow**, a separate project and specification (`oxygenflow/docs/spec.md`; the first slice is `oxygenflow/docs/m0.md`). A pipeline is `.flow/pipeline.kdl` in the state being built; jobs run in Flow's isolation tiers (MicroVM by default, a syscall sandbox, WASM), never in containers on the hub. The hub's side: |
|
| 480 | 480 | ||
| 481 | - | - **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) and `job_timeout_minutes` (60). |
|
| 481 | + | - **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). |
|
| 482 | + | - **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`). |
|
| 482 | 483 | - **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. |
|
| 483 | 484 | - **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: |
|
| 484 | 485 | - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state. |
|
| … | 272 unchanged lines |