Runners decline, and the queue says why a job waits 4d2e44d79b03
POST /api/v1/flow/jobs/{id}/decline (OxygenFlow spec §11.5): the job is
queued again and kept from that runner while its profile (tiers, images,
architecture, labels, capacity, hashed at each claim) stays the same, ten
minutes at most; three different runners declining it end it as
unplaceable with their reasons, never a failed check. A placement pass
every 15 s writes why each ready queued job no runner can take waits
(spec §11.4), shown on the run page and in the API (FlowJob.waiting); a
runner's page shows its tiers and its declines. Migration 0043.
refs oxygenflow#36
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 00:42 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4d2e44d79b03ab939a3b69c02fa7115265089c072018123c18fba764865fe298- Depends on
- 25091e5c3bba 348f8c4baccf 380996db84e5 42fa95540468 62ceb2eec18c 749d9a5fcd9f 76bc5a058351 76f262e1a666 7f5f6c056d8f 8a3334b7a364 90326c9601f9 a3c8288b0141 b0612e7dce30 b5668108487f b64e2537d702 b66e70b7b6c7 b6b7e4cf6b24 bf1ab3458db1 bf3d07541368 c4c87f481d05 cc19697f8871 d23464c0ac60 efd29a0033e8 fd3db644df22
- Depended on by
- 041a945f55f8 951aed50c2f5 8501fd5815b9 56b8dbe66974 ab4df6aae179 794ff94f194a 858eca0d03c4 e57765dee61a 58cbf42447bc e9b9619cd857 5d90e0e045e2 417184c73f4e d9b3b84605a4 71e65fcf95ab 501d8b268dd4 44a3d2ebaddd 2fb70d019851 92a797b20912 67040b158ae2 554ccacfc123 1f78655a0466
- 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.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
- 43 atoms, 1 tree operation, 14 files
Browse the tree at this change
added crates/hub/migrations/0043_flow_declines.sql +19 −0
| 1 | + | -- Placement you can see (OxygenFlow spec §11.4–11.5, oxygenflow#36): what |
|
| 2 | + | -- each runner offers at its last claim, the jobs runners handed back with |
|
| 3 | + | -- a reason, and why a queued job waits. |
|
| 4 | + | ALTER TABLE flow_runners ADD COLUMN tiers TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | -- JSON array of image hashes; empty when the runner did not say. |
|
| 6 | + | ALTER TABLE flow_runners ADD COLUMN images TEXT NOT NULL DEFAULT ''; |
|
| 7 | + | -- BLAKE3 of what it offers: a decline holds until this changes. |
|
| 8 | + | ALTER TABLE flow_runners ADD COLUMN profile TEXT NOT NULL DEFAULT ''; |
|
| 9 | + | ALTER TABLE flow_jobs ADD COLUMN waiting TEXT NOT NULL DEFAULT ''; |
|
| 10 | + | CREATE TABLE flow_declines ( |
|
| 11 | + | job_id INTEGER NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE, |
|
| 12 | + | runner_id INTEGER NOT NULL, |
|
| 13 | + | runner TEXT NOT NULL, |
|
| 14 | + | profile TEXT NOT NULL, |
|
| 15 | + | reason TEXT NOT NULL, |
|
| 16 | + | ms INTEGER NOT NULL, |
|
| 17 | + | PRIMARY KEY (job_id, runner_id) |
|
| 18 | + | ); |
|
| 19 | + | CREATE INDEX flow_declines_runner ON flow_declines (runner_id, ms); |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Runners decline, and the queue says why a job waits (oxygenflow#36, OxygenFlow spec §11.4–11.5).** A runner can hand back a job it cannot run with a reason. The job is queued again and not offered to that runner while what it offers stays the same, for ten minutes at most. After three different runners decline it, the job ends as unplaceable with their reasons. Every 15 seconds the hub works out why each ready job no runner can take is waiting, from each runner's last claim: no runner online, a missing tier, the wrong architecture, an image it does not hold, a label, memory or CPUs, a scope, or a decline. The run page and the API show the reason, and a runner's page shows its tiers and what it declined. |
|
| 8 | + | ||
| 7 | 9 | - **Assistants through the API and `oh` (§13.4).** List, add or change, and remove a repository's, an organisation's or the site's assistants, and read or set their prompts: `/repos/{o}/{n}/assistants`, `/orgs/{org}/assistants`, `/admin/assistants`; `oh assistant list|add|remove|prompt` with `--org` or `--site`. Tokens are write-only and read from an environment variable by `oh`. |
|
| 8 | 10 | ||
| 9 | 11 | - **Adding an assistant picks from what the provider offers (#109).** A provider list fills the name, endpoint and API (with *Other* for anything else), **List models** fetches the endpoint's own models into the Model field, and a model the endpoint does not list is refused on save with ones it does. Administration is a sectioned page with a left-hand menu, its audit log paged (#110). |
|
| … | 117 unchanged lines |
modified crates/hub-api/src/lib.rs +4 −0
| … | 550 unchanged lines |
||
| 551 | 551 | pub finished_ms: Option<i64>, |
|
| 552 | 552 | /// Log lines stored so far. |
|
| 553 | 553 | pub log_lines: i64, |
|
| 554 | + | /// Why a queued job no runner can take waits (OxygenFlow spec §11.4); |
|
| 555 | + | /// empty when it only waits its turn. |
|
| 556 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 557 | + | pub waiting: String, |
|
| 554 | 558 | } |
|
| 555 | 559 | ||
| 556 | 560 | /// A line of a job's log. |
|
| … | 583 unchanged lines |
modified crates/hub-api/ts/index.d.ts +43 −6
| … | 47 unchanged lines |
||
| 48 | 48 | status: "invalid"; |
|
| 49 | 49 | }; |
|
| 50 | 50 | ||
| 51 | + | export type Via = { |
|
| 52 | + | expires_ms?: number | null; |
|
| 53 | + | key: string; |
|
| 54 | + | name: string; |
|
| 55 | + | revoked: boolean; |
|
| 56 | + | scope: string; |
|
| 57 | + | }; |
|
| 58 | + | ||
| 51 | 59 | export type ChangeSummary = { |
|
| 52 | 60 | author: Author; |
|
| 53 | 61 | deps: string[]; |
|
| … | 1 unchanged lines |
||
| 55 | 63 | head: boolean; |
|
| 56 | 64 | message: string; |
|
| 57 | 65 | signature: Signature; |
|
| 66 | + | via?: Via | null; |
|
| 58 | 67 | when_ms: number; |
|
| 59 | 68 | }; |
|
| 60 | 69 | ||
| … | 9 unchanged lines |
||
| 70 | 79 | sealed: boolean; |
|
| 71 | 80 | }; |
|
| 72 | 81 | ||
| 82 | + | export type Label = { |
|
| 83 | + | colour: string; |
|
| 84 | + | description: string; |
|
| 85 | + | name: string; |
|
| 86 | + | }; |
|
| 87 | + | ||
| 73 | 88 | export type Landed = { |
|
| 74 | 89 | by: string; |
|
| 75 | 90 | via: string; |
|
| … | 23 unchanged lines |
||
| 99 | 114 | changes: ChangeSummary[]; |
|
| 100 | 115 | conflicted: boolean; |
|
| 101 | 116 | created_ms: number; |
|
| 117 | + | labels?: Label[]; |
|
| 102 | 118 | landable: boolean; |
|
| 103 | 119 | landed?: Landed | null; |
|
| 104 | 120 | number: number; |
|
| … | 8 unchanged lines |
||
| 113 | 129 | title: string; |
|
| 114 | 130 | updated_ms: number; |
|
| 115 | 131 | url: string; |
|
| 116 | - | }; |
|
| 117 | - | ||
| 118 | - | export type Label = { |
|
| 119 | - | colour: string; |
|
| 120 | - | description: string; |
|
| 121 | - | name: string; |
|
| 122 | 132 | }; |
|
| 123 | 133 | ||
| 124 | 134 | export type Issue = { |
|
| … | 24 unchanged lines |
||
| 149 | 159 | title: string; |
|
| 150 | 160 | }; |
|
| 151 | 161 | ||
| 162 | + | export type NewMilestone = { |
|
| 163 | + | description?: string; |
|
| 164 | + | due?: string | null; |
|
| 165 | + | title: string; |
|
| 166 | + | }; |
|
| 167 | + | ||
| 168 | + | export type MilestoneEdit = { |
|
| 169 | + | description?: string | null; |
|
| 170 | + | due?: string | null; |
|
| 171 | + | state?: string | null; |
|
| 172 | + | title?: string | null; |
|
| 173 | + | }; |
|
| 174 | + | ||
| 152 | 175 | export type Atom = { |
|
| 153 | 176 | change: string; |
|
| 154 | 177 | index: number; |
|
| … | 39 unchanged lines |
||
| 194 | 217 | id: number; |
|
| 195 | 218 | sealed: boolean; |
|
| 196 | 219 | verdict: string; |
|
| 220 | + | via?: string; |
|
| 197 | 221 | }; |
|
| 198 | 222 | ||
| 199 | 223 | export type TreeEntry = { |
|
| … | 155 unchanged lines |
||
| 355 | 379 | body?: string | null; |
|
| 356 | 380 | labels?: string[] | null; |
|
| 357 | 381 | milestone?: string | null; |
|
| 382 | + | title?: string | null; |
|
| 383 | + | }; |
|
| 384 | + | ||
| 385 | + | export type ReleaseEdit = { |
|
| 386 | + | notes?: string | null; |
|
| 387 | + | prerelease?: boolean | null; |
|
| 388 | + | title?: string | null; |
|
| 389 | + | }; |
|
| 390 | + | ||
| 391 | + | export type ProposalEdit = { |
|
| 392 | + | body?: string | null; |
|
| 393 | + | labels?: string[] | null; |
|
| 358 | 394 | title?: string | null; |
|
| 359 | 395 | }; |
|
| 360 | 396 | ||
| … | 144 unchanged lines |
||
| 505 | 541 | runner: string; |
|
| 506 | 542 | started_ms?: number | null; |
|
| 507 | 543 | status: string; |
|
| 544 | + | waiting?: string; |
|
| 508 | 545 | }; |
|
| 509 | 546 | ||
| 510 | 547 | export type FlowRun = { |
|
| … | 30 unchanged lines |
modified crates/hub/src/api/mod.rs +2 −0
| … | 37 unchanged lines |
||
| 38 | 38 | .route("/flow/runners/register", post(crate::flow::register)) |
|
| 39 | 39 | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 40 | 40 | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| 41 | + | .route("/flow/jobs/{id}/decline", post(crate::flow::decline)) |
|
| 41 | 42 | .route( |
|
| 42 | 43 | "/flow/jobs/{id}/artifacts/{name}", |
|
| 43 | 44 | post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
|
| … | 1793 unchanged lines |
||
| 1837 | 1838 | started_ms: j.started_ms, |
|
| 1838 | 1839 | finished_ms: j.finished_ms, |
|
| 1839 | 1840 | log_lines: j.log_count, |
|
| 1841 | + | waiting: j.waiting, |
|
| 1840 | 1842 | }) |
|
| 1841 | 1843 | .collect(), |
|
| 1842 | 1844 | } |
|
| … | 516 unchanged lines |
modified crates/hub/src/db/flow.rs +163 −2
| … | 58 unchanged lines |
||
| 59 | 59 | } |
|
| 60 | 60 | } |
|
| 61 | 61 | ||
| 62 | - | #[derive(Clone, Debug, FromRow)] |
|
| 62 | + | #[derive(Clone, Debug, Default, FromRow)] |
|
| 63 | 63 | pub struct Job { |
|
| 64 | 64 | pub id: i64, |
|
| 65 | 65 | pub run_id: i64, |
|
| … | 21 unchanged lines |
||
| 87 | 87 | pub receipt_signer: String, |
|
| 88 | 88 | /// Why a signed receipt was not kept. |
|
| 89 | 89 | pub receipt_note: String, |
|
| 90 | + | /// Why a queued job no runner can take waits (spec §11.4), from the |
|
| 91 | + | /// last placement pass; empty when it is only waiting its turn. |
|
| 92 | + | pub waiting: String, |
|
| 90 | 93 | } |
|
| 91 | 94 | ||
| 92 | 95 | impl Job { |
|
| … | 94 unchanged lines |
||
| 187 | 190 | /// The runner labels this job needs. |
|
| 188 | 191 | pub fn needs(&self) -> Vec<String> { |
|
| 189 | 192 | labels_in(&self.spec, &self.sandbox) |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// The architecture the job's sandbox asks for (OxygenFlow spec §11.3). |
|
| 196 | + | pub fn arch(&self) -> Option<String> { |
|
| 197 | + | arch_in(&self.sandbox) |
|
| 190 | 198 | } |
|
| 191 | 199 | } |
|
| 192 | 200 | ||
| … | 132 unchanged lines |
||
| 325 | 333 | pub arch: String, |
|
| 326 | 334 | /// Each image's architecture as runners have reported it (`flow_images`). |
|
| 327 | 335 | pub image_archs: std::collections::HashMap<String, String>, |
|
| 336 | + | /// Jobs this runner declined, not to be offered to it again (spec §11.5). |
|
| 337 | + | pub declined: std::collections::HashSet<i64>, |
|
| 328 | 338 | } |
|
| 329 | 339 | ||
| 330 | 340 | impl Room { |
|
| … | 67 unchanged lines |
||
| 398 | 408 | .fetch_all(&mut *tx) |
|
| 399 | 409 | .await?; |
|
| 400 | 410 | for j in queued { |
|
| 401 | - | if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) { |
|
| 411 | + | if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) || room.declined.contains(&j.id) { |
|
| 402 | 412 | continue; |
|
| 403 | 413 | } |
|
| 404 | 414 | // Every label the job and its sandbox ask for (`runs-on`). |
|
| … | 38 unchanged lines |
||
| 443 | 453 | } |
|
| 444 | 454 | tx.commit().await?; |
|
| 445 | 455 | Ok(None) |
|
| 456 | + | } |
|
| 457 | + | ||
| 458 | + | /// How long a decline keeps a job from its runner when the runner's |
|
| 459 | + | /// profile stays the same: a reason such as memory in use passes. |
|
| 460 | + | pub const DECLINE_HOLDS_MS: i64 = 10 * 60_000; |
|
| 461 | + | ||
| 462 | + | /// The jobs runner `runner_id` declined with this profile, recently. |
|
| 463 | + | pub async fn declined_by(pool: &Pool, runner_id: i64, profile: &str) -> sqlx::Result<std::collections::HashSet<i64>> { |
|
| 464 | + | let rows: Vec<(i64,)> = sqlx::query_as("SELECT job_id FROM flow_declines WHERE runner_id = ? AND profile = ? AND ms > ?") |
|
| 465 | + | .bind(runner_id) |
|
| 466 | + | .bind(profile) |
|
| 467 | + | .bind(now_ms() - DECLINE_HOLDS_MS) |
|
| 468 | + | .fetch_all(pool) |
|
| 469 | + | .await?; |
|
| 470 | + | Ok(rows.into_iter().map(|(j,)| j).collect()) |
|
| 471 | + | } |
|
| 472 | + | ||
| 473 | + | /// A decline: one runner's reason for handing a job back. |
|
| 474 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 475 | + | pub struct DeclineRow { |
|
| 476 | + | pub job_id: i64, |
|
| 477 | + | pub runner_id: i64, |
|
| 478 | + | pub runner: String, |
|
| 479 | + | pub reason: String, |
|
| 480 | + | pub ms: i64, |
|
| 481 | + | } |
|
| 482 | + | ||
| 483 | + | /// Runner `runner_id` hands back job `job_id`, running on it: the decline |
|
| 484 | + | /// is kept (one per job and runner, the latest), and the job is queued |
|
| 485 | + | /// again. Returns every decline the job has had, or `None` when the job |
|
| 486 | + | /// was not running on that runner. |
|
| 487 | + | pub async fn decline( |
|
| 488 | + | pool: &Pool, |
|
| 489 | + | job_id: i64, |
|
| 490 | + | runner_id: i64, |
|
| 491 | + | runner: &str, |
|
| 492 | + | profile: &str, |
|
| 493 | + | reason: &str, |
|
| 494 | + | ) -> sqlx::Result<Option<Vec<DeclineRow>>> { |
|
| 495 | + | let mut tx = super::begin_write(pool).await?; |
|
| 496 | + | let back = sqlx::query( |
|
| 497 | + | "UPDATE flow_jobs SET status = 'queued', runner = '', token_hash = '', started_ms = NULL, runner_id = NULL \ |
|
| 498 | + | WHERE id = ? AND status = 'running' AND runner_id = ?", |
|
| 499 | + | ) |
|
| 500 | + | .bind(job_id) |
|
| 501 | + | .bind(runner_id) |
|
| 502 | + | .execute(&mut *tx) |
|
| 503 | + | .await?; |
|
| 504 | + | if back.rows_affected() != 1 { |
|
| 505 | + | tx.rollback().await?; |
|
| 506 | + | return Ok(None); |
|
| 507 | + | } |
|
| 508 | + | sqlx::query( |
|
| 509 | + | "INSERT INTO flow_declines (job_id, runner_id, runner, profile, reason, ms) VALUES (?, ?, ?, ?, ?, ?) \ |
|
| 510 | + | ON CONFLICT (job_id, runner_id) DO UPDATE SET runner = excluded.runner, profile = excluded.profile, reason = excluded.reason, ms = excluded.ms", |
|
| 511 | + | ) |
|
| 512 | + | .bind(job_id) |
|
| 513 | + | .bind(runner_id) |
|
| 514 | + | .bind(runner) |
|
| 515 | + | .bind(profile) |
|
| 516 | + | .bind(reason) |
|
| 517 | + | .bind(now_ms()) |
|
| 518 | + | .execute(&mut *tx) |
|
| 519 | + | .await?; |
|
| 520 | + | let all: Vec<DeclineRow> = |
|
| 521 | + | sqlx::query_as("SELECT job_id, runner_id, runner, reason, ms FROM flow_declines WHERE job_id = ? ORDER BY ms") |
|
| 522 | + | .bind(job_id) |
|
| 523 | + | .fetch_all(&mut *tx) |
|
| 524 | + | .await?; |
|
| 525 | + | tx.commit().await?; |
|
| 526 | + | Ok(Some(all)) |
|
| 527 | + | } |
|
| 528 | + | ||
| 529 | + | /// The declines of job `job_id`, oldest first. |
|
| 530 | + | pub async fn declines_of_job(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<DeclineRow>> { |
|
| 531 | + | sqlx::query_as("SELECT job_id, runner_id, runner, reason, ms FROM flow_declines WHERE job_id = ? ORDER BY ms") |
|
| 532 | + | .bind(job_id) |
|
| 533 | + | .fetch_all(pool) |
|
| 534 | + | .await |
|
| 535 | + | } |
|
| 536 | + | ||
| 537 | + | /// A runner's latest declines, newest first, with where the job is. |
|
| 538 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 539 | + | pub struct RunnerDecline { |
|
| 540 | + | pub job_id: i64, |
|
| 541 | + | pub job: String, |
|
| 542 | + | pub run_number: i64, |
|
| 543 | + | pub owner: String, |
|
| 544 | + | pub name: String, |
|
| 545 | + | pub reason: String, |
|
| 546 | + | pub ms: i64, |
|
| 547 | + | } |
|
| 548 | + | ||
| 549 | + | pub async fn declines_of_runner(pool: &Pool, runner_id: i64, limit: i64) -> sqlx::Result<Vec<RunnerDecline>> { |
|
| 550 | + | sqlx::query_as( |
|
| 551 | + | "SELECT d.job_id, j.name AS job, r.number AS run_number, rep.owner, rep.name, d.reason, d.ms \ |
|
| 552 | + | FROM flow_declines d JOIN flow_jobs j ON j.id = d.job_id JOIN flow_runs r ON r.id = j.run_id \ |
|
| 553 | + | JOIN repositories rep ON rep.id = r.repo_id WHERE d.runner_id = ? ORDER BY d.ms DESC LIMIT ?", |
|
| 554 | + | ) |
|
| 555 | + | .bind(runner_id) |
|
| 556 | + | .bind(limit) |
|
| 557 | + | .fetch_all(pool) |
|
| 558 | + | .await |
|
| 559 | + | } |
|
| 560 | + | ||
| 561 | + | /// Queued jobs whose dependencies have passed, in their live runs, with |
|
| 562 | + | /// their repositories (owner, id): the ones a runner could take now. |
|
| 563 | + | pub async fn ready_queued(pool: &Pool) -> sqlx::Result<Vec<(Job, String, i64)>> { |
|
| 564 | + | let queued: Vec<Job> = sqlx::query_as( |
|
| 565 | + | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 566 | + | WHERE j.status = 'queued' AND r.status IN ('queued', 'running') ORDER BY j.run_id, j.depth, j.id LIMIT 500", |
|
| 567 | + | ) |
|
| 568 | + | .fetch_all(pool) |
|
| 569 | + | .await?; |
|
| 570 | + | let mut out = Vec::new(); |
|
| 571 | + | for j in queued { |
|
| 572 | + | let mut ready = true; |
|
| 573 | + | for d in j.depends_on() { |
|
| 574 | + | let st: Option<String> = sqlx::query_scalar("SELECT status FROM flow_jobs WHERE run_id = ? AND name = ?") |
|
| 575 | + | .bind(j.run_id) |
|
| 576 | + | .bind(&d) |
|
| 577 | + | .fetch_optional(pool) |
|
| 578 | + | .await?; |
|
| 579 | + | if st.as_deref() != Some("passed") { |
|
| 580 | + | ready = false; |
|
| 581 | + | break; |
|
| 582 | + | } |
|
| 583 | + | } |
|
| 584 | + | if !ready { |
|
| 585 | + | continue; |
|
| 586 | + | } |
|
| 587 | + | let repo: Option<(String, i64)> = |
|
| 588 | + | sqlx::query_as("SELECT rep.owner, rep.id FROM flow_runs r JOIN repositories rep ON rep.id = r.repo_id WHERE r.id = ?") |
|
| 589 | + | .bind(j.run_id) |
|
| 590 | + | .fetch_optional(pool) |
|
| 591 | + | .await?; |
|
| 592 | + | if let Some((owner, id)) = repo { |
|
| 593 | + | out.push((j, owner, id)); |
|
| 594 | + | } |
|
| 595 | + | } |
|
| 596 | + | Ok(out) |
|
| 597 | + | } |
|
| 598 | + | ||
| 599 | + | pub async fn set_waiting(pool: &Pool, job_id: i64, waiting: &str) -> sqlx::Result<()> { |
|
| 600 | + | sqlx::query("UPDATE flow_jobs SET waiting = ? WHERE id = ? AND waiting <> ?") |
|
| 601 | + | .bind(waiting) |
|
| 602 | + | .bind(job_id) |
|
| 603 | + | .bind(waiting) |
|
| 604 | + | .execute(pool) |
|
| 605 | + | .await?; |
|
| 606 | + | Ok(()) |
|
| 446 | 607 | } |
|
| 447 | 608 | ||
| 448 | 609 | /// Append records; returns how many were kept (the rest exceed the cap). |
|
| … | 262 unchanged lines |
modified crates/hub/src/db/runners.rs +37 −1
| … | 3 unchanged lines |
||
| 4 | 4 | use super::{now_ms, Pool}; |
|
| 5 | 5 | use sqlx::FromRow; |
|
| 6 | 6 | ||
| 7 | - | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | #[derive(Clone, Debug, Default, FromRow)] |
|
| 8 | 8 | pub struct Runner { |
|
| 9 | 9 | pub id: i64, |
|
| 10 | 10 | pub name: String, |
|
| … | 20 unchanged lines |
||
| 31 | 31 | pub admin_labels: String, |
|
| 32 | 32 | /// A time limit for its jobs in minutes, below the site's, when set. |
|
| 33 | 33 | pub timeout_minutes: Option<i64>, |
|
| 34 | + | /// The tiers it offered at its last claim, comma-separated. |
|
| 35 | + | pub tiers: String, |
|
| 36 | + | /// The images it held then (a JSON array); empty when it did not say. |
|
| 37 | + | pub images: String, |
|
| 38 | + | /// BLAKE3 of what it offers; its declines hold until this changes. |
|
| 39 | + | pub profile: String, |
|
| 34 | 40 | } |
|
| 35 | 41 | ||
| 36 | 42 | /// Which repositories' jobs a runner may take (oxygenhub#41). |
|
| … | 117 unchanged lines |
||
| 154 | 160 | if let Some(n) = name { |
|
| 155 | 161 | sqlx::query("UPDATE flow_runners SET name = ? WHERE id = ? AND source = 'config'").bind(n).bind(id).execute(pool).await?; |
|
| 156 | 162 | } |
|
| 163 | + | Ok(()) |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | /// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and |
|
| 167 | + | /// images, and the profile hash over those, its architecture, labels and |
|
| 168 | + | /// capacity. |
|
| 169 | + | pub async fn set_offer(pool: &Pool, id: i64, tiers: &[String], images: Option<&[String]>, profile: &str) -> sqlx::Result<()> { |
|
| 170 | + | let images = images.map(|i| serde_json::to_string(i).unwrap_or_default()).unwrap_or_default(); |
|
| 171 | + | sqlx::query("UPDATE flow_runners SET tiers = ?, images = ?, profile = ? WHERE id = ?") |
|
| 172 | + | .bind(tiers.join(",")) |
|
| 173 | + | .bind(images) |
|
| 174 | + | .bind(profile) |
|
| 175 | + | .bind(id) |
|
| 176 | + | .execute(pool) |
|
| 177 | + | .await?; |
|
| 157 | 178 | Ok(()) |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | impl Runner { |
|
| 182 | + | pub fn tier_list(&self) -> Vec<&str> { |
|
| 183 | + | self.tiers.split(',').filter(|t| !t.is_empty()).collect() |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// The images it holds, `None` when it does not say. |
|
| 187 | + | pub fn image_list(&self) -> Option<Vec<String>> { |
|
| 188 | + | if self.images.is_empty() { |
|
| 189 | + | None |
|
| 190 | + | } else { |
|
| 191 | + | serde_json::from_str(&self.images).ok() |
|
| 192 | + | } |
|
| 193 | + | } |
|
| 158 | 194 | } |
|
| 159 | 195 | ||
| 160 | 196 | pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| … | 181 unchanged lines |
modified crates/hub/src/flow.rs +235 −0
| … | 466 unchanged lines |
||
| 467 | 467 | fdb::record_image_archs(&app.db, archs).await?; |
|
| 468 | 468 | } |
|
| 469 | 469 | let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone()); |
|
| 470 | + | // What it offers, kept for the placement pass; a job it declined is |
|
| 471 | + | // not offered to it again while this stays the same (spec §11.5). |
|
| 472 | + | let profile = profile_of(&req, &arch); |
|
| 473 | + | db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), &profile).await?; |
|
| 470 | 474 | let room = fdb::Room { |
|
| 475 | + | declined: fdb::declined_by(&app.db, me.id, &profile).await?, |
|
| 471 | 476 | memory_mib: req.free_memory_mib, |
|
| 472 | 477 | cpus: req.free_cpus, |
|
| 473 | 478 | images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()), |
|
| … | 109 unchanged lines |
||
| 583 | 588 | Ok(Json(claim).into_response()) |
|
| 584 | 589 | } |
|
| 585 | 590 | ||
| 591 | + | /// BLAKE3 over what a claiming runner offers: tiers, images, architecture, |
|
| 592 | + | /// labels and capacity, each list sorted. |
|
| 593 | + | fn profile_of(req: &ClaimRequest, arch: &str) -> String { |
|
| 594 | + | let sorted = |v: &[String]| { |
|
| 595 | + | let mut v = v.to_vec(); |
|
| 596 | + | v.sort(); |
|
| 597 | + | v |
|
| 598 | + | }; |
|
| 599 | + | let info = req.info.as_ref(); |
|
| 600 | + | let v = serde_json::json!({ |
|
| 601 | + | "arch": arch, |
|
| 602 | + | "tiers": sorted(&req.runtimes), |
|
| 603 | + | "images": req.images.as_deref().map(sorted), |
|
| 604 | + | "labels": info.map(|i| sorted(&i.labels)), |
|
| 605 | + | "memory_mib": info.and_then(|i| i.memory_mib), |
|
| 606 | + | "cpus": info.and_then(|i| i.cpus), |
|
| 607 | + | }); |
|
| 608 | + | blake3::hash(v.to_string().as_bytes()).to_hex().to_string() |
|
| 609 | + | } |
|
| 610 | + | ||
| 611 | + | /// A job handed back by this many different runners is unplaceable. |
|
| 612 | + | pub const UNPLACEABLE_AFTER: usize = 3; |
|
| 613 | + | ||
| 614 | + | /// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the |
|
| 615 | + | /// runner will not run a job it was handed. The job is queued again and |
|
| 616 | + | /// not offered to that runner while its profile stays the same (and for |
|
| 617 | + | /// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`] |
|
| 618 | + | /// different runners have declined it, it ends as unplaceable with their |
|
| 619 | + | /// reasons. A decline is never a failed check. |
|
| 620 | + | pub async fn decline( |
|
| 621 | + | State(app): State<Shared>, |
|
| 622 | + | headers: HeaderMap, |
|
| 623 | + | Path(id): Path<i64>, |
|
| 624 | + | Json(d): Json<flow_core::api::Decline>, |
|
| 625 | + | ) -> Result<StatusCode, ApiError> { |
|
| 626 | + | let me = runner_auth(&app, &headers).await?; |
|
| 627 | + | db::runners::seen(&app.db, me.id, None, None).await?; |
|
| 628 | + | let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect(); |
|
| 629 | + | let reason = match reason.trim() { |
|
| 630 | + | "" => "no reason given".to_string(), |
|
| 631 | + | r => r.to_string(), |
|
| 632 | + | }; |
|
| 633 | + | let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else { |
|
| 634 | + | return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into())); |
|
| 635 | + | }; |
|
| 636 | + | tracing::info!("flow: runner {} declined job {id}: {reason}", me.name); |
|
| 637 | + | let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?; |
|
| 638 | + | let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect(); |
|
| 639 | + | if runners.len() >= UNPLACEABLE_AFTER { |
|
| 640 | + | let error = format!( |
|
| 641 | + | "unplaceable: declined by {} runners: {}", |
|
| 642 | + | runners.len(), |
|
| 643 | + | all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ") |
|
| 644 | + | ); |
|
| 645 | + | let changed = |
|
| 646 | + | fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" }) |
|
| 647 | + | .await?; |
|
| 648 | + | report_changed(&app, job.run_id, changed).await?; |
|
| 649 | + | } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? { |
|
| 650 | + | if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? { |
|
| 651 | + | let summary = format!("declined by {}: {reason}; waiting for another runner", me.name); |
|
| 652 | + | report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await; |
|
| 653 | + | fdb::set_waiting(&app.db, id, &summary).await?; |
|
| 654 | + | } |
|
| 655 | + | } |
|
| 656 | + | Ok(StatusCode::NO_CONTENT) |
|
| 657 | + | } |
|
| 658 | + | ||
| 659 | + | /// Why no runner can take `job` of a repository of `owner` (id `repo_id`) |
|
| 660 | + | /// now (spec §11.4), from what each runner offered at its last claim: |
|
| 661 | + | /// empty when one could (it waits its turn or a free slot), else what |
|
| 662 | + | /// each one lacks. `declines` are the job's. |
|
| 663 | + | pub fn why_waiting( |
|
| 664 | + | job: &fdb::Job, |
|
| 665 | + | owner: &str, |
|
| 666 | + | repo_id: i64, |
|
| 667 | + | runners: &[db::runners::Runner], |
|
| 668 | + | image_archs: &std::collections::HashMap<String, String>, |
|
| 669 | + | declines: &[fdb::DeclineRow], |
|
| 670 | + | now: i64, |
|
| 671 | + | ) -> String { |
|
| 672 | + | let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default(); |
|
| 673 | + | let rootfs = sandbox["rootfs"].as_str().unwrap_or(""); |
|
| 674 | + | let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned()); |
|
| 675 | + | let need = |k: &str| sandbox[k].as_u64().unwrap_or(0); |
|
| 676 | + | let needs = job.needs(); |
|
| 677 | + | let lacks = |r: &db::runners::Runner| -> Option<String> { |
|
| 678 | + | if r.state == "draining" { |
|
| 679 | + | return Some("is draining".into()); |
|
| 680 | + | } |
|
| 681 | + | let scope = r.scope(); |
|
| 682 | + | if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) { |
|
| 683 | + | return Some("serves other repositories".into()); |
|
| 684 | + | } |
|
| 685 | + | let tiers = r.tier_list(); |
|
| 686 | + | if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) { |
|
| 687 | + | return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", "))); |
|
| 688 | + | } |
|
| 689 | + | if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) { |
|
| 690 | + | return Some(format!("is {}; the job needs {w}", r.arch)); |
|
| 691 | + | } |
|
| 692 | + | if r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) { |
|
| 693 | + | return Some(format!("does not hold image {}", &rootfs[..12.min(rootfs.len())])); |
|
| 694 | + | } |
|
| 695 | + | let labels = r.label_list(); |
|
| 696 | + | if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) { |
|
| 697 | + | return Some(format!("lacks label {l}")); |
|
| 698 | + | } |
|
| 699 | + | if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) { |
|
| 700 | + | return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib"))); |
|
| 701 | + | } |
|
| 702 | + | if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) { |
|
| 703 | + | return Some(format!("has {c} CPUs; the job needs {}", need("cpus"))); |
|
| 704 | + | } |
|
| 705 | + | if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) { |
|
| 706 | + | return Some(format!("declined it: {}", d.reason)); |
|
| 707 | + | } |
|
| 708 | + | None |
|
| 709 | + | }; |
|
| 710 | + | let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect(); |
|
| 711 | + | if online.is_empty() { |
|
| 712 | + | return "no runner is online".into(); |
|
| 713 | + | } |
|
| 714 | + | let mut why = Vec::new(); |
|
| 715 | + | for r in &online { |
|
| 716 | + | match lacks(r) { |
|
| 717 | + | None => return String::new(), |
|
| 718 | + | Some(w) => why.push(format!("{} {w}", r.name)), |
|
| 719 | + | } |
|
| 720 | + | } |
|
| 721 | + | let more = why.len().saturating_sub(4); |
|
| 722 | + | why.truncate(4); |
|
| 723 | + | let mut text = format!("no runner can take it: {}", why.join("; ")); |
|
| 724 | + | if more > 0 { |
|
| 725 | + | text.push_str(&format!("; and {more} more")); |
|
| 726 | + | } |
|
| 727 | + | text |
|
| 728 | + | } |
|
| 729 | + | ||
| 730 | + | /// One placement pass: say why each ready queued job waits. |
|
| 731 | + | pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> { |
|
| 732 | + | let ready = fdb::ready_queued(&app.db).await?; |
|
| 733 | + | if ready.is_empty() { |
|
| 734 | + | return Ok(()); |
|
| 735 | + | } |
|
| 736 | + | let runners = db::runners::list(&app.db).await?; |
|
| 737 | + | let archs = fdb::image_archs(&app.db).await?; |
|
| 738 | + | let now = db::now_ms(); |
|
| 739 | + | for (job, owner, repo_id) in ready { |
|
| 740 | + | let declines = fdb::declines_of_job(&app.db, job.id).await?; |
|
| 741 | + | let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now); |
|
| 742 | + | fdb::set_waiting(&app.db, job.id, &why).await?; |
|
| 743 | + | } |
|
| 744 | + | Ok(()) |
|
| 745 | + | } |
|
| 746 | + | ||
| 747 | + | /// Run a placement pass every 15 seconds. |
|
| 748 | + | pub async fn placement(app: Shared) { |
|
| 749 | + | loop { |
|
| 750 | + | tokio::time::sleep(std::time::Duration::from_secs(15)).await; |
|
| 751 | + | if let Err(e) = explain_waits(&app).await { |
|
| 752 | + | tracing::warn!("flow: placement pass: {e}"); |
|
| 753 | + | } |
|
| 754 | + | } |
|
| 755 | + | } |
|
| 756 | + | ||
| 586 | 757 | /// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads). |
|
| 587 | 758 | async fn pins_in( |
|
| 588 | 759 | app: &Shared, |
|
| … | 566 unchanged lines |
||
| 1155 | 1326 | assert_eq!( |
|
| 1156 | 1327 | [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)], |
|
| 1157 | 1328 | ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"] |
|
| 1329 | + | ); |
|
| 1330 | + | } |
|
| 1331 | + | } |
|
| 1332 | + | ||
| 1333 | + | #[cfg(test)] |
|
| 1334 | + | mod placement_tests { |
|
| 1335 | + | use super::*; |
|
| 1336 | + | ||
| 1337 | + | #[test] |
|
| 1338 | + | fn a_waiting_job_says_what_each_runner_lacks() { |
|
| 1339 | + | use crate::db::runners::Runner; |
|
| 1340 | + | let rootfs = "ab".repeat(32); |
|
| 1341 | + | let job = fdb::Job { |
|
| 1342 | + | id: 7, |
|
| 1343 | + | runtime: "syscall".into(), |
|
| 1344 | + | sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(), |
|
| 1345 | + | spec: serde_json::json!({ "name": "build" }).to_string(), |
|
| 1346 | + | ..Default::default() |
|
| 1347 | + | }; |
|
| 1348 | + | let now = db::now_ms(); |
|
| 1349 | + | let runner = |id: i64, name: &str| Runner { |
|
| 1350 | + | id, |
|
| 1351 | + | name: name.into(), |
|
| 1352 | + | state: "active".into(), |
|
| 1353 | + | last_seen_ms: Some(now), |
|
| 1354 | + | arch: "x86_64".into(), |
|
| 1355 | + | tiers: "microvm,syscall".into(), |
|
| 1356 | + | images: serde_json::to_string(&[&rootfs]).unwrap(), |
|
| 1357 | + | memory_mib: Some(16384), |
|
| 1358 | + | cpus: Some(8), |
|
| 1359 | + | ..Default::default() |
|
| 1360 | + | }; |
|
| 1361 | + | let archs = std::collections::HashMap::new(); |
|
| 1362 | + | let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| why_waiting(&job, "andy", 1, runners, &archs, declines, now); |
|
| 1363 | + | assert_eq!(why(&[], &[]), "no runner is online"); |
|
| 1364 | + | assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn"); |
|
| 1365 | + | let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") }; |
|
| 1366 | + | let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") }; |
|
| 1367 | + | let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") }; |
|
| 1368 | + | let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") }; |
|
| 1369 | + | let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") }; |
|
| 1370 | + | assert_eq!( |
|
| 1371 | + | why(&[microvm_only, small, other, offline], &[]), |
|
| 1372 | + | "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories" |
|
| 1373 | + | ); |
|
| 1374 | + | let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into(); |
|
| 1375 | + | assert_eq!( |
|
| 1376 | + | why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now), |
|
| 1377 | + | "no runner can take it: narvi is riscv64; the job needs x86_64" |
|
| 1378 | + | ); |
|
| 1379 | + | let without = Runner { images: "[]".into(), ..runner(6, "new") }; |
|
| 1380 | + | assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab"); |
|
| 1381 | + | let d = fdb::DeclineRow { |
|
| 1382 | + | job_id: 7, |
|
| 1383 | + | runner_id: 1, |
|
| 1384 | + | runner: "earth-1".into(), |
|
| 1385 | + | reason: "only 512 MiB of memory is free here".into(), |
|
| 1386 | + | ms: now, |
|
| 1387 | + | }; |
|
| 1388 | + | assert_eq!( |
|
| 1389 | + | why(&[runner(1, "earth-1")], std::slice::from_ref(&d)), |
|
| 1390 | + | "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here" |
|
| 1158 | 1391 | ); |
|
| 1392 | + | let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d }; |
|
| 1393 | + | assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds"); |
|
| 1159 | 1394 | } |
|
| 1160 | 1395 | } |
modified crates/hub/src/lib.rs +1 −0
| … | 113 unchanged lines |
||
| 114 | 114 | tokio::spawn(hooks::worker(app.clone())); |
|
| 115 | 115 | tokio::spawn(flow::reaper(app.clone())); |
|
| 116 | 116 | tokio::spawn(flow::scheduler(app.clone())); |
|
| 117 | + | tokio::spawn(flow::placement(app.clone())); |
|
| 117 | 118 | tokio::spawn(maintenance::nightly(app.clone())); |
|
| 118 | 119 | Ok(app) |
|
| 119 | 120 | } |
|
| … | 42 unchanged lines |
modified crates/hub/src/web/runners.rs +3 −1
| … | 280 unchanged lines |
||
| 281 | 281 | r: runners::Runner, |
|
| 282 | 282 | running: i64, |
|
| 283 | 283 | history: Vec<runners::RunnerJob>, |
|
| 284 | + | declines: Vec<crate::db::flow::RunnerDecline>, |
|
| 284 | 285 | older: Option<i64>, |
|
| 285 | 286 | jobs: i64, |
|
| 286 | 287 | passed: i64, |
|
| … | 30 unchanged lines |
||
| 317 | 318 | history.truncate(PER_PAGE as usize); |
|
| 318 | 319 | let (jobs, passed, busy_ms) = runners::totals(&app.db, id).await.map_err(e)?; |
|
| 319 | 320 | let running = runners::running(&app.db).await.map_err(e)?.into_iter().find(|(rid, _)| *rid == id).map(|(_, n)| n).unwrap_or(0); |
|
| 320 | - | Ok(Page(RunnerPage { shell, r, running, history, older, jobs, passed, busy_ms })) |
|
| 321 | + | let declines = crate::db::flow::declines_of_runner(&app.db, id, 20).await.map_err(e)?; |
|
| 322 | + | Ok(Page(RunnerPage { shell, r, running, history, declines, older, jobs, passed, busy_ms })) |
|
| 321 | 323 | } |
|
| 322 | 324 | ||
| 323 | 325 | pub fn duration(j: &runners::RunnerJob) -> String { |
|
| … | 83 unchanged lines |
modified crates/hub/tests/flow.rs +62 −1
| … | 41 unchanged lines |
||
| 42 | 42 | let dir = tempfile::tempdir().unwrap(); |
|
| 43 | 43 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 44 | 44 | config.auth.push_tokens = vec!["site".into()]; |
|
| 45 | - | config.flow.runner_tokens = vec!["runner-secret".into()]; |
|
| 45 | + | config.flow.runner_tokens = vec!["runner-secret".into(), "runner-two".into(), "runner-three".into()]; |
|
| 46 | 46 | config.flow.repos = vec!["andy/*".into()]; |
|
| 47 | 47 | config.registration.mode = "open".into(); |
|
| 48 | 48 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| … | 2016 unchanged lines |
||
| 2065 | 2065 | let (_, queue) = call(app, "GET", "/admin/runners", None, None).await; |
|
| 2066 | 2066 | let _ = queue; |
|
| 2067 | 2067 | } |
|
| 2068 | + | ||
| 2069 | + | /// OxygenFlow spec §11.5: a runner hands back a job it cannot run. The |
|
| 2070 | + | /// job is queued again and not offered to that runner again, the decline |
|
| 2071 | + | /// shows on the run's page and is kept for the runner's, and once three |
|
| 2072 | + | /// runners have declined it the job ends as unplaceable with their reasons. |
|
| 2073 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2074 | + | async fn a_declined_job_goes_to_another_runner_or_ends_unplaceable() { |
|
| 2075 | + | let h = hub().await; |
|
| 2076 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 2077 | + | create(&h.app, "andy", "declines").await; |
|
| 2078 | + | let url = format!("{}/andy/declines", h.base); |
|
| 2079 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2080 | + | let c = client(&url, work.path()); |
|
| 2081 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 2082 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 2083 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 2084 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2085 | + | let run = runs_of(&h.app, "andy", "declines").await[0].clone(); |
|
| 2086 | + | let claim_as = |token: &'static str, name: &'static str| { |
|
| 2087 | + | let app = h.app.clone(); |
|
| 2088 | + | async move { |
|
| 2089 | + | let body = serde_json::json!({ "runner": name, "runtimes": ["microvm"] }).to_string(); |
|
| 2090 | + | let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some(token), Some(body)).await; |
|
| 2091 | + | (st == StatusCode::OK).then(|| serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap()) |
|
| 2092 | + | } |
|
| 2093 | + | }; |
|
| 2094 | + | let decline = |token: &'static str, id: i64, reason: &'static str| { |
|
| 2095 | + | let app = h.app.clone(); |
|
| 2096 | + | async move { |
|
| 2097 | + | let body = serde_json::json!({ "reason": reason }).to_string(); |
|
| 2098 | + | call(&app, "POST", &format!("/api/v1/flow/jobs/{id}/decline"), Some(token), Some(body)).await.0 |
|
| 2099 | + | } |
|
| 2100 | + | }; |
|
| 2101 | + | let job = claim_as("runner-secret", "earth-1").await.expect("a job"); |
|
| 2102 | + | // The other runners are known to the hub before they decline anything. |
|
| 2103 | + | assert_eq!(decline("runner-two", job, "not mine").await, StatusCode::CONFLICT, "only the runner it runs on may decline it"); |
|
| 2104 | + | assert_eq!(decline("runner-secret", job, "the MicroVM tier needs /dev/kvm").await, StatusCode::NO_CONTENT); |
|
| 2105 | + | let j = db::flow::job(&h.app.db, job).await.unwrap().unwrap(); |
|
| 2106 | + | assert_eq!((j.status.as_str(), j.runner.as_str()), ("queued", "")); |
|
| 2107 | + | assert_eq!(j.waiting, "declined by earth-1: the MicroVM tier needs /dev/kvm; waiting for another runner"); |
|
| 2108 | + | // earth-1 is offered the other ready job, not this one, while it stays the same. |
|
| 2109 | + | let next = claim_as("runner-secret", "earth-1").await.expect("the other ready job"); |
|
| 2110 | + | assert_ne!(next, job); |
|
| 2111 | + | let page = call(&h.app, "GET", &format!("/andy/declines/flow/{}", run.number), None, None).await.1; |
|
| 2112 | + | assert!(page.contains("Waiting: declined by earth-1: the MicroVM tier needs /dev/kvm"), "{page}"); |
|
| 2113 | + | assert_eq!(claim_as("runner-two", "plex-1").await, Some(job), "another runner takes it"); |
|
| 2114 | + | assert_eq!(decline("runner-two", job, "rootfs 36fc4e323804 is not on this runner").await, StatusCode::NO_CONTENT); |
|
| 2115 | + | assert_eq!(claim_as("runner-three", "narvi").await, Some(job)); |
|
| 2116 | + | assert_eq!(decline("runner-three", job, "rootfs 36fc4e323804 is for x86_64; this runner is riscv64").await, StatusCode::NO_CONTENT); |
|
| 2117 | + | let j = db::flow::job(&h.app.db, job).await.unwrap().unwrap(); |
|
| 2118 | + | assert_eq!(j.status, "error"); |
|
| 2119 | + | assert_eq!( |
|
| 2120 | + | j.error, |
|
| 2121 | + | "unplaceable: declined by 3 runners: earth-1: the MicroVM tier needs /dev/kvm; plex-1: rootfs 36fc4e323804 is not on this runner; narvi: rootfs 36fc4e323804 is for x86_64; this runner is riscv64" |
|
| 2122 | + | ); |
|
| 2123 | + | let r = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex-1").unwrap(); |
|
| 2124 | + | let declined = db::flow::declines_of_runner(&h.app.db, r.id, 10).await.unwrap(); |
|
| 2125 | + | assert_eq!(declined.len(), 1); |
|
| 2126 | + | assert_eq!((declined[0].job_id, declined[0].reason.as_str()), (job, "rootfs 36fc4e323804 is not on this runner")); |
|
| 2127 | + | assert_eq!(r.tier_list(), vec!["microvm"]); |
|
| 2128 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified templates/admin_runner.html +15 −0
| … | 8 unchanged lines |
||
| 9 | 9 | <dt>Machine</dt><dd>{% if r.arch.is_empty() %}<span class="mute">not reported yet</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</dd> |
|
| 10 | 10 | <dt>Labels</dt><dd>{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}{% if r.label_list().is_empty() %}<span class="mute">none</span>{% endif %}{% if !r.admin_label_list().is_empty() %} <span class="mute small">({{ r.admin_label_list().len() }} set here)</span>{% endif %}</dd> |
|
| 11 | 11 | <dt>Time limit</dt><dd>{% if let Some(m) = r.timeout_minutes %}{{ m }} min for its jobs, below the site's{% else %}<span class="mute">the site's</span>{% endif %}</dd> |
|
| 12 | + | <dt>Tiers</dt><dd>{% if r.tier_list().is_empty() %}<span class="mute">not reported yet</span>{% else %}{{ r.tier_list().join(", ") }}{% endif %}{% if let Some(i) = r.image_list() %} · {{ i.len()|plural("image", "images") }} held{% endif %}</dd> |
|
| 12 | 13 | <dt>Capacity</dt><dd>{{ r.max_jobs|plural("job", "jobs") }} at once{% if let Some(m) = r.memory_mib %}, {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %}, {{ c }} vCPU{% endif %}</dd> |
|
| 13 | 14 | <dt>Running now</dt><dd>{{ running|plural("job", "jobs") }}</dd> |
|
| 14 | 15 | <dt>Ran</dt><dd>{{ jobs|plural("job", "jobs") }}, {{ passed }} passed{% if jobs > 0 %} ({{ passed * 100 / jobs }}%){% endif %}, busy {{ self.busy() }}</dd> |
|
| … | 12 unchanged lines |
||
| 27 | 28 | <button type="submit">SAVE</button> |
|
| 28 | 29 | </form> |
|
| 29 | 30 | </section> |
|
| 31 | + | {% if !declines.is_empty() %}<section class="panel"> |
|
| 32 | + | <h2>Declined</h2> |
|
| 33 | + | <p class="small mute">Jobs this runner handed back before running them, and why (OxygenFlow spec §11.5).</p> |
|
| 34 | + | <div class="table"><table> |
|
| 35 | + | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Reason</th><th>When</th></tr> |
|
| 36 | + | {% for d in declines %}<tr> |
|
| 37 | + | <td><a href="/{{ d.owner }}/{{ d.name }}">{{ d.owner }}/{{ d.name }}</a></td> |
|
| 38 | + | <td><a href="/{{ d.owner }}/{{ d.name }}/flow/{{ d.run_number }}">#{{ d.run_number }}</a></td> |
|
| 39 | + | <td>{{ d.job }}</td> |
|
| 40 | + | <td>{{ d.reason }}</td> |
|
| 41 | + | <td class="small"><time title="{{ d.ms|when_i }}">{{ d.ms|ago_i }}</time></td> |
|
| 42 | + | </tr>{% endfor %} |
|
| 43 | + | </table></div> |
|
| 44 | + | </section>{% endif %} |
|
| 30 | 45 | <section class="panel"> |
|
| 31 | 46 | <h2>Jobs</h2> |
|
| 32 | 47 | {% if history.is_empty() %}<p class="mute">No jobs yet.</p>{% else %} |
|
| … | 16 unchanged lines |
modified templates/flow_run.html +1 −1
| … | 36 unchanged lines |
||
| 37 | 37 | {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if !releases.is_empty() %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 38 | 38 | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| 39 | 39 | {% endfor %}</pre> |
|
| 40 | - | {% if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| 40 | + | {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| 41 | 41 | {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %} |
|
| 42 | 42 | </details> |
|
| 43 | 43 | {% endfor %} |
|
| … | 2 unchanged lines |