OXYGENHUB

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