Runners offer the manifests of images the hub lacks; a fetch the hub cannot seed asks peers alone (refs #94) 32f54b393396
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c80f7f4a2dbd
- When
- · 2026-10-27 02:06 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
32f54b39339668432c73fe9c37d66a616fe6b4e55f705a599c6f62b7100bce22- Depends on
- 2814f4213e7c 3e3cc9475ab3 4be5e4cd0af6 4cf0c99a1486 77c8c24066ef b14aeaae683a bb643c252515 c3db750ed96e e0918b887850
- Depended on by
- 8b7034c55402 069e16fba3bd
- In bookmarks
- main
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 17 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **The swarm moves an image nobody pushed (#94).** A runner that serves its peers tells the hub, at start and every ten minutes, which images it holds, and sends the manifest of each the hub keeps none of: the manifest alone, not the image. With a hub that keeps such manifests (oxygenhub#342), runners in the same site fetch the image from it. A fetch told the hub holds no chunks asks peers alone, and fails plainly when none can serve it. Design: docs/jetstream.md §8.1.1. |
|
| 7 | 8 | - **A WASM job stopped while its module compiles stops (#93).** The time limit or a cancel moved the engine's epoch once; a store made after that set its deadline past it, so the job ran on forever (seen as a test spinning 35 minutes under load). Once stopped, the epoch now moves every tick until the job ends. |
|
| 8 | 9 | - **A runner claims only what its host can start (#92).** An idle runner told the hub it had no memory limit, so it was offered sandboxes bigger than its machine, fetched their images, and declined them one by one (mars-1, 7 GB, and the 8 GiB jobs). A claim now asks for the budget's room capped at the host's free memory. |
|
| 9 | 10 | - **`flow rootfs push` waits for the hub to check a big image (oxygenhub#341).** The hub reads the whole image before it accepts the manifest; the manifest request now waits up to an hour, not the 120 s every other request gets. |
|
| … | 111 unchanged lines |
modified crates/flow-core/src/api.rs +19 −0
| … | 107 unchanged lines |
||
| 108 | 108 | pub peers: Vec<SwarmPeer>, |
|
| 109 | 109 | /// Announce again after this many seconds while still fetching. |
|
| 110 | 110 | pub interval_s: u64, |
|
| 111 | + | /// Whether the hub holds the image's chunks and so may be asked for |
|
| 112 | + | /// them (jetstream.md §8.1.1): `Some(false)` for an image only runners |
|
| 113 | + | /// hold; absent from a hub that predates it, which always seeds. |
|
| 114 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 115 | + | pub seed: Option<bool>, |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// `POST /flow/images/held`: the images a runner holds, by hash |
|
| 119 | + | /// (oxygenflow#94). |
|
| 120 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 121 | + | pub struct HeldImages { |
|
| 122 | + | pub images: Vec<String>, |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | /// The hub's answer to [`HeldImages`]: those it keeps no manifest for, which |
|
| 126 | + | /// the runner sends with `PUT /flow/images/{hash}/held`. |
|
| 127 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 128 | + | pub struct WantedManifests { |
|
| 129 | + | pub wanted: Vec<String>, |
|
| 111 | 130 | } |
|
| 112 | 131 | ||
| 113 | 132 | /// A peer in the swarm for one image. |
|
| … | 336 unchanged lines |
modified crates/flow/src/images.rs +44 −0
| … | 223 unchanged lines |
||
| 224 | 224 | Ok(()) |
|
| 225 | 225 | } |
|
| 226 | 226 | ||
| 227 | + | impl Client { |
|
| 228 | + | /// Tell the hub which images this home holds, and send the manifest of |
|
| 229 | + | /// each it keeps none for (oxygenflow#94, jetstream.md §8.1.1): the |
|
| 230 | + | /// swarm can then carry an image nobody pushed. A manifest not kept here |
|
| 231 | + | /// yet is made by reading the image once, and kept. How many were sent; |
|
| 232 | + | /// none from a hub that predates it. |
|
| 233 | + | pub fn offer_held(&self, home: &Home) -> Result<usize> { |
|
| 234 | + | let images = home.images(); |
|
| 235 | + | if images.is_empty() { |
|
| 236 | + | return Ok(0); |
|
| 237 | + | } |
|
| 238 | + | let url = format!("{}/flow/images/held", self.base); |
|
| 239 | + | let wanted: flow_core::api::WantedManifests = |
|
| 240 | + | match self.agent.post(&url).set("Authorization", &self.auth()).send_json(flow_core::api::HeldImages { images }) { |
|
| 241 | + | Ok(r) => r.into_json()?, |
|
| 242 | + | Err(ureq::Error::Status(404, _)) => return Ok(0), |
|
| 243 | + | Err(e) => return Err(Self::fail(&url, e)), |
|
| 244 | + | }; |
|
| 245 | + | let mut sent = 0; |
|
| 246 | + | for hash in wanted.wanted.iter().filter(|h| images::is_hash(h)) { |
|
| 247 | + | let path = home.rootfs(hash); |
|
| 248 | + | if !path.is_file() { |
|
| 249 | + | continue; |
|
| 250 | + | } |
|
| 251 | + | let kept = std::fs::read(manifest_path(&home.root, hash)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok()); |
|
| 252 | + | let m = match kept.filter(|m| m.hash == *hash) { |
|
| 253 | + | Some(m) => m, |
|
| 254 | + | None => { |
|
| 255 | + | let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?; |
|
| 256 | + | if m.hash != *hash { |
|
| 257 | + | continue; |
|
| 258 | + | } |
|
| 259 | + | save_manifest(&home.root, &m)?; |
|
| 260 | + | m |
|
| 261 | + | } |
|
| 262 | + | }; |
|
| 263 | + | let url = format!("{}/flow/images/{hash}/held", self.base); |
|
| 264 | + | self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?; |
|
| 265 | + | sent += 1; |
|
| 266 | + | } |
|
| 267 | + | Ok(sent) |
|
| 268 | + | } |
|
| 269 | + | } |
|
| 270 | + | ||
| 227 | 271 | /// Where an image's chunk manifest is kept (`manifests/<hash>.json`), so |
|
| 228 | 272 | /// the runner can serve its chunks to peers out of the image itself. |
|
| 229 | 273 | pub(crate) fn manifest_path(root: &Path, hash: &str) -> PathBuf { |
|
| … | 44 unchanged lines |
modified crates/flow/src/runner.rs +18 −0
| … | 793 unchanged lines |
||
| 794 | 794 | if let Some(addr) = &cfg.metrics { |
|
| 795 | 795 | crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone()); |
|
| 796 | 796 | } |
|
| 797 | + | // The manifests of images held here that the hub keeps none of, so |
|
| 798 | + | // peers in this runner's site can fetch them without anyone pushing |
|
| 799 | + | // them (oxygenflow#94): at start and every ten minutes, which also |
|
| 800 | + | // catches an image imported while it runs. Only a runner that serves |
|
| 801 | + | // its peers has anything to offer. |
|
| 802 | + | if swarm.is_some() { |
|
| 803 | + | let (root, cfg) = (home.root.clone(), cfg.clone()); |
|
| 804 | + | std::thread::spawn(move || loop { |
|
| 805 | + | let offered = Home::locate(Some(&root)) |
|
| 806 | + | .and_then(|home| crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?.offer_held(&home)); |
|
| 807 | + | match offered { |
|
| 808 | + | Ok(0) => {} |
|
| 809 | + | Ok(n) => eprintln!("flow runner: sent the hub the manifests of {n} image(s) only runners hold"), |
|
| 810 | + | Err(e) => eprintln!("flow runner: offering held images: {e:#}"), |
|
| 811 | + | } |
|
| 812 | + | std::thread::sleep(Duration::from_secs(600)); |
|
| 813 | + | }); |
|
| 814 | + | } |
|
| 797 | 815 | let used = std::sync::Mutex::new(Used::default()); |
|
| 798 | 816 | let mut idle = Duration::from_secs(2); |
|
| 799 | 817 | let pending = report_pending(home); |
|
| … | 1484 unchanged lines |
modified crates/flow/src/swarm.rs +64 −5
| … | 259 unchanged lines |
||
| 260 | 260 | } |
|
| 261 | 261 | } |
|
| 262 | 262 | }; |
|
| 263 | + | // An image only runners hold (jetstream.md §8.1.1): the hub |
|
| 264 | + | // has its manifest but none of its chunks, so only peers are asked. |
|
| 265 | + | let hub_seeds = announced.as_ref().is_none_or(|a| a.seed != Some(false)); |
|
| 263 | 266 | std::thread::scope(|s| { |
|
| 264 | - | for _ in 0..HUB_WORKERS { |
|
| 265 | - | s.spawn(move || worker(Source::Hub)); |
|
| 267 | + | if hub_seeds { |
|
| 268 | + | for _ in 0..HUB_WORKERS { |
|
| 269 | + | s.spawn(move || worker(Source::Hub)); |
|
| 270 | + | } |
|
| 266 | 271 | } |
|
| 267 | 272 | let mut spawned = 0; |
|
| 268 | 273 | let mut spawn_new = || { |
|
| … | 11 unchanged lines |
||
| 280 | 285 | let mut asked_hub = Instant::now(); |
|
| 281 | 286 | let interval = Duration::from_secs(announced.as_ref().map(|a| a.interval_s).unwrap_or(30).max(5)); |
|
| 282 | 287 | let mut last = Instant::now(); |
|
| 288 | + | let mut peerless = Instant::now(); |
|
| 283 | 289 | while picker.lock().unwrap().missing() > 0 && failed.lock().unwrap().is_none() { |
|
| 284 | 290 | std::thread::sleep(Duration::from_millis(100)); |
|
| 291 | + | if !hub_seeds { |
|
| 292 | + | if peers.lock().unwrap().iter().any(|p| p.alive.load(Ordering::Relaxed)) { |
|
| 293 | + | peerless = Instant::now(); |
|
| 294 | + | } else if peerless.elapsed() >= interval * 2 { |
|
| 295 | + | *failed.lock().unwrap() = Some(anyhow::anyhow!( |
|
| 296 | + | "image {} is held only by runners, and none in this site could serve it for {} s; send it to the hub with `flow rootfs push`", |
|
| 297 | + | &hash[..12], |
|
| 298 | + | (interval * 2).as_secs() |
|
| 299 | + | )); |
|
| 300 | + | } |
|
| 301 | + | } |
|
| 285 | 302 | if last.elapsed() < REFRESH { |
|
| 286 | 303 | continue; |
|
| 287 | 304 | } |
|
| … | 437 unchanged lines |
||
| 725 | 742 | } |
|
| 726 | 743 | ||
| 727 | 744 | fn fake_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub { |
|
| 728 | - | slow_hub(m, image, site, Duration::ZERO) |
|
| 745 | + | hub_with(m, image, site, Duration::ZERO, true) |
|
| 729 | 746 | } |
|
| 730 | 747 | ||
| 731 | 748 | /// A hub that takes `delay` over each chunk. |
|
| 732 | 749 | fn slow_hub(m: Manifest, image: Vec<u8>, site: &str, delay: Duration) -> FakeHub { |
|
| 750 | + | hub_with(m, image, site, delay, true) |
|
| 751 | + | } |
|
| 752 | + | ||
| 753 | + | /// A hub that keeps the image's manifest but none of its chunks, as for |
|
| 754 | + | /// an image only runners hold (jetstream.md §8.1.1). |
|
| 755 | + | fn peer_only_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub { |
|
| 756 | + | hub_with(m, image, site, Duration::ZERO, false) |
|
| 757 | + | } |
|
| 758 | + | ||
| 759 | + | fn hub_with(m: Manifest, image: Vec<u8>, site: &str, delay: Duration, seeds: bool) -> FakeHub { |
|
| 733 | 760 | let sk = SecretKey::generate(); |
|
| 734 | 761 | let id = sk.identity("hub", "hub@example", 1); |
|
| 735 | 762 | let key = id.hash(); |
|
| … | 45 unchanged lines |
||
| 781 | 808 | (200, id.encode()) |
|
| 782 | 809 | } else if path == format!("/api/v1/flow/images/{}", m.hash) { |
|
| 783 | 810 | (200, serde_json::to_vec(m).unwrap()) |
|
| 784 | - | } else if let Some(c) = path.strip_prefix("/api/v1/flow/images/chunks/") { |
|
| 811 | + | } else if let Some(c) = path.strip_prefix("/api/v1/flow/images/chunks/").filter(|_| seeds) { |
|
| 785 | 812 | count.fetch_add(1, Ordering::Relaxed); |
|
| 786 | 813 | std::thread::sleep(delay); |
|
| 787 | 814 | (200, chunks[c].clone()) |
|
| … | 8 unchanged lines |
||
| 796 | 823 | expires_ms: now_ms() + 60_000, |
|
| 797 | 824 | }; |
|
| 798 | 825 | let peers: Vec<SwarmPeer> = runners.iter().filter(|(t, _)| *t != token).map(|(_, p)| p.clone()).collect(); |
|
| 799 | - | let a = flow_core::api::Announced { ticket: ticket.seal(key, sk), peers, interval_s: 5 }; |
|
| 826 | + | let a = flow_core::api::Announced { |
|
| 827 | + | ticket: ticket.seal(key, sk), |
|
| 828 | + | peers, |
|
| 829 | + | interval_s: 5, |
|
| 830 | + | seed: (!seeds).then_some(false), |
|
| 831 | + | }; |
|
| 800 | 832 | (200, serde_json::to_vec(&a).unwrap()) |
|
| 801 | 833 | } else { |
|
| 802 | 834 | (404, b"no".to_vec()) |
|
| … | 76 unchanged lines |
||
| 879 | 911 | assert_eq!(moved.chunks, m.needed().len()); |
|
| 880 | 912 | // B now offers the whole image too, read out of its rootfs. |
|
| 881 | 913 | assert!(matches!(b.state.offers.lock().unwrap().get(&m.hash), Some(Offer { bits: None, .. }))); |
|
| 914 | + | } |
|
| 915 | + | ||
| 916 | + | #[test] |
|
| 917 | + | fn an_image_only_runners_hold_comes_from_a_peer_and_fails_plainly_without_one() { |
|
| 918 | + | let bytes = image(); |
|
| 919 | + | let m = jet::images::scan(&bytes[..], Some("x86_64".into()), |_, _| Ok(())).unwrap(); |
|
| 920 | + | let hub = peer_only_hub(m.clone(), bytes.clone(), "lab"); |
|
| 921 | + | // No peer yet: no hub to fall back on, so the fetch says why. |
|
| 922 | + | let lone = tempfile::tempdir().unwrap(); |
|
| 923 | + | let (lone_home, lone_runner) = runner(lone.path(), &hub, 3, "tc", free_port(), "lab"); |
|
| 924 | + | let e = lone_runner.fetch(&lone_home, &m.hash).err().expect("a fetch with no peer and no seed fails"); |
|
| 925 | + | assert!(e.to_string().contains("held only by runners"), "{e:#}"); |
|
| 926 | + | // With a peer holding it, it all comes from the peer. |
|
| 927 | + | let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); |
|
| 928 | + | std::fs::create_dir_all(a_dir.path().join("cas")).unwrap(); |
|
| 929 | + | std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap(); |
|
| 930 | + | let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab"); |
|
| 931 | + | let deadline = Instant::now() + Duration::from_secs(20); |
|
| 932 | + | while !a.state.offers.lock().unwrap().contains_key(&m.hash) { |
|
| 933 | + | assert!(Instant::now() < deadline, "runner A never offered its image"); |
|
| 934 | + | std::thread::sleep(Duration::from_millis(20)); |
|
| 935 | + | } |
|
| 936 | + | let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab"); |
|
| 937 | + | let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap(); |
|
| 938 | + | assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes); |
|
| 939 | + | assert_eq!(moved.peer_bytes, moved.bytes, "every chunk came from the peer"); |
|
| 940 | + | assert_eq!(hub.chunks_served.load(Ordering::Relaxed), 0); |
|
| 882 | 941 | } |
|
| 883 | 942 | ||
| 884 | 943 | #[test] |
|
| … | 98 unchanged lines |
modified docs/jetstream.md +5 −4
| … | 310 unchanged lines |
||
| 311 | 311 | pushed, and for a day every Rust job queued on the one runner that had |
|
| 312 | 312 | imported it while the others sat idle. |
|
| 313 | 313 | ||
| 314 | - | - **Runners say what they hold.** A claim already lists the images a runner |
|
| 315 | - | holds. When the hub keeps no manifest for one of them, the claim's answer |
|
| 316 | - | asks for it (`send_manifests`), and the runner sends the manifest alone |
|
| 314 | + | - **Runners say what they hold.** At start, every ten minutes and after an |
|
| 315 | + | import, a runner sends the hashes of the images it holds |
|
| 316 | + | (`POST /flow/images/held {images}`); the hub answers with those it keeps |
|
| 317 | + | no manifest for (`{wanted}`), and the runner sends each manifest alone |
|
| 317 | 318 | (`PUT /flow/images/{hash}/held`, a few MB even for a 14 GB image): no |
|
| 318 | - | chunks. |
|
| 319 | + | chunks. (Not in the claim: an idle runner's claim gets no answer at all.) |
|
| 319 | 320 | - **The hub keeps it as peer-held.** It checks the manifest is well formed |
|
| 320 | 321 | and names the hash it is filed under, and records the image as held by |
|
| 321 | 322 | runners, not by itself: it cannot read the image to check it. A pushed |
|
| … | 260 unchanged lines |