Placement ranks runners and says why a job ran where it did 501d8b268dd4
When a runner claims, a job another free runner would do better (it holds the job's image while the claimant would fetch it, or it has a higher speed class), that polled in the last 10 s, is held from the claimant for up to 15 s; after that any runner that fits takes it. The fit check behind the waiting reasons is now one function both use. Each job records why it ran where it did (migration 0055): 'holds the image, idle, speed 100 (class 3)', with 'after N s held for a better runner' when it was; shown on the run page, in the API and by oh run view. refs andymccall/oxygenflow#42
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 22:49 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
501d8b268dd41fb957743014aff920aa0e26226d075e1d2fff07ebf54a46f65e- Depends on
- 0b203bbf7cc3 42fa95540468 44a3d2ebaddd 4d2e44d79b03 5c6e92fd8d71 67040b158ae2 74be97962ed8 7f5f6c056d8f 90326c9601f9 92a797b20912 93be9ce4653e 941abdd6bb4c b6b7e4cf6b24 c4c87f481d05 cc19697f8871 d23464c0ac60
- Depended on by
- 1ec29691b67a b1860d6090ec 0468394734e9 951aed50c2f5 d7050703d2f4 56b8dbe66974 5588bb3631d2 858eca0d03c4 58cbf42447bc 3b1bc27fe805 5d90e0e045e2 417184c73f4e d9b3b84605a4 e8fa53694f68 c2be148cfe83 4fa0da126b70 c95446a17fdd 1c8276b90314 20b8fe85888b
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check 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
- 24 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/hub/migrations/0055_job_placed.sql +3 −0
| 1 | + | -- Why a job ran where it did (oxygenflow#42, Jetstream §7): "holds the |
|
| 2 | + | -- image, idle, speed 100 (class 3)". |
|
| 3 | + | ALTER TABLE flow_jobs ADD COLUMN placed TEXT NOT NULL DEFAULT ''; |
modified crates/hub-api/src/lib.rs +4 −0
| … | 554 unchanged lines |
||
| 555 | 555 | /// empty when it only waits its turn. |
|
| 556 | 556 | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 557 | 557 | pub waiting: String, |
|
| 558 | + | /// Why it ran on its runner (oxygenflow#42): "holds the image, idle, |
|
| 559 | + | /// speed 100 (class 3)". |
|
| 560 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 561 | + | pub placed: String, |
|
| 558 | 562 | } |
|
| 559 | 563 | ||
| 560 | 564 | /// A line of a job's log. |
|
| … | 694 unchanged lines |
modified crates/hub/src/api/mod.rs +1 −0
| … | 1867 unchanged lines |
||
| 1868 | 1868 | finished_ms: j.finished_ms, |
|
| 1869 | 1869 | log_lines: j.log_count, |
|
| 1870 | 1870 | waiting: j.waiting, |
|
| 1871 | + | placed: j.placed, |
|
| 1871 | 1872 | }) |
|
| 1872 | 1873 | .collect(), |
|
| 1873 | 1874 | } |
|
| … | 532 unchanged lines |
modified crates/hub/src/db/flow.rs +27 −0
| … | 94 unchanged lines |
||
| 95 | 95 | pub peak_memory_mib: Option<i64>, |
|
| 96 | 96 | /// The free disk it needs, learned from past runs of the same job. |
|
| 97 | 97 | pub need_disk_mib: Option<i64>, |
|
| 98 | + | /// Why it ran on its runner (oxygenflow#42). |
|
| 99 | + | pub placed: String, |
|
| 98 | 100 | } |
|
| 99 | 101 | ||
| 100 | 102 | impl Job { |
|
| … | 45 unchanged lines |
||
| 146 | 148 | Ok(peak.map(|p| p + p / 4 + 64)) |
|
| 147 | 149 | } |
|
| 148 | 150 | ||
| 151 | + | /// Record why job `job_id` ran where it did. |
|
| 152 | + | pub async fn set_placed(pool: &Pool, job_id: i64, placed: &str) -> sqlx::Result<()> { |
|
| 153 | + | sqlx::query("UPDATE flow_jobs SET placed = ? WHERE id = ?").bind(placed).bind(job_id).execute(pool).await?; |
|
| 154 | + | Ok(()) |
|
| 155 | + | } |
|
| 156 | + | ||
| 149 | 157 | /// Keep what a job took, as its runner reported. |
|
| 150 | 158 | pub async fn set_peaks(pool: &Pool, job_id: i64, disk_mib: Option<u64>, memory_mib: Option<u64>) -> sqlx::Result<()> { |
|
| 151 | 159 | sqlx::query("UPDATE flow_jobs SET peak_disk_mib = ?, peak_memory_mib = ? WHERE id = ?") |
|
| … | 296 unchanged lines |
||
| 448 | 456 | scope: &super::runners::Scope, |
|
| 449 | 457 | labels: &[String], |
|
| 450 | 458 | ) -> sqlx::Result<Option<Job>> { |
|
| 459 | + | claim_ranked(pool, runtimes, runner, token_hash, room, scope, labels, &|_| false).await |
|
| 460 | + | } |
|
| 461 | + | ||
| 462 | + | /// [`claim_within`], passing over a job `elsewhere` says a better runner |
|
| 463 | + | /// should take (oxygenflow#42). |
|
| 464 | + | #[allow(clippy::too_many_arguments)] |
|
| 465 | + | pub async fn claim_ranked( |
|
| 466 | + | pool: &Pool, |
|
| 467 | + | runtimes: &[String], |
|
| 468 | + | runner: &str, |
|
| 469 | + | token_hash: &str, |
|
| 470 | + | room: Room, |
|
| 471 | + | scope: &super::runners::Scope, |
|
| 472 | + | labels: &[String], |
|
| 473 | + | elsewhere: &(dyn Fn(&Job) -> bool + Sync), |
|
| 474 | + | ) -> sqlx::Result<Option<Job>> { |
|
| 451 | 475 | let mut tx = super::begin_write(pool).await?; |
|
| 452 | 476 | let queued: Vec<Job> = sqlx::query_as( |
|
| 453 | 477 | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \ |
|
| … | 35 unchanged lines |
||
| 489 | 513 | if passed != deps.len() as i64 { |
|
| 490 | 514 | continue; |
|
| 491 | 515 | } |
|
| 516 | + | } |
|
| 517 | + | if elsewhere(&j) { |
|
| 518 | + | continue; |
|
| 492 | 519 | } |
|
| 493 | 520 | let now = now_ms(); |
|
| 494 | 521 | let done = sqlx::query( |
|
| … | 446 unchanged lines |
modified crates/hub/src/flow.rs +148 −53
| … | 515 unchanged lines |
||
| 516 | 516 | labels.push(l.to_string()); |
|
| 517 | 517 | } |
|
| 518 | 518 | } |
|
| 519 | + | // Ranking (oxygenflow#42): a job another free runner would do better |
|
| 520 | + | // (it holds the image, or is faster) is held for it briefly. |
|
| 521 | + | let runners = db::runners::list(&app.db).await?; |
|
| 522 | + | let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone()); |
|
| 523 | + | let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect(); |
|
| 524 | + | let owners: std::collections::HashMap<i64, (String, i64)> = |
|
| 525 | + | fdb::queue(&app.db).await?.into_iter().map(|q| (q.job_id, (q.owner, q.repo_id))).collect(); |
|
| 526 | + | let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?); |
|
| 527 | + | let now = db::now_ms(); |
|
| 528 | + | let fleet = Fleet { image_archs: &archs, hub_images: &held, now }; |
|
| 529 | + | let better = |j: &fdb::Job| -> bool { |
|
| 530 | + | let Some((owner, repo_id)) = owners.get(&j.id) else { return false }; |
|
| 531 | + | let rootfs = rootfs_of(j); |
|
| 532 | + | let mut holds = HELD.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 533 | + | let holds = holds.get_or_insert_with(Default::default); |
|
| 534 | + | holds.retain(|_, t| now - *t < 600_000); |
|
| 535 | + | if holds.get(&j.id).is_some_and(|t| now - *t >= BETTER_HOLD_MS) { |
|
| 536 | + | return false; |
|
| 537 | + | } |
|
| 538 | + | let mine = rank(&me_now, &rootfs); |
|
| 539 | + | let other = runners.iter().any(|r| { |
|
| 540 | + | r.id != me.id |
|
| 541 | + | && r.state == "active" |
|
| 542 | + | && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS) |
|
| 543 | + | && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1) |
|
| 544 | + | && rank(r, &rootfs) > mine |
|
| 545 | + | && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none() |
|
| 546 | + | }); |
|
| 547 | + | if other { |
|
| 548 | + | holds.entry(j.id).or_insert(now); |
|
| 549 | + | } |
|
| 550 | + | other |
|
| 551 | + | }; |
|
| 519 | 552 | let Some(job) = |
|
| 520 | - | fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await? |
|
| 553 | + | fdb::claim_ranked(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels, &better).await? |
|
| 521 | 554 | else { |
|
| 522 | 555 | return Ok(StatusCode::NO_CONTENT.into_response()); |
|
| 523 | 556 | }; |
|
| 557 | + | // Why here (oxygenflow#42). |
|
| 558 | + | let waited = HELD.lock().unwrap_or_else(|p| p.into_inner()).as_mut().and_then(|h| h.remove(&job.id)).map(|t| now - t); |
|
| 559 | + | let rootfs = rootfs_of(&job); |
|
| 560 | + | let mut why = vec![if me_now.image_list().is_some_and(|have| have.contains(&rootfs)) { |
|
| 561 | + | "holds the image".to_string() |
|
| 562 | + | } else { |
|
| 563 | + | "fetches the image from the hub".to_string() |
|
| 564 | + | }]; |
|
| 565 | + | match running.get(&me.id).copied().unwrap_or(0) { |
|
| 566 | + | 0 => why.push("idle".into()), |
|
| 567 | + | n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })), |
|
| 568 | + | } |
|
| 569 | + | if let Some(sp) = me_now.speed { |
|
| 570 | + | why.push(format!("speed {sp} (class {})", me_now.speed_class.unwrap_or(0))); |
|
| 571 | + | } |
|
| 572 | + | if let Some(w) = waited { |
|
| 573 | + | why.push(format!("after {} s held for a better runner", w / 1000)); |
|
| 574 | + | } |
|
| 575 | + | fdb::set_placed(&app.db, job.id, &why.join(", ")).await?; |
|
| 524 | 576 | db::runners::set_job_runner(&app.db, job.id, me.id).await?; |
|
| 525 | 577 | let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 526 | 578 | let row = |
|
| … | 153 unchanged lines |
||
| 680 | 732 | } |
|
| 681 | 733 | } |
|
| 682 | 734 | Ok(StatusCode::NO_CONTENT) |
|
| 735 | + | } |
|
| 736 | + | ||
| 737 | + | /// Why no runner can take `job` of a repository of `owner` (id `repo_id`) |
|
| 738 | + | /// now (spec §11.4), from what each runner offered at its last claim: |
|
| 739 | + | /// empty when one could (it waits its turn or a free slot), else what |
|
| 740 | + | /// each one lacks. `declines` are the job's. |
|
| 741 | + | /// What placement knows of the fleet: images' architectures, the images |
|
| 742 | + | /// the hub holds, and the time. |
|
| 743 | + | pub struct Fleet<'a> { |
|
| 744 | + | pub image_archs: &'a std::collections::HashMap<String, String>, |
|
| 745 | + | pub hub_images: &'a std::collections::HashSet<String>, |
|
| 746 | + | pub now: i64, |
|
| 747 | + | } |
|
| 748 | + | ||
| 749 | + | /// Why runner `r` cannot take `job` of a repository of `owner` (id |
|
| 750 | + | /// `repo_id`), from what it offered at its last claim (spec §11.4); `None` |
|
| 751 | + | /// when it can. `declines` are the job's. |
|
| 752 | + | pub fn runner_lacks( |
|
| 753 | + | job: &fdb::Job, |
|
| 754 | + | owner: &str, |
|
| 755 | + | repo_id: i64, |
|
| 756 | + | r: &db::runners::Runner, |
|
| 757 | + | fleet: &Fleet, |
|
| 758 | + | declines: &[fdb::DeclineRow], |
|
| 759 | + | ) -> Option<String> { |
|
| 760 | + | let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default(); |
|
| 761 | + | let rootfs = sandbox["rootfs"].as_str().unwrap_or(""); |
|
| 762 | + | let wanted_arch = job.arch().or_else(|| fleet.image_archs.get(rootfs).cloned()); |
|
| 763 | + | let need = |k: &str| sandbox[k].as_u64().unwrap_or(0); |
|
| 764 | + | if r.state == "draining" { |
|
| 765 | + | return Some("is draining".into()); |
|
| 766 | + | } |
|
| 767 | + | let scope = r.scope(); |
|
| 768 | + | if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) { |
|
| 769 | + | return Some("serves other repositories".into()); |
|
| 770 | + | } |
|
| 771 | + | let tiers = r.tier_list(); |
|
| 772 | + | if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) { |
|
| 773 | + | return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", "))); |
|
| 774 | + | } |
|
| 775 | + | if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) { |
|
| 776 | + | return Some(format!("is {}; the job needs {w}", r.arch)); |
|
| 777 | + | } |
|
| 778 | + | let fetches = r.fetches_images && fleet.hub_images.contains(rootfs); |
|
| 779 | + | if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) { |
|
| 780 | + | let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" }; |
|
| 781 | + | return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())])); |
|
| 782 | + | } |
|
| 783 | + | let labels = r.label_list(); |
|
| 784 | + | if let Some(l) = job.needs().iter().find(|l| !labels.contains(&l.as_str())) { |
|
| 785 | + | return Some(format!("lacks label {l}")); |
|
| 786 | + | } |
|
| 787 | + | if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) { |
|
| 788 | + | return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib"))); |
|
| 789 | + | } |
|
| 790 | + | if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) { |
|
| 791 | + | return Some(format!("has {c} CPUs; the job needs {}", need("cpus"))); |
|
| 792 | + | } |
|
| 793 | + | // The disk it learned it needs (oxygenflow#41). |
|
| 794 | + | if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) { |
|
| 795 | + | if free < n { |
|
| 796 | + | return Some(format!( |
|
| 797 | + | "has {} free; the job needs ~{} (its recent runs took up to {})", |
|
| 798 | + | mib(free), |
|
| 799 | + | mib(n), |
|
| 800 | + | mib((n - 64) * 4 / 5) |
|
| 801 | + | )); |
|
| 802 | + | } |
|
| 803 | + | } |
|
| 804 | + | if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && fleet.now - d.ms < fdb::DECLINE_HOLDS_MS) { |
|
| 805 | + | return Some(format!("declined it: {}", d.reason)); |
|
| 806 | + | } |
|
| 807 | + | None |
|
| 683 | 808 | } |
|
| 684 | 809 | ||
| 685 | 810 | /// Why no runner can take `job` of a repository of `owner` (id `repo_id`) |
|
| … | 11 unchanged lines |
||
| 697 | 822 | now: i64, |
|
| 698 | 823 | hub_images: &std::collections::HashSet<String>, |
|
| 699 | 824 | ) -> String { |
|
| 700 | - | let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default(); |
|
| 701 | - | let rootfs = sandbox["rootfs"].as_str().unwrap_or(""); |
|
| 702 | - | let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned()); |
|
| 703 | - | let need = |k: &str| sandbox[k].as_u64().unwrap_or(0); |
|
| 704 | - | let needs = job.needs(); |
|
| 705 | - | let lacks = |r: &db::runners::Runner| -> Option<String> { |
|
| 706 | - | if r.state == "draining" { |
|
| 707 | - | return Some("is draining".into()); |
|
| 708 | - | } |
|
| 709 | - | let scope = r.scope(); |
|
| 710 | - | if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) { |
|
| 711 | - | return Some("serves other repositories".into()); |
|
| 712 | - | } |
|
| 713 | - | let tiers = r.tier_list(); |
|
| 714 | - | if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) { |
|
| 715 | - | return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", "))); |
|
| 716 | - | } |
|
| 717 | - | if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) { |
|
| 718 | - | return Some(format!("is {}; the job needs {w}", r.arch)); |
|
| 719 | - | } |
|
| 720 | - | let fetches = r.fetches_images && hub_images.contains(rootfs); |
|
| 721 | - | if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) { |
|
| 722 | - | let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" }; |
|
| 723 | - | return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())])); |
|
| 724 | - | } |
|
| 725 | - | let labels = r.label_list(); |
|
| 726 | - | if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) { |
|
| 727 | - | return Some(format!("lacks label {l}")); |
|
| 728 | - | } |
|
| 729 | - | if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) { |
|
| 730 | - | return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib"))); |
|
| 731 | - | } |
|
| 732 | - | if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) { |
|
| 733 | - | return Some(format!("has {c} CPUs; the job needs {}", need("cpus"))); |
|
| 734 | - | } |
|
| 735 | - | // The disk it learned it needs (oxygenflow#41). |
|
| 736 | - | if let (Some(n), Some(free)) = (job.need_disk_mib, r.free_disk_mib) { |
|
| 737 | - | if free < n { |
|
| 738 | - | return Some(format!( |
|
| 739 | - | "has {} free; the job needs ~{} (its recent runs took up to {})", |
|
| 740 | - | mib(free), |
|
| 741 | - | mib(n), |
|
| 742 | - | mib((n - 64) * 4 / 5) |
|
| 743 | - | )); |
|
| 744 | - | } |
|
| 745 | - | } |
|
| 746 | - | if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) { |
|
| 747 | - | return Some(format!("declined it: {}", d.reason)); |
|
| 748 | - | } |
|
| 749 | - | None |
|
| 750 | - | }; |
|
| 825 | + | let fleet = Fleet { image_archs, hub_images, now }; |
|
| 751 | 826 | let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect(); |
|
| 752 | 827 | if online.is_empty() { |
|
| 753 | 828 | return "no runner is online".into(); |
|
| 754 | 829 | } |
|
| 755 | 830 | let mut why = Vec::new(); |
|
| 756 | 831 | for r in &online { |
|
| 757 | - | match lacks(r) { |
|
| 832 | + | match runner_lacks(job, owner, repo_id, r, &fleet, declines) { |
|
| 758 | 833 | None => return String::new(), |
|
| 759 | 834 | Some(w) => why.push(format!("{} {w}", r.name)), |
|
| 760 | 835 | } |
|
| … | 5 unchanged lines |
||
| 766 | 841 | text.push_str(&format!("; and {more} more")); |
|
| 767 | 842 | } |
|
| 768 | 843 | text |
|
| 844 | + | } |
|
| 845 | + | ||
| 846 | + | /// How long a job is held for a better runner before any runner that fits |
|
| 847 | + | /// takes it (oxygenflow#42). |
|
| 848 | + | const BETTER_HOLD_MS: i64 = 15_000; |
|
| 849 | + | /// A runner counts as there to take a job when it asked this recently. |
|
| 850 | + | const POLLING_MS: i64 = 10_000; |
|
| 851 | + | ||
| 852 | + | /// When each job was first passed over for a better runner. |
|
| 853 | + | static HELD: std::sync::Mutex<Option<std::collections::HashMap<i64, i64>>> = std::sync::Mutex::new(None); |
|
| 854 | + | ||
| 855 | + | /// How good runner `r` is for a job on image `rootfs`: holding the image |
|
| 856 | + | /// counts most, then the speed class. |
|
| 857 | + | fn rank(r: &db::runners::Runner, rootfs: &str) -> i64 { |
|
| 858 | + | let holds = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs)); |
|
| 859 | + | (holds as i64) * 10 + r.speed_class.unwrap_or(0) |
|
| 860 | + | } |
|
| 861 | + | ||
| 862 | + | fn rootfs_of(job: &fdb::Job) -> String { |
|
| 863 | + | serde_json::from_str::<serde_json::Value>(&job.sandbox).ok().and_then(|v| v["rootfs"].as_str().map(String::from)).unwrap_or_default() |
|
| 769 | 864 | } |
|
| 770 | 865 | ||
| 771 | 866 | /// A size in MiB as people say it: 9.4 GiB, 740 MiB. |
|
| … | 988 unchanged lines |
modified crates/hub/tests/flow.rs +50 −0
| … | 2786 unchanged lines |
||
| 2787 | 2787 | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(claim_with(20_000))).await; |
|
| 2788 | 2788 | assert_eq!(st, StatusCode::OK, "a roomier runner takes it: {body}"); |
|
| 2789 | 2789 | } |
|
| 2790 | + | ||
| 2791 | + | /// oxygenflow#42: a job another free runner would do better (it holds the |
|
| 2792 | + | /// image) is held from a runner that would fetch it, the better runner |
|
| 2793 | + | /// takes it, and the job says why it ran there. |
|
| 2794 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2795 | + | async fn a_job_waits_briefly_for_a_runner_that_holds_its_image() { |
|
| 2796 | + | let h = hub().await; |
|
| 2797 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 2798 | + | // A second runner, with a token of its own. |
|
| 2799 | + | db::runners::configured(&h.app.db, &blake3::hash(b"other-secret").to_hex(), "earth-9").await.unwrap(); |
|
| 2800 | + | let ask = |images: &str| { |
|
| 2801 | + | format!( |
|
| 2802 | + | r#"{{"runner":"r","runtimes":["microvm"],"images":[{images}],"fetches_images":true,"facts":{{"speed":100,"speed_class":3,"free_disk_mib":50000}}}}"# |
|
| 2803 | + | ) |
|
| 2804 | + | }; |
|
| 2805 | + | let holder = ask(&format!("\"{ROOTFS}\"")); |
|
| 2806 | + | // earth-9 holds the image and is polling; nothing is queued yet. |
|
| 2807 | + | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder.clone())).await; |
|
| 2808 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 2809 | + | // The hub holds the image too, so test-1 could fetch it. |
|
| 2810 | + | sqlx::query("INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, 1, 'x86_64', 1, 'andy', 0)") |
|
| 2811 | + | .bind(ROOTFS) |
|
| 2812 | + | .execute(&h.app.db) |
|
| 2813 | + | .await |
|
| 2814 | + | .unwrap(); |
|
| 2815 | + | ||
| 2816 | + | create(&h.app, "andy", "demo").await; |
|
| 2817 | + | let url = format!("{}/andy/demo", h.base); |
|
| 2818 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2819 | + | let c = client(&url, work.path()); |
|
| 2820 | + | write( |
|
| 2821 | + | work.path(), |
|
| 2822 | + | ".flow/pipeline.kdl", |
|
| 2823 | + | &format!("schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n sandbox \"alpine\"\n exec \"make\"\n}}\n"), |
|
| 2824 | + | ); |
|
| 2825 | + | c.commit("Build", &[]).unwrap(); |
|
| 2826 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2827 | + | runs_of(&h.app, "andy", "demo").await; |
|
| 2828 | + | ||
| 2829 | + | let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(""))).await; |
|
| 2830 | + | assert_eq!(st, StatusCode::NO_CONTENT, "held for the runner that holds the image"); |
|
| 2831 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder)).await; |
|
| 2832 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 2833 | + | let id = serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap(); |
|
| 2834 | + | let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed; |
|
| 2835 | + | assert!(placed.starts_with("holds the image, idle, speed 100 (class 3)"), "{placed}"); |
|
| 2836 | + | assert!(placed.contains("held for a better runner"), "{placed}"); |
|
| 2837 | + | let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", Some("site"), None).await; |
|
| 2838 | + | assert!(body.contains("\"placed\":\"holds the image"), "{body}"); |
|
| 2839 | + | } |
modified crates/oh/src/main.rs +4 −1
| … | 1030 unchanged lines |
||
| 1031 | 1031 | }; |
|
| 1032 | 1032 | let on = if j.runner.is_empty() { String::new() } else { format!(" on {}", j.runner) }; |
|
| 1033 | 1033 | println!(" {} {}{}", ui::status(&j.status), j.name, ui::dim(&format!("{dur}{on}"))); |
|
| 1034 | - | // Why a queued job no runner can take waits (oxygenhub#28). |
|
| 1034 | + | // Why a queued job no runner can take waits (oxygenhub#28), and |
|
| 1035 | + | // why a job ran where it did (oxygenflow#42). |
|
| 1035 | 1036 | if j.status == "queued" && !j.waiting.is_empty() { |
|
| 1036 | 1037 | println!(" {}", ui::warn(&format!("waiting: {}", j.waiting))); |
|
| 1038 | + | } else if !j.placed.is_empty() { |
|
| 1039 | + | println!(" {}", ui::dim(&format!("placed: {}", j.placed))); |
|
| 1037 | 1040 | } |
|
| 1038 | 1041 | } |
|
| 1039 | 1042 | if !r.error.is_empty() { |
|
| … | 503 unchanged lines |
modified docs/spec.md +1 −1
| … | 514 unchanged lines |
||
| 515 | 515 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 516 | 516 | ||
| 517 | 517 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 518 | - | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 518 | + | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). |
|
| 519 | 519 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 520 | 520 | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 521 | 521 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| … | 231 unchanged lines |
modified templates/flow_run.html +2 −1
| … | 31 unchanged lines |
||
| 32 | 32 | </section> |
|
| 33 | 33 | {% for l in logs %} |
|
| 34 | 34 | <details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" || (l.j.status == "queued" && !l.j.waiting.is_empty()) %} open{% endif %}> |
|
| 35 | - | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary> |
|
| 35 | + | <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on <span{% if !l.j.placed.is_empty() %} title="Placed there: {{ l.j.placed }}"{% endif %}>{{ l.j.runner }}</span>{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary> |
|
| 36 | 36 | {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %} |
|
| 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 l.j.status != "passed" %} <span class="warn" title="Kept with when "always": the job did not pass, so its receipt does not name this file">· from a job that did not pass, not in its receipt</span>{% endif %}{% if !releases.is_empty() && l.j.status == "passed" %} <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 | {% if let Some(t) = l.tests %}<div class="flow-tests" id="tests-{{ l.j.id }}"> |
|
| … | 6 unchanged lines |
||
| 45 | 45 | </div>{% endif %} |
|
| 46 | 46 | <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> |
|
| 47 | 47 | {% endfor %}</pre> |
|
| 48 | + | {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %} |
|
| 48 | 49 | {% 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 %} |
|
| 49 | 50 | {% 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 %} |
|
| 50 | 51 | </details> |
|
| … | 3 unchanged lines |