Flow: jobs that ask for runner labels go to runners with them (refs #41, part 3) c4c87f481d05
A claim takes a job only when the runner has every label its job and sandbox name with runs-on, from the labels it reports on the claim or registered with; the queue on Admin → Runners says which labels a waiting job needs. oxygenflow pinned with runs-on.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 03:05 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c4c87f481d05863699f00b3fff9578e7d2b07d4156ea2c7e41cbbd9f94b8d77f- Depends on
- 046bd71a180f 380996db84e5 42fa95540468 74be97962ed8 76bc5a058351 7974adb0be66 93be9ce4653e b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368
- Depended on by
- 107eaba52c9d e9b9619cd857 417184c73f4e 501d8b268dd4 4d2e44d79b03 efd29a0033e8 7f5f6c056d8f b0612e7dce30 070a7b64b368 2bbe88a85860
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
- 16 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/hub/src/db/flow.rs +36 −2
| … | 153 unchanged lines |
||
| 154 | 154 | Ok(id) |
|
| 155 | 155 | } |
|
| 156 | 156 | ||
| 157 | + | /// The labels a compiled job and its sandbox ask a runner for (`runs-on`). |
|
| 158 | + | fn labels_in(spec: &str, sandbox: &str) -> Vec<String> { |
|
| 159 | + | let mut out: Vec<String> = [spec, sandbox] |
|
| 160 | + | .iter() |
|
| 161 | + | .filter_map(|j| serde_json::from_str::<serde_json::Value>(j).ok()) |
|
| 162 | + | .flat_map(|v| v["runs_on"].as_array().cloned().unwrap_or_default()) |
|
| 163 | + | .filter_map(|l| l.as_str().map(String::from)) |
|
| 164 | + | .collect(); |
|
| 165 | + | out.sort(); |
|
| 166 | + | out.dedup(); |
|
| 167 | + | out |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | impl Job { |
|
| 171 | + | /// The runner labels this job needs. |
|
| 172 | + | pub fn needs(&self) -> Vec<String> { |
|
| 173 | + | labels_in(&self.spec, &self.sandbox) |
|
| 174 | + | } |
|
| 175 | + | } |
|
| 176 | + | ||
| 157 | 177 | /// A job waiting for or held by a runner, with where it comes from. |
|
| 158 | 178 | #[derive(Clone, Debug, FromRow)] |
|
| 159 | 179 | pub struct QueueEntry { |
|
| … | 8 unchanged lines |
||
| 168 | 188 | pub status: String, |
|
| 169 | 189 | /// The runner holding it, when running. |
|
| 170 | 190 | pub runner: String, |
|
| 191 | + | pub spec: String, |
|
| 192 | + | pub sandbox: String, |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | impl QueueEntry { |
|
| 196 | + | /// The runner labels the job needs. |
|
| 197 | + | pub fn needs(&self) -> Vec<String> { |
|
| 198 | + | labels_in(&self.spec, &self.sandbox) |
|
| 199 | + | } |
|
| 171 | 200 | } |
|
| 172 | 201 | ||
| 173 | 202 | /// Every job of a live run that is running or queued: running ones first, |
|
| 174 | 203 | /// then queued ones in the order runners claim them (hub spec §13.2). |
|
| 175 | 204 | pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> { |
|
| 176 | 205 | sqlx::query_as( |
|
| 177 | - | "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner \ |
|
| 206 | + | "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox \ |
|
| 178 | 207 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \ |
|
| 179 | 208 | WHERE j.status IN ('queued', 'running') AND r.status IN ('queued', 'running') \ |
|
| 180 | 209 | ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id", |
|
| … | 79 unchanged lines |
||
| 260 | 289 | } |
|
| 261 | 290 | ||
| 262 | 291 | pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> { |
|
| 263 | - | claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default()).await |
|
| 292 | + | claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default(), &[]).await |
|
| 264 | 293 | } |
|
| 265 | 294 | ||
| 266 | 295 | /// The oldest runnable job that fits in `room`. |
|
| … | 7 unchanged lines |
||
| 274 | 303 | token_hash: &str, |
|
| 275 | 304 | room: Room, |
|
| 276 | 305 | scope: &super::runners::Scope, |
|
| 306 | + | labels: &[String], |
|
| 277 | 307 | ) -> sqlx::Result<Option<Job>> { |
|
| 278 | 308 | let mut tx = super::begin_write(pool).await?; |
|
| 279 | 309 | let queued: Vec<Job> = sqlx::query_as( |
|
| … | 10 unchanged lines |
||
| 290 | 320 | .await?; |
|
| 291 | 321 | for j in queued { |
|
| 292 | 322 | if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) { |
|
| 323 | + | continue; |
|
| 324 | + | } |
|
| 325 | + | // Every label the job and its sandbox ask for (`runs-on`). |
|
| 326 | + | if !j.needs().iter().all(|l| labels.contains(l)) { |
|
| 293 | 327 | continue; |
|
| 294 | 328 | } |
|
| 295 | 329 | let deps = j.depends_on(); |
|
| … | 222 unchanged lines |
modified crates/hub/src/flow.rs +10 −1
| … | 256 unchanged lines |
||
| 257 | 257 | let runner = if me.source == "registered" { me.name.clone() } else { claimed }; |
|
| 258 | 258 | let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
|
| 259 | 259 | let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus }; |
|
| 260 | - | let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope()).await? else { |
|
| 260 | + | // The labels it reports now, or those it registered with. |
|
| 261 | + | let labels: Vec<String> = match &req.info { |
|
| 262 | + | Some(i) => i.labels.clone(), |
|
| 263 | + | None => me.label_list().into_iter().map(String::from).collect(), |
|
| 264 | + | }; |
|
| 265 | + | let Some(job) = |
|
| 266 | + | fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await? |
|
| 267 | + | else { |
|
| 261 | 268 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 262 | 269 | }; |
|
| 263 | 270 | db::runners::set_job_runner(&app.db, job.id, me.id).await?; |
|
| … | 372 unchanged lines |
||
| 636 | 643 | job: job.into(), |
|
| 637 | 644 | status: status.into(), |
|
| 638 | 645 | runner: String::new(), |
|
| 646 | + | spec: String::new(), |
|
| 647 | + | sandbox: String::new(), |
|
| 639 | 648 | } |
|
| 640 | 649 | } |
|
| 641 | 650 | ||
| … | 30 unchanged lines |
modified crates/hub/tests/flow.rs +39 −0
| … | 1288 unchanged lines |
||
| 1289 | 1289 | let (_, page) = web("POST", format!("/orgs/acme/settings/runners/{}/remove", acme.id), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1290 | 1290 | assert!(page.contains("acme-runner removed"), "{page}"); |
|
| 1291 | 1291 | } |
|
| 1292 | + | ||
| 1293 | + | /// runs-on (oxygenhub#41, part 3): a job that asks for a label goes only to |
|
| 1294 | + | /// a runner that has it, and waits for one otherwise. |
|
| 1295 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1296 | + | async fn jobs_that_ask_for_labels_wait_for_a_runner_with_them() { |
|
| 1297 | + | use oxy_core::identity::SecretKey; |
|
| 1298 | + | let h = hub().await; |
|
| 1299 | + | create(&h.app, "andy", "demo").await; |
|
| 1300 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1301 | + | let c = client(&format!("{}/andy/demo", h.base), work.path()); |
|
| 1302 | + | let cross = "job \"cross\" {\n sandbox \"alpine\"\n runs-on \"riscv64\"\n exec \"true\"\n}\n"; |
|
| 1303 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline(cross)); |
|
| 1304 | + | c.snapshot("t").unwrap(); |
|
| 1305 | + | c.commit("pipeline", &[]).unwrap(); |
|
| 1306 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1307 | + | assert!(!runs_of(&h.app, "andy", "demo").await.is_empty()); |
|
| 1308 | + | // The site runner has no labels: it takes the others, never cross. |
|
| 1309 | + | let mut taken = Vec::new(); |
|
| 1310 | + | while let Some(j) = claim(&h.app).await { |
|
| 1311 | + | taken.push(j["job"]["name"].as_str().unwrap().to_string()); |
|
| 1312 | + | } |
|
| 1313 | + | assert!(!taken.contains(&"cross".to_string()) && taken.contains(&"lint".to_string()), "{taken:?}"); |
|
| 1314 | + | let queue = db::flow::queue(&h.app.db).await.unwrap(); |
|
| 1315 | + | let waiting = queue.iter().find(|q| q.job == "cross").unwrap(); |
|
| 1316 | + | assert_eq!((waiting.status.as_str(), waiting.needs()), ("queued", vec!["riscv64".to_string()])); |
|
| 1317 | + | // A runner registered with the label takes it. |
|
| 1318 | + | let token = "flowreg_riscv"; |
|
| 1319 | + | db::runners::add_registration(&h.app.db, &blake3::hash(token.as_bytes()).to_hex(), "admin", &Default::default()).await.unwrap(); |
|
| 1320 | + | let identity = SecretKey::generate().identity("visionfive", "", 1); |
|
| 1321 | + | let info = serde_json::json!({ "arch": "riscv64", "os": "linux", "version": "0.1.0", "labels": ["riscv64"], "max_jobs": 1 }); |
|
| 1322 | + | let reg = serde_json::json!({ "token": token, "name": "visionfive", "identity": hex::encode(identity.encode()), "info": info }); |
|
| 1323 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 1324 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1325 | + | let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string(); |
|
| 1326 | + | let claim_body = serde_json::json!({ "runner": "x", "runtimes": ["microvm"], "info": info }).to_string(); |
|
| 1327 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some(&credential), Some(claim_body)).await; |
|
| 1328 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1329 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["job"]["name"], "cross"); |
|
| 1330 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −1
| … | 462 unchanged lines |
||
| 463 | 463 | - On the runner, `flow runner register https://hub --token …` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`) and gets its own credential (`flowrunner_…`, stored hashed). |
|
| 464 | 464 | - The runner's key is bound at registration: receipts from that runner signed by another key are not kept. |
|
| 465 | 465 | - Runners configured with a `hub.toml` token still work. They are tracked from their first request under the name they claim with, and their key is bound on their first signed receipt. |
|
| 466 | - | - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners. |
|
| 466 | + | - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. A job whose pipeline says `runs-on` (OxygenFlow spec 3.1) goes only to a runner that has all of its job's and sandbox's labels, from those the runner reports on the claim or registered with; until one claims it, the queue says which labels it waits for. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners. |
|
| 467 | 467 | - **Each claim** carries the runner's description and marks it seen. |
|
| 468 | 468 | - **The Runners page** lists each runner: online (seen in the last 90 s), offline, draining or disabled; what it serves; signing key; machine; labels; capacity; jobs running now; and when it was last seen. |
|
| 469 | 469 | - **Actions:** |
|
| … | 179 unchanged lines |
modified templates/admin_runners.html +1 −1
| … | 42 unchanged lines |
||
| 43 | 43 | {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %} |
|
| 44 | 44 | <div class="table"><table> |
|
| 45 | 45 | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr> |
|
| 46 | - | {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %} |
|
| 46 | + | {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %} |
|
| 47 | 47 | </table></div> |
|
| 48 | 48 | <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p> |
|
| 49 | 49 | {% endif %} |
|
| … | 12 unchanged lines |