OXYGENHUB

Jetstream: runners share images peer to peer b14aeaae683a

A runner with a [runner.swarm] section serves the image chunks it holds,
whole images and ones still arriving, on a small HTTP endpoint, and fetches
an image it lacks from its peers and the hub together: rarest chunks first
from peers, the hub only for chunks no peer has or is fetching, with an
endgame and a stall rule. Peers admit each other with a short-lived ticket
the hub signs; every chunk is checked against its hash. Uploads are capped
and connections limited. Without the section nothing changes.

closes #47
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 14:56 UTC
Signature
✔ signed by Andy McCall
Hash
b14aeaae683a204f6fb6066f891ce105b0005a7da3a0e2b6bf16e131757c01d1
Depends on
1daca0b6bb8c 2814f4213e7c 3007d8f011fa 47c1d64dec8a 4cf0c99a1486 59078d2b8d46 62829d15ef09 b11e30f5031d bb643c252515 c3db750ed96e e0918b887850 ea56c56eaf90
Depended on by
1215f05ff8d0 069e16fba3bd 32f54b393396 3e3cc9475ab3 3554afefb6aa c90dd8d5fc36 5cf1e8aefbb6 ea81796bb757 1e6b9fb1e73f 0077c7488e5d 82cf6e91d398 d60aac6c3f8a ecce3719da80
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
29 atoms, 2 tree operations, 10 files

Browse the tree at this change

added crates/flow/src/swarm.rs +794 −0
1 +
//! The swarm (oxygenflow#47, docs/jetstream.md §8.1): a runner with a
2 +
//! `[runner.swarm]` section serves the image chunks it holds to its peers,
3 +
//! and fetches an image it lacks from its peers and the hub together.
4 +
//!
5 +
//! The server is a small HTTP/1.1 endpoint on the swarm address: a peer
6 +
//! shows the ticket its hub signed and gets a session, then asks which
7 +
//! chunks of an image this runner has and for chunks by hash. A chunk is
8 +
//! served from the chunk cache, or read out of a whole image by its
9 +
//! manifest. Every chunk is checked by the side that asked for it, so a
10 +
//! peer can only fail to help.
11 +
12 +
use crate::home::{Home, RunnerConfig};
13 +
use crate::images::{cache_path, chunk_cache, manifest_path, save_manifest, write_image, Client, Moved};
14 +
use anyhow::{bail, Context, Result};
15 +
use flow_core::api::{SwarmAddr, SwarmPeer};
16 +
use jet::images::{is_hash, Manifest};
17 +
use jet::swarm::{Bits, Pick, Picker, Source, Ticket};
18 +
use oxy_core::identity::Identity;
19 +
use std::collections::HashMap;
20 +
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
21 +
use std::net::{TcpListener, TcpStream};
22 +
use std::path::{Path, PathBuf};
23 +
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
24 +
use std::sync::{Arc, Mutex};
25 +
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26 +
27 +
/// Connections served at once; more are told 503 and try elsewhere.
28 +
const MAX_CONNS: usize = 16;
29 +
/// Requests in flight to one peer, and to the hub.
30 +
const PEER_WORKERS: usize = 4;
31 +
const HUB_WORKERS: usize = 8;
32 +
/// How often a fetcher asks its peers what they have.
33 +
const REFRESH: Duration = Duration::from_millis(500);
34 +
/// Hub failures a fetch survives before it gives up.
35 +
const HUB_FAILURES: usize = 5;
36 +
/// The largest request body the server reads: a ticket.
37 +
const MAX_BODY: usize = 64 * 1024;
38 +
/// Names the part files of chunks two sources fetch at once apart.
39 +
static PART: AtomicU64 = AtomicU64::new(0);
40 +
41 +
fn now_ms() -> u64 {
42 +
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
43 +
}
44 +
45 +
/// An image this runner offers: which of its needed chunks are here
46 +
/// (`None`: all of them, the image is whole) and which it is fetching now.
47 +
struct Offer {
48 +
    bits: Option<Bits>,
49 +
    fetching: Bits,
50 +
}
51 +
52 +
struct State {
53 +
    root: PathBuf,
54 +
    site: String,
55 +
    client: Client,
56 +
    /// The hub's identity, which signs tickets; fetched when first needed.
57 +
    hub: Mutex<Option<Identity>>,
58 +
    offers: Mutex<HashMap<String, Offer>>,
59 +
    /// Where a chunk lies in a whole image: the image, offset and length.
60 +
    located: Mutex<HashMap<String, (PathBuf, u64, u32)>>,
61 +
    /// Sessions: token to (peer name, expiry).
62 +
    sessions: Mutex<HashMap<String, (String, u64)>>,
63 +
    conns: AtomicUsize,
64 +
    /// The upload allowance: bytes in hand (negative: owed) and when it
65 +
    /// was last topped up.
66 +
    bucket: Mutex<(f64, Instant)>,
67 +
    rate: f64,
68 +
}
69 +
70 +
/// This runner's place in the swarm.
71 +
pub struct Swarm {
72 +
    state: Arc<State>,
73 +
    pub addr: SwarmAddr,
74 +
    peer: ureq::Agent,
75 +
}
76 +
77 +
impl Swarm {
78 +
    /// Start serving peers when the runner is configured for it.
79 +
    pub fn start(home: &Home, cfg: &RunnerConfig) -> Result<Option<Swarm>> {
80 +
        let Some(sc) = &cfg.swarm else { return Ok(None) };
81 +
        let listener = TcpListener::bind(&sc.listen).with_context(|| format!("swarm: listen on {}", sc.listen))?;
82 +
        let client = Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref())?;
83 +
        let hub = client.hub_identity().map_err(|e| eprintln!("swarm: the hub's key: {e:#}; trying again when a peer comes")).ok();
84 +
        let rate = sc.upload_mib_s.unwrap_or(50).max(1) as f64 * (1 << 20) as f64;
85 +
        let state = Arc::new(State {
86 +
            root: home.root.clone(),
87 +
            site: sc.site.clone(),
88 +
            client,
89 +
            hub: Mutex::new(hub),
90 +
            offers: Mutex::new(HashMap::new()),
91 +
            located: Mutex::new(HashMap::new()),
92 +
            sessions: Mutex::new(HashMap::new()),
93 +
            conns: AtomicUsize::new(0),
94 +
            bucket: Mutex::new((rate, Instant::now())),
95 +
            rate,
96 +
        });
97 +
        // Whole images: offered from their manifests; one without a
98 +
        // manifest (imported here, not fetched) is scanned for one, in the
99 +
        // background and one at a time.
100 +
        let mut unscanned = Vec::new();
101 +
        for h in home.images() {
102 +
            match std::fs::read(manifest_path(&home.root, &h)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok()) {
103 +
                Some(m) if m.hash == h => offer_whole(&state, &m, &home.rootfs(&h)),
104 +
                _ => unscanned.push((h.clone(), home.rootfs(&h), home.image_arch(&h))),
105 +
            }
106 +
        }
107 +
        let scanning = state.clone();
108 +
        std::thread::spawn(move || {
109 +
            for (h, path, arch) in unscanned {
110 +
                match jet::images::scan_file(&path, arch, |_, _| Ok(())) {
111 +
                    Ok(m) if m.hash == h => {
112 +
                        save_manifest(&scanning.root, &m).ok();
113 +
                        offer_whole(&scanning, &m, &path);
114 +
                    }
115 +
                    Ok(_) => eprintln!("swarm: {} does not hold the image its name says; not offering it", path.display()),
116 +
                    Err(e) => eprintln!("swarm: scan {}: {e}", path.display()),
117 +
                }
118 +
            }
119 +
        });
120 +
        let serving = state.clone();
121 +
        std::thread::spawn(move || accept(listener, serving));
122 +
        let addr = SwarmAddr { addr: sc.advertise.clone().unwrap_or_else(|| sc.listen.clone()), site: sc.site.clone() };
123 +
        eprintln!("swarm: serving image chunks on {} (site {:?}, up to {} MiB/s)", sc.listen, sc.site, sc.upload_mib_s.unwrap_or(50));
124 +
        let peer = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(2)).timeout(Duration::from_secs(20)).build();
125 +
        Ok(Some(Swarm { state, addr, peer }))
126 +
    }
127 +
128 +
    /// Fetch image `hash` into `home` from the peers and the hub (§8.1):
129 +
    /// the rarest chunks from peers first, the hub for what no peer has.
130 +
    /// `Ok(None)` when the hub does not hold it.
131 +
    pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> {
132 +
        let start = Instant::now();
133 +
        let client = &self.state.client;
134 +
        let Some(m) = client.manifest(hash)? else { return Ok(None) };
135 +
        if m.hash != hash {
136 +
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
137 +
        }
138 +
        save_manifest(&home.root, &m)?;
139 +
        let needed: Vec<String> = m.needed().into_iter().map(String::from).collect();
140 +
        let cache = chunk_cache(home);
141 +
        let mut have = Bits::new(needed.len());
142 +
        for (i, h) in needed.iter().enumerate() {
143 +
            if cache_path(&cache, h).is_file() {
144 +
                have.set(i);
145 +
            }
146 +
        }
147 +
        self.state.offers.lock().unwrap().insert(hash.into(), Offer { bits: Some(have.clone()), fetching: Bits::new(needed.len()) });
148 +
        let announced = client
149 +
            .announce(hash, "fetching")
150 +
            .map_err(|e| eprintln!("swarm: announce {}: {e:#}; fetching from the hub alone", &hash[..12]))
151 +
            .ok();
152 +
        let seed = now_ms() ^ (std::process::id() as u64) << 32;
153 +
        let picker = Mutex::new(Picker::new(needed.len(), have, seed));
154 +
        let peers: Mutex<Vec<Arc<Peer>>> = Mutex::new(Vec::new());
155 +
        if let Some(a) = &announced {
156 +
            self.join(&a.peers, &a.ticket, hash, needed.len(), &peers, &picker);
157 +
        }
158 +
        let (bytes, peer_bytes, got) = (AtomicU64::new(0), AtomicU64::new(0), AtomicUsize::new(0));
159 +
        let (failed, hub_failures) = (Mutex::new(None::<anyhow::Error>), AtomicUsize::new(0));
160 +
        {
161 +
            let (picker, peers, needed, cache) = (&picker, &peers, &needed, &cache);
162 +
            let (bytes, peer_bytes, got, failed, hub_failures) = (&bytes, &peer_bytes, &got, &failed, &hub_failures);
163 +
            let (state, m) = (&self.state, &m);
164 +
            let worker = move |src: Source| loop {
165 +
                if failed.lock().unwrap().is_some() {
166 +
                    break;
167 +
                }
168 +
                let peer = match src {
169 +
                    Source::Peer(p) => Some(peers.lock().unwrap()[p].clone()),
170 +
                    Source::Hub => None,
171 +
                };
172 +
                if peer.as_ref().is_some_and(|p| !p.alive.load(Ordering::Relaxed)) {
173 +
                    break;
174 +
                }
175 +
                // Taken apart from the match, so the lock is not held
176 +
                // while waiting.
177 +
                let pick = picker.lock().unwrap().pick(src);
178 +
                let i = match pick {
179 +
                    Pick::Done => break,
180 +
                    Pick::Wait => {
181 +
                        std::thread::sleep(Duration::from_millis(20));
182 +
                        continue;
183 +
                    }
184 +
                    Pick::Chunk(i) => i,
185 +
                };
186 +
                if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
187 +
                    o.fetching.set(i);
188 +
                }
189 +
                let h = &needed[i];
190 +
                let fetched = match &peer {
191 +
                    Some(p) => self.peer_chunk(p, h),
192 +
                    None => client.chunk(h),
193 +
                };
194 +
                let kept = fetched.and_then(|b| {
195 +
                    if blake3::hash(&b).to_hex().as_str() != h {
196 +
                        bail!("chunk {} came back with other bytes", &h[..12]);
197 +
                    }
198 +
                    let p = cache_path(cache, h);
199 +
                    std::fs::create_dir_all(p.parent().expect("a chunk directory"))?;
200 +
                    let tmp = p.with_extension(format!("part{}", PART.fetch_add(1, Ordering::Relaxed)));
201 +
                    std::fs::write(&tmp, &b)?;
202 +
                    std::fs::rename(&tmp, &p)?;
203 +
                    Ok(b.len() as u64)
204 +
                });
205 +
                match kept {
206 +
                    Ok(n) => {
207 +
                        if let Some(Offer { bits: Some(b), fetching }) = state.offers.lock().unwrap().get_mut(&m.hash) {
208 +
                            b.set(i);
209 +
                            fetching.clear(i);
210 +
                        }
211 +
                        picker.lock().unwrap().got(i);
212 +
                        bytes.fetch_add(n, Ordering::Relaxed);
213 +
                        got.fetch_add(1, Ordering::Relaxed);
214 +
                        if peer.is_some() {
215 +
                            peer_bytes.fetch_add(n, Ordering::Relaxed);
216 +
                        }
217 +
                    }
218 +
                    Err(e) => {
219 +
                        picker.lock().unwrap().failed(i);
220 +
                        if let Some(o) = state.offers.lock().unwrap().get_mut(&m.hash) {
221 +
                            o.fetching.clear(i);
222 +
                        }
223 +
                        match (&peer, src) {
224 +
                            (Some(p), Source::Peer(idx)) => {
225 +
                                eprintln!("swarm: peer {} failed ({e:#}); carrying on without it", p.name);
226 +
                                p.alive.store(false, Ordering::Relaxed);
227 +
                                picker.lock().unwrap().drop_peer(idx);
228 +
                                break;
229 +
                            }
230 +
                            _ => {
231 +
                                if hub_failures.fetch_add(1, Ordering::Relaxed) + 1 >= HUB_FAILURES {
232 +
                                    *failed.lock().unwrap() = Some(e);
233 +
                                    break;
234 +
                                }
235 +
                                std::thread::sleep(Duration::from_millis(500));
236 +
                            }
237 +
                        }
238 +
                    }
239 +
                }
240 +
            };
241 +
            std::thread::scope(|s| {
242 +
                for _ in 0..HUB_WORKERS {
243 +
                    s.spawn(move || worker(Source::Hub));
244 +
                }
245 +
                let mut spawned = 0;
246 +
                let mut spawn_new = || {
247 +
                    let n = peers.lock().unwrap().len();
248 +
                    for p in spawned..n {
249 +
                        for _ in 0..PEER_WORKERS {
250 +
                            s.spawn(move || worker(Source::Peer(p)));
251 +
                        }
252 +
                    }
253 +
                    spawned = n;
254 +
                };
255 +
                spawn_new();
256 +
                // Keep the peers' maps fresh, and find new peers while
257 +
                // fetching, until every chunk is here.
258 +
                let mut asked_hub = Instant::now();
259 +
                let interval = Duration::from_secs(announced.as_ref().map(|a| a.interval_s).unwrap_or(30).max(5));
260 +
                let mut last = Instant::now();
261 +
                while picker.lock().unwrap().missing() > 0 && failed.lock().unwrap().is_none() {
262 +
                    std::thread::sleep(Duration::from_millis(100));
263 +
                    if last.elapsed() < REFRESH {
264 +
                        continue;
265 +
                    }
266 +
                    last = Instant::now();
267 +
                    let now: Vec<(usize, Arc<Peer>)> =
268 +
                        peers.lock().unwrap().iter().cloned().enumerate().filter(|(_, p)| p.alive.load(Ordering::Relaxed)).collect();
269 +
                    for (idx, p) in now {
270 +
                        match self.peer_have(&p, hash, needed.len()) {
271 +
                            Ok((bits, fetching)) => picker.lock().unwrap().peer_fetching(idx, bits, fetching),
272 +
                            Err(_) => {
273 +
                                p.alive.store(false, Ordering::Relaxed);
274 +
                                picker.lock().unwrap().drop_peer(idx);
275 +
                            }
276 +
                        }
277 +
                    }
278 +
                    if announced.is_some() && asked_hub.elapsed() >= interval {
279 +
                        asked_hub = Instant::now();
280 +
                        if let Ok(a) = client.announce(hash, "fetching") {
281 +
                            self.join(&a.peers, &a.ticket, hash, needed.len(), peers, picker);
282 +
                            spawn_new();
283 +
                        }
284 +
                    }
285 +
                }
286 +
            });
287 +
        }
288 +
        if let Some(e) = failed.into_inner().unwrap() {
289 +
            client.announce(hash, "stopped").ok();
290 +
            return Err(e);
291 +
        }
292 +
        write_image(home, &m)?;
293 +
        offer_whole(&self.state, &m, &home.rootfs(hash));
294 +
        client.announce(hash, "done").ok();
295 +
        Ok(Some(Moved {
296 +
            chunks: got.into_inner(),
297 +
            bytes: bytes.into_inner(),
298 +
            peer_bytes: peer_bytes.into_inner(),
299 +
            elapsed: start.elapsed(),
300 +
        }))
301 +
    }
302 +
303 +
    /// Admit this runner to the peers it does not know yet: a session from
304 +
    /// each, and what each has.
305 +
    fn join(&self, found: &[SwarmPeer], ticket: &str, hash: &str, len: usize, peers: &Mutex<Vec<Arc<Peer>>>, picker: &Mutex<Picker>) {
306 +
        for sp in found {
307 +
            if peers.lock().unwrap().iter().any(|p| p.runner == sp.runner) {
308 +
                continue;
309 +
            }
310 +
            let joined = self.hello(&sp.addr, ticket).and_then(|session| {
311 +
                let p = Peer {
312 +
                    runner: sp.runner,
313 +
                    name: sp.name.clone(),
314 +
                    base: format!("http://{}/swarm/v1", sp.addr),
315 +
                    session,
316 +
                    alive: AtomicBool::new(true),
317 +
                };
318 +
                let (bits, fetching) = self.peer_have(&p, hash, len)?;
319 +
                Ok((p, bits, fetching))
320 +
            });
321 +
            match joined {
322 +
                Ok((p, bits, fetching)) => {
323 +
                    let mut list = peers.lock().unwrap();
324 +
                    picker.lock().unwrap().peer_fetching(list.len(), bits, fetching);
325 +
                    list.push(Arc::new(p));
326 +
                }
327 +
                Err(e) => eprintln!("swarm: peer {} ({}): {e:#}", sp.name, sp.addr),
328 +
            }
329 +
        }
330 +
    }
331 +
332 +
    fn hello(&self, addr: &str, ticket: &str) -> Result<String> {
333 +
        let url = format!("http://{addr}/swarm/v1/hello");
334 +
        let r: serde_json::Value = self.peer.post(&url).send_string(ticket).map_err(|e| peer_error(&url, e))?.into_json()?;
335 +
        r["session"].as_str().map(String::from).context("the peer gave no session")
336 +
    }
337 +
338 +
    /// What a peer has of image `hash`, and what it is fetching now:
339 +
    /// nothing yet when it has not begun (the tracker may name it a moment
340 +
    /// before it starts).
341 +
    fn peer_have(&self, p: &Peer, hash: &str, len: usize) -> Result<(Bits, Bits)> {
342 +
        let url = format!("{}/images/{hash}/have", p.base);
343 +
        let r = match self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call() {
344 +
            Ok(r) => r,
345 +
            Err(ureq::Error::Status(404, _)) => return Ok((Bits::new(len), Bits::new(len))),
346 +
            Err(e) => return Err(peer_error(&url, e)),
347 +
        };
348 +
        let mut body = Vec::new();
349 +
        r.into_reader().take(len as u64 / 4 + 64).read_to_end(&mut body)?;
350 +
        if body == b"complete" {
351 +
            return Ok((Bits::full(len), Bits::new(len)));
352 +
        }
353 +
        let (have, fetching) = body.split_at(len.div_ceil(8).min(body.len()));
354 +
        Ok((Bits::from_bytes(len, have), Bits::from_bytes(len, fetching)))
355 +
    }
356 +
357 +
    fn peer_chunk(&self, p: &Peer, hash: &str) -> Result<Vec<u8>> {
358 +
        let url = format!("{}/chunks/{hash}", p.base);
359 +
        let r = self.peer.get(&url).set("Authorization", &format!("Bearer {}", p.session)).call().map_err(|e| peer_error(&url, e))?;
360 +
        let mut out = Vec::new();
361 +
        r.into_reader().take(oxy_core::chunk::MAX_SIZE as u64 + 1).read_to_end(&mut out)?;
362 +
        Ok(out)
363 +
    }
364 +
}
365 +
366 +
/// A peer this fetch asks for chunks.
367 +
struct Peer {
368 +
    runner: i64,
369 +
    name: String,
370 +
    base: String,
371 +
    session: String,
372 +
    alive: AtomicBool,
373 +
}
374 +
375 +
fn peer_error(url: &str, e: ureq::Error) -> anyhow::Error {
376 +
    match e {
377 +
        ureq::Error::Status(code, r) => anyhow::anyhow!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default().trim()),
378 +
        e => anyhow::anyhow!("{url}: {e}"),
379 +
    }
380 +
}
381 +
382 +
/// Offer a whole image: every chunk of it can be read out of `path`.
383 +
fn offer_whole(state: &State, m: &Manifest, path: &Path) {
384 +
    let mut located = state.located.lock().unwrap();
385 +
    let mut at = 0u64;
386 +
    for c in &m.chunks {
387 +
        if !c.is_zeros() {
388 +
            located.entry(c.hash().to_string()).or_insert_with(|| (path.to_path_buf(), at, c.1));
389 +
        }
390 +
        at += c.1 as u64;
391 +
    }
392 +
    state.offers.lock().unwrap().insert(m.hash.clone(), Offer { bits: None, fetching: Bits::new(0) });
393 +
}
394 +
395 +
fn accept(listener: TcpListener, state: Arc<State>) {
396 +
    for conn in listener.incoming() {
397 +
        let Ok(mut conn) = conn else { continue };
398 +
        if state.conns.load(Ordering::Relaxed) >= MAX_CONNS {
399 +
            respond(&mut conn, 503, "text/plain", b"busy: ask another peer").ok();
400 +
            continue;
401 +
        }
402 +
        state.conns.fetch_add(1, Ordering::Relaxed);
403 +
        let state = state.clone();
404 +
        std::thread::spawn(move || {
405 +
            serve_conn(conn, &state).ok();
406 +
            state.conns.fetch_sub(1, Ordering::Relaxed);
407 +
        });
408 +
    }
409 +
}
410 +
411 +
fn respond(conn: &mut TcpStream, code: u16, kind: &str, body: &[u8]) -> std::io::Result<()> {
412 +
    let reason = match code {
413 +
        200 => "OK",
414 +
        400 => "Bad Request",
415 +
        401 => "Unauthorized",
416 +
        403 => "Forbidden",
417 +
        404 => "Not Found",
418 +
        503 => "Service Unavailable",
419 +
        _ => "Error",
420 +
    };
421 +
    write!(conn, "HTTP/1.1 {code} {reason}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n\r\n", body.len())?;
422 +
    conn.write_all(body)?;
423 +
    conn.flush()
424 +
}
425 +
426 +
/// One connection: requests one after another while the peer keeps it.
427 +
fn serve_conn(conn: TcpStream, state: &State) -> std::io::Result<()> {
428 +
    conn.set_read_timeout(Some(Duration::from_secs(30)))?;
429 +
    let mut out = conn.try_clone()?;
430 +
    let mut r = BufReader::new(conn);
431 +
    loop {
432 +
        let mut line = String::new();
433 +
        if r.read_line(&mut line)? == 0 {
434 +
            return Ok(());
435 +
        }
436 +
        let mut parts = line.split_whitespace();
437 +
        let (method, path) = (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").to_string());
438 +
        let (mut length, mut auth, mut close) = (0usize, String::new(), false);
439 +
        loop {
440 +
            let mut h = String::new();
441 +
            if r.read_line(&mut h)? == 0 {
442 +
                return Ok(());
443 +
            }
444 +
            let h = h.trim_end();
445 +
            if h.is_empty() {
446 +
                break;
447 +
            }
448 +
            if let Some((k, v)) = h.split_once(':') {
449 +
                match k.trim().to_ascii_lowercase().as_str() {
450 +
                    "content-length" => length = v.trim().parse().unwrap_or(usize::MAX),
451 +
                    "authorization" => auth = v.trim().to_string(),
452 +
                    "connection" => close = v.trim().eq_ignore_ascii_case("close"),
453 +
                    _ => {}
454 +
                }
455 +
            }
456 +
        }
457 +
        if length > MAX_BODY {
458 +
            return respond(&mut out, 400, "text/plain", b"too large");
459 +
        }
460 +
        let mut body = vec![0; length];
461 +
        r.read_exact(&mut body)?;
462 +
        let (code, kind, reply) = route(state, &method, &path, &auth, &body);
463 +
        if code == 200 && kind == "application/octet-stream" {
464 +
            throttle(state, reply.len());
465 +
        }
466 +
        respond(&mut out, code, kind, &reply)?;
467 +
        if close {
468 +
            return Ok(());
469 +
        }
470 +
    }
471 +
}
472 +
473 +
fn route(state: &State, method: &str, path: &str, auth: &str, body: &[u8]) -> (u16, &'static str, Vec<u8>) {
474 +
    let text = |code, s: &str| (code, "text/plain", s.as_bytes().to_vec());
475 +
    if method == "POST" && path == "/swarm/v1/hello" {
476 +
        return match hello(state, &String::from_utf8_lossy(body)) {
477 +
            Ok(session) => (200, "application/json", serde_json::json!({ "session": session }).to_string().into_bytes()),
478 +
            Err(why) => text(403, &why),
479 +
        };
480 +
    }
481 +
    let session = auth.strip_prefix("Bearer ").unwrap_or("");
482 +
    let admitted = {
483 +
        let mut sessions = state.sessions.lock().unwrap();
484 +
        let now = now_ms();
485 +
        sessions.retain(|_, (_, until)| *until > now);
486 +
        sessions.contains_key(session)
487 +
    };
488 +
    if !admitted {
489 +
        return text(401, "show a ticket at /swarm/v1/hello first");
490 +
    }
491 +
    let rest = path.strip_prefix("/swarm/v1/").unwrap_or("");
492 +
    match (method, rest.split('/').collect::<Vec<_>>().as_slice()) {
493 +
        ("GET", ["images", image, "have"]) => match state.offers.lock().unwrap().get(*image) {
494 +
            Some(Offer { bits: None, .. }) => (200, "text/plain", b"complete".to_vec()),
495 +
            Some(Offer { bits: Some(b), fetching }) => (200, "application/x-have", [b.to_bytes(), fetching.to_bytes()].concat()),
496 +
            None => text(404, "this runner does not hold that image"),
497 +
        },
498 +
        ("GET", ["chunks", chunk]) if is_hash(chunk) => match read_chunk(state, chunk) {
499 +
            Some(b) => (200, "application/octet-stream", b),
500 +
            None => text(404, "no such chunk here"),
501 +
        },
502 +
        _ => text(404, "not a swarm path"),
503 +
    }
504 +
}
505 +
506 +
/// Admit a peer that shows a ticket from this runner's hub, for this
507 +
/// site: a session until the ticket expires.
508 +
fn hello(state: &State, ticket: &str) -> std::result::Result<String, String> {
509 +
    let hub = {
510 +
        let mut hub = state.hub.lock().unwrap();
511 +
        if hub.is_none() {
512 +
            *hub = state.client.hub_identity().ok();
513 +
        }
514 +
        hub.clone().ok_or("this runner cannot reach its hub to check tickets")?
515 +
    };
516 +
    let t = Ticket::open(ticket, &hub, now_ms())?;
517 +
    if t.site != state.site {
518 +
        return Err(format!("this runner is in site {:?}, the ticket's runner in {:?}", state.site, t.site));
519 +
    }
520 +
    let session = random_token().map_err(|e| format!("make a session: {e}"))?;
521 +
    state.sessions.lock().unwrap().insert(session.clone(), (t.name, t.expires_ms));
522 +
    Ok(session)
523 +
}
524 +
525 +
fn random_token() -> std::io::Result<String> {
526 +
    let mut b = [0u8; 32];
527 +
    std::fs::File::open("/dev/urandom")?.read_exact(&mut b)?;
528 +
    Ok(hex::encode(b))
529 +
}
530 +
531 +
fn read_chunk(state: &State, hash: &str) -> Option<Vec<u8>> {
532 +
    if let Ok(b) = std::fs::read(cache_path(&state.root.join("chunks"), hash)) {
533 +
        return Some(b);
534 +
    }
535 +
    let (path, at, len) = state.located.lock().unwrap().get(hash).cloned()?;
536 +
    let mut f = std::fs::File::open(path).ok()?;
537 +
    f.seek(SeekFrom::Start(at)).ok()?;
538 +
    let mut b = vec![0; len as usize];
539 +
    f.read_exact(&mut b).ok()?;
540 +
    Some(b)
541 +
}
542 +
543 +
/// Hold back until sending `n` more bytes keeps within the upload rate.
544 +
fn throttle(state: &State, n: usize) {
545 +
    let wait = {
546 +
        let mut b = state.bucket.lock().unwrap();
547 +
        let now = Instant::now();
548 +
        b.0 = (b.0 + now.duration_since(b.1).as_secs_f64() * state.rate).min(state.rate);
549 +
        b.1 = now;
550 +
        b.0 -= n as f64;
551 +
        if b.0 < 0.0 {
552 +
            -b.0 / state.rate
553 +
        } else {
554 +
            0.0
555 +
        }
556 +
    };
557 +
    if wait > 0.0 {
558 +
        std::thread::sleep(Duration::from_secs_f64(wait));
559 +
    }
560 +
}
561 +
562 +
#[cfg(test)]
563 +
mod tests {
564 +
    use super::*;
565 +
    use oxy_core::identity::SecretKey;
566 +
567 +
    /// A hub that holds one image and runs the tracker: it serves the
568 +
    /// manifest and chunks (counting the chunks), and answers announces
569 +
    /// with every other runner as a peer and a ticket for the caller,
570 +
    /// known by its token.
571 +
    struct FakeHub {
572 +
        url: String,
573 +
        chunks_served: Arc<AtomicUsize>,
574 +
        runners: Arc<Mutex<Vec<(String, SwarmPeer)>>>,
575 +
    }
576 +
577 +
    fn fake_hub(m: Manifest, image: Vec<u8>, site: &str) -> FakeHub {
578 +
        slow_hub(m, image, site, Duration::ZERO)
579 +
    }
580 +
581 +
    /// A hub that takes `delay` over each chunk.
582 +
    fn slow_hub(m: Manifest, image: Vec<u8>, site: &str, delay: Duration) -> FakeHub {
583 +
        let sk = SecretKey::generate();
584 +
        let id = sk.identity("hub", "hub@example", 1);
585 +
        let key = id.hash();
586 +
        let mut chunks = HashMap::new();
587 +
        let mut at = 0usize;
588 +
        for c in &m.chunks {
589 +
            chunks.insert(c.hash().to_string(), image[at..at + c.len()].to_vec());
590 +
            at += c.len();
591 +
        }
592 +
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
593 +
        let url = format!("http://{}", listener.local_addr().unwrap());
594 +
        let served = Arc::new(AtomicUsize::new(0));
595 +
        let runners: Arc<Mutex<Vec<(String, SwarmPeer)>>> = Arc::new(Mutex::new(Vec::new()));
596 +
        let (count, known, site) = (served.clone(), runners.clone(), site.to_string());
597 +
        let shared = Arc::new((m, chunks, id, key, sk));
598 +
        std::thread::spawn(move || {
599 +
            for conn in listener.incoming() {
600 +
                let Ok(conn) = conn else { continue };
601 +
                let (shared, count, known, site) = (shared.clone(), count.clone(), known.clone(), site.clone());
602 +
                std::thread::spawn(move || {
603 +
                    let (m, chunks, id, key, sk) = &*shared;
604 +
                    let mut out = conn.try_clone().unwrap();
605 +
                    let mut r = BufReader::new(conn);
606 +
                    loop {
607 +
                        let mut line = String::new();
608 +
                        if r.read_line(&mut line).unwrap_or(0) == 0 {
609 +
                            return;
610 +
                        }
611 +
                        let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
612 +
                        let (mut len, mut token) = (0, String::new());
613 +
                        loop {
614 +
                            let mut h = String::new();
615 +
                            r.read_line(&mut h).unwrap();
616 +
                            let h = h.trim_end().to_string();
617 +
                            if h.is_empty() {
618 +
                                break;
619 +
                            }
620 +
                            let lower = h.to_ascii_lowercase();
621 +
                            if let Some(v) = lower.strip_prefix("content-length:") {
622 +
                                len = v.trim().parse().unwrap();
623 +
                            }
624 +
                            if let Some(v) = h.strip_prefix("Authorization: Bearer ") {
625 +
                                token = v.to_string();
626 +
                            }
627 +
                        }
628 +
                        let mut body = vec![0; len];
629 +
                        r.read_exact(&mut body).unwrap();
630 +
                        let (code, reply): (u16, Vec<u8>) = if path == "/.well-known/oxyhub-key" {
631 +
                            (200, id.encode())
632 +
                        } else if path == format!("/api/v1/flow/images/{}", m.hash) {
633 +
                            (200, serde_json::to_vec(m).unwrap())
634 +
                        } else if let Some(c) = path.strip_prefix("/api/v1/flow/images/chunks/") {
635 +
                            count.fetch_add(1, Ordering::Relaxed);
636 +
                            std::thread::sleep(delay);
637 +
                            (200, chunks[c].clone())
638 +
                        } else if path == "/api/v1/flow/swarm/announce" {
639 +
                            let runners = known.lock().unwrap();
640 +
                            let me = runners.iter().find(|(t, _)| *t == token).map(|(_, p)| p.clone()).unwrap();
641 +
                            let ticket = Ticket {
642 +
                                hub: key.to_hex(),
643 +
                                runner: me.runner,
644 +
                                name: me.name.clone(),
645 +
                                site: site.clone(),
646 +
                                expires_ms: now_ms() + 60_000,
647 +
                            };
648 +
                            let peers: Vec<SwarmPeer> = runners.iter().filter(|(t, _)| *t != token).map(|(_, p)| p.clone()).collect();
649 +
                            let a = flow_core::api::Announced { ticket: ticket.seal(key, sk), peers, interval_s: 5 };
650 +
                            (200, serde_json::to_vec(&a).unwrap())
651 +
                        } else {
652 +
                            (404, b"no".to_vec())
653 +
                        };
654 +
                        let head = format!("HTTP/1.1 {code} X\r\nContent-Length: {}\r\n\r\n", reply.len());
655 +
                        if out.write_all(head.as_bytes()).and_then(|_| out.write_all(&reply)).is_err() {
656 +
                            return;
657 +
                        }
658 +
                    }
659 +
                });
660 +
            }
661 +
        });
662 +
        FakeHub { url, chunks_served: served, runners }
663 +
    }
664 +
665 +
    fn free_port() -> u16 {
666 +
        TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
667 +
    }
668 +
669 +
    /// A runner home in `dir` with a swarm on `port`, known to `hub` by
670 +
    /// `token` as runner `id`.
671 +
    fn runner(dir: &Path, hub: &FakeHub, id: i64, token: &str, port: u16, site: &str) -> (Home, Swarm) {
672 +
        let toml = format!(
673 +
            "hub = \"{}\"\ntoken = \"{token}\"\nname = \"r{id}\"\n[swarm]\nlisten = \"127.0.0.1:{port}\"\nsite = \"{site}\"\n",
674 +
            hub.url
675 +
        );
676 +
        std::fs::write(dir.join(crate::home::RUNNER_TOML), toml).unwrap();
677 +
        let home = Home::locate(Some(dir)).unwrap();
678 +
        let cfg = home.config.runner.clone().unwrap();
679 +
        let swarm = Swarm::start(&home, &cfg).unwrap().unwrap();
680 +
        hub.runners
681 +
            .lock()
682 +
            .unwrap()
683 +
            .push((token.into(), SwarmPeer { runner: id, name: format!("r{id}"), addr: format!("127.0.0.1:{port}"), complete: true }));
684 +
        (home, swarm)
685 +
    }
686 +
687 +
    fn image() -> Vec<u8> {
688 +
        // 3 MiB of varied bytes, with a run of zeros the swarm never moves.
689 +
        let mut x = 0x9e3779b97f4a7c15u64;
690 +
        let mut b: Vec<u8> = (0..3 << 20)
691 +
            .map(|_| {
692 +
                x ^= x << 13;
693 +
                x ^= x >> 7;
694 +
                x ^= x << 17;
695 +
                x as u8
696 +
            })
697 +
            .collect();
698 +
        b[1 << 20..(1 << 20) + (300 << 10)].fill(0);
699 +
        b
700 +
    }
701 +
702 +
    #[test]
703 +
    fn a_runner_fetches_an_image_from_its_peer_and_not_the_hub() {
704 +
        let bytes = image();
705 +
        let m = jet::images::scan(&bytes[..], Some("x86_64".into()), |_, _| Ok(())).unwrap();
706 +
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
707 +
        let (a_dir, b_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
708 +
        // Runner A holds the image, imported rather than fetched: its swarm
709 +
        // scans it for a manifest before offering it.
710 +
        std::fs::create_dir_all(a_dir.path().join("cas")).unwrap();
711 +
        std::fs::write(a_dir.path().join("cas").join(format!("{}.ext4", m.hash)), &bytes).unwrap();
712 +
        let (_a_home, a) = runner(a_dir.path(), &hub, 1, "ta", free_port(), "lab");
713 +
        let deadline = Instant::now() + Duration::from_secs(20);
714 +
        while !a.state.offers.lock().unwrap().contains_key(&m.hash) {
715 +
            assert!(Instant::now() < deadline, "runner A never offered its image");
716 +
            std::thread::sleep(Duration::from_millis(20));
717 +
        }
718 +
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
719 +
        let moved = b.fetch(&b_home, &m.hash).unwrap().unwrap();
720 +
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes, "the image came back whole");
721 +
        assert_eq!(moved.peer_bytes, moved.bytes, "every chunk came from the peer");
722 +
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), 0, "the hub sent no chunks");
723 +
        assert_eq!(moved.chunks, m.needed().len());
724 +
        // B now offers the whole image too, read out of its rootfs.
725 +
        assert!(matches!(b.state.offers.lock().unwrap().get(&m.hash), Some(Offer { bits: None, .. })));
726 +
    }
727 +
728 +
    #[test]
729 +
    fn runners_starting_together_share_what_each_has_fetched() {
730 +
        let bytes = image();
731 +
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
732 +
        let needed = m.needed().len();
733 +
        // A slow hub, as a remote one is: the runners have time to swap.
734 +
        let hub = slow_hub(m.clone(), bytes.clone(), "lab", Duration::from_millis(400));
735 +
        let (b_dir, c_dir) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
736 +
        let (b_home, b) = runner(b_dir.path(), &hub, 2, "tb", free_port(), "lab");
737 +
        let (c_home, c) = runner(c_dir.path(), &hub, 3, "tc", free_port(), "lab");
738 +
        let (mb, mc) = std::thread::scope(|s| {
739 +
            let fb = s.spawn(|| b.fetch(&b_home, &m.hash).unwrap().unwrap());
740 +
            let fc = s.spawn(|| c.fetch(&c_home, &m.hash).unwrap().unwrap());
741 +
            (fb.join().unwrap(), fc.join().unwrap())
742 +
        });
743 +
        assert_eq!(std::fs::read(b_home.rootfs(&m.hash)).unwrap(), bytes);
744 +
        assert_eq!(std::fs::read(c_home.rootfs(&m.hash)).unwrap(), bytes);
745 +
        let served = hub.chunks_served.load(Ordering::Relaxed);
746 +
        assert!(served < 2 * needed, "the hub sent {served} chunks for two runners needing {needed} each");
747 +
        assert!(mb.peer_bytes + mc.peer_bytes > 0, "they shared");
748 +
    }
749 +
750 +
    #[test]
751 +
    fn without_peers_the_hub_sends_everything_and_a_ticket_from_another_site_is_refused() {
752 +
        let bytes = image();
753 +
        let m = jet::images::scan(&bytes[..], None, |_, _| Ok(())).unwrap();
754 +
        let hub = fake_hub(m.clone(), bytes.clone(), "lab");
755 +
        let dir = tempfile::tempdir().unwrap();
756 +
        let (home, s) = runner(dir.path(), &hub, 1, "t1", free_port(), "lab");
757 +
        let moved = s.fetch(&home, &m.hash).unwrap().unwrap();
758 +
        assert_eq!(moved.peer_bytes, 0);
759 +
        assert_eq!(hub.chunks_served.load(Ordering::Relaxed), m.needed().len());
760 +
        assert_eq!(std::fs::read(home.rootfs(&m.hash)).unwrap(), bytes);
761 +
762 +
        // A runner in another site shows a ticket for "lab": refused.
763 +
        let other = tempfile::tempdir().unwrap();
764 +
        let port = free_port();
765 +
        let (_h, o) = runner(other.path(), &hub, 3, "t3", port, "elsewhere");
766 +
        let ticket = s.state.client.announce(&m.hash, "fetching").unwrap().ticket;
767 +
        let err = o.hello(&format!("127.0.0.1:{port}"), &ticket).unwrap_err();
768 +
        assert!(format!("{err:#}").contains("HTTP 403"), "{err:#}");
769 +
        // And no session, no chunks.
770 +
        let url = format!("http://127.0.0.1:{port}/swarm/v1/chunks/{}", m.chunks[0].hash());
771 +
        assert!(matches!(o.peer.get(&url).call(), Err(ureq::Error::Status(401, _))));
772 +
    }
773 +
774 +
    #[test]
775 +
    fn the_upload_rate_holds_back_a_burst() {
776 +
        let state = State {
777 +
            root: PathBuf::new(),
778 +
            site: String::new(),
779 +
            client: Client::new("http://127.0.0.1:1", "t", None).unwrap(),
780 +
            hub: Mutex::new(None),
781 +
            offers: Mutex::new(HashMap::new()),
782 +
            located: Mutex::new(HashMap::new()),
783 +
            sessions: Mutex::new(HashMap::new()),
784 +
            conns: AtomicUsize::new(0),
785 +
            bucket: Mutex::new((1_000_000.0, Instant::now())),
786 +
            rate: 1_000_000.0,
787 +
        };
788 +
        let start = Instant::now();
789 +
        throttle(&state, 1_000_000);
790 +
        assert!(start.elapsed() < Duration::from_millis(100), "the first second's worth goes at once");
791 +
        throttle(&state, 300_000);
792 +
        assert!(start.elapsed() >= Duration::from_millis(250), "then it waits for the rate");
793 +
    }
794 +
}
added crates/jet/src/swarm.rs +430 −0
1 +
//! The swarm (oxygenflow#47, docs/jetstream.md §8.1): runners fetch image
2 +
//! chunks from each other as well as from the hub.
3 +
//!
4 +
//! This module holds what both ends agree on and what can be tested
5 +
//! without a network: the have-map a peer offers ([`Bits`]), the ticket the
6 +
//! hub signs to admit a runner to its peers ([`Ticket`]), and the order in
7 +
//! which a fetcher asks its sources for chunks ([`Picker`]): rarest first,
8 +
//! the hub only for what no peer has, and an endgame at the close.
9 +
10 +
use oxy_core::hash::KeyId;
11 +
use oxy_core::identity::{verify_challenge, Identity, SecretKey, Signature};
12 +
use serde::{Deserialize, Serialize};
13 +
use std::collections::HashMap;
14 +
use std::time::{Duration, Instant};
15 +
16 +
/// How long a ticket admits a runner to its peers.
17 +
pub const TICKET_LIFE_MS: u64 = 15 * 60 * 1000;
18 +
/// Peers make no progress for this long and the hub is asked for anything.
19 +
pub const STALL: Duration = Duration::from_secs(2);
20 +
/// Most sources asked for the same chunk in the endgame.
21 +
const ENDGAME_COPIES: u8 = 2;
22 +
23 +
/// One bit per chunk of a manifest's needed list, in its order.
24 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
25 +
pub struct Bits {
26 +
    words: Vec<u64>,
27 +
    len: usize,
28 +
}
29 +
30 +
impl Bits {
31 +
    pub fn new(len: usize) -> Bits {
32 +
        Bits { words: vec![0; len.div_ceil(64)], len }
33 +
    }
34 +
35 +
    /// Every bit set: a peer that holds the whole image.
36 +
    pub fn full(len: usize) -> Bits {
37 +
        let mut b = Bits::new(len);
38 +
        (0..len).for_each(|i| b.set(i));
39 +
        b
40 +
    }
41 +
42 +
    pub fn len(&self) -> usize {
43 +
        self.len
44 +
    }
45 +
46 +
    pub fn is_empty(&self) -> bool {
47 +
        self.len == 0
48 +
    }
49 +
50 +
    pub fn get(&self, i: usize) -> bool {
51 +
        i < self.len && self.words[i / 64] & (1 << (i % 64)) != 0
52 +
    }
53 +
54 +
    pub fn set(&mut self, i: usize) {
55 +
        if i < self.len {
56 +
            self.words[i / 64] |= 1 << (i % 64);
57 +
        }
58 +
    }
59 +
60 +
    pub fn clear(&mut self, i: usize) {
61 +
        if i < self.len {
62 +
            self.words[i / 64] &= !(1 << (i % 64));
63 +
        }
64 +
    }
65 +
66 +
    pub fn count(&self) -> usize {
67 +
        self.words.iter().map(|w| w.count_ones() as usize).sum()
68 +
    }
69 +
70 +
    /// Little-endian bytes, as `GET …/have` sends them.
71 +
    pub fn to_bytes(&self) -> Vec<u8> {
72 +
        let mut out: Vec<u8> = self.words.iter().flat_map(|w| w.to_le_bytes()).collect();
73 +
        out.truncate(self.len.div_ceil(8));
74 +
        out
75 +
    }
76 +
77 +
    /// The bits of a `have` answer for a list of `len` chunks; bits past
78 +
    /// `len` are ignored, and a short answer means the rest are unset.
79 +
    pub fn from_bytes(len: usize, bytes: &[u8]) -> Bits {
80 +
        let mut b = Bits::new(len);
81 +
        for (i, byte) in bytes.iter().enumerate().take(len.div_ceil(8)) {
82 +
            b.words[i / 8] |= (*byte as u64) << (8 * (i % 8));
83 +
        }
84 +
        if let Some(last) = b.words.last_mut() {
85 +
            if !len.is_multiple_of(64) {
86 +
                *last &= (1u64 << (len % 64)) - 1;
87 +
            }
88 +
        }
89 +
        b
90 +
    }
91 +
}
92 +
93 +
/// What the hub signs to admit a runner to its peers.
94 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95 +
pub struct Ticket {
96 +
    /// The hub's key, hex: a peer accepts only its own hub's tickets.
97 +
    pub hub: String,
98 +
    pub runner: i64,
99 +
    pub name: String,
100 +
    pub site: String,
101 +
    pub expires_ms: u64,
102 +
}
103 +
104 +
impl Ticket {
105 +
    /// The ticket as text: its JSON and the hub's signature over it, hex,
106 +
    /// joined by a dot.
107 +
    pub fn seal(&self, key: &KeyId, secret: &SecretKey) -> String {
108 +
        let body = serde_json::to_vec(self).expect("a ticket serialises");
109 +
        let sig = secret.sign_challenge(key, &body).expect("a ticket is longer than 16 bytes");
110 +
        format!("{}.{}", hex::encode(&body), hex::encode(Signature::encode_list(&[sig])))
111 +
    }
112 +
113 +
    /// Read and check a ticket: signed by `hub`, not expired at `now_ms`.
114 +
    pub fn open(text: &str, hub: &Identity, now_ms: u64) -> Result<Ticket, String> {
115 +
        let (body, sig) = text.trim().split_once('.').ok_or("not a ticket")?;
116 +
        let body = hex::decode(body).map_err(|_| "not a ticket")?;
117 +
        let sigs = hex::decode(sig).ok().and_then(|b| Signature::decode_list(&b).ok()).ok_or("not a ticket's signature")?;
118 +
        let sig = sigs.first().ok_or("an unsigned ticket")?;
119 +
        verify_challenge(hub, sig, &body).map_err(|_| "the ticket is not signed by this runner's hub")?;
120 +
        let t: Ticket = serde_json::from_slice(&body).map_err(|_| "not a ticket")?;
121 +
        if t.hub != hub.hash().to_hex() {
122 +
            return Err("the ticket names another hub".into());
123 +
        }
124 +
        if t.expires_ms <= now_ms {
125 +
            return Err("the ticket has expired".into());
126 +
        }
127 +
        Ok(t)
128 +
    }
129 +
}
130 +
131 +
/// Where a chunk comes from: the hub, or a peer by its index.
132 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133 +
pub enum Source {
134 +
    Hub,
135 +
    Peer(usize),
136 +
}
137 +
138 +
/// What a source should do next.
139 +
#[derive(Debug, PartialEq, Eq)]
140 +
pub enum Pick {
141 +
    /// Ask for this chunk (an index into the needed list).
142 +
    Chunk(usize),
143 +
    /// Nothing for this source now; ask again shortly.
144 +
    Wait,
145 +
    /// Every chunk is here.
146 +
    Done,
147 +
}
148 +
149 +
/// The order a fetcher asks its sources for chunks (docs/jetstream.md
150 +
/// §8.1): the rarest chunk a peer holds first; the hub for chunks no peer
151 +
/// holds, or for anything once the peers stall; and in the endgame, a
152 +
/// second source for a chunk already asked for.
153 +
pub struct Picker {
154 +
    have: Bits,
155 +
    asked: Vec<u8>,
156 +
    /// The source first asked for each chunk in flight: the endgame asks
157 +
    /// a different one.
158 +
    first: Vec<Option<Source>>,
159 +
    /// Each peer's map: what it has, and what it is fetching now.
160 +
    peers: HashMap<usize, (Bits, Bits)>,
161 +
    /// Missing chunks, rarest first; rebuilt when the peers' maps change.
162 +
    order: Vec<usize>,
163 +
    /// How many peers hold each chunk, as of the last rebuild.
164 +
    holders: Vec<u16>,
165 +
    /// Whether some peer is fetching each chunk now: the hub is not asked
166 +
    /// for it, the peer will have it shortly.
167 +
    coming: Vec<bool>,
168 +
    progress: Instant,
169 +
    seed: u64,
170 +
}
171 +
172 +
impl Picker {
173 +
    /// A picker for `len` chunks, of which `have` are already here.
174 +
    pub fn new(len: usize, have: Bits, seed: u64) -> Picker {
175 +
        let mut p = Picker {
176 +
            have,
177 +
            asked: vec![0; len],
178 +
            first: vec![None; len],
179 +
            peers: HashMap::new(),
180 +
            order: Vec::new(),
181 +
            holders: vec![0; len],
182 +
            coming: vec![false; len],
183 +
            progress: Instant::now(),
184 +
            seed: seed | 1,
185 +
        };
186 +
        p.reorder();
187 +
        p
188 +
    }
189 +
190 +
    pub fn len(&self) -> usize {
191 +
        self.asked.len()
192 +
    }
193 +
194 +
    pub fn is_empty(&self) -> bool {
195 +
        self.asked.is_empty()
196 +
    }
197 +
198 +
    pub fn missing(&self) -> usize {
199 +
        self.len() - self.have.count()
200 +
    }
201 +
202 +
    pub fn have(&self) -> &Bits {
203 +
        &self.have
204 +
    }
205 +
206 +
    /// A peer's have-map, new or refreshed.
207 +
    pub fn peer(&mut self, peer: usize, bits: Bits) {
208 +
        let none = Bits::new(bits.len());
209 +
        self.peer_fetching(peer, bits, none);
210 +
    }
211 +
212 +
    /// A peer's have-map and the chunks it is fetching now.
213 +
    pub fn peer_fetching(&mut self, peer: usize, have: Bits, fetching: Bits) {
214 +
        self.peers.insert(peer, (have, fetching));
215 +
        self.reorder();
216 +
    }
217 +
218 +
    /// A peer that failed: it is asked for nothing more.
219 +
    pub fn drop_peer(&mut self, peer: usize) {
220 +
        self.peers.remove(&peer);
221 +
        self.reorder();
222 +
    }
223 +
224 +
    fn next_rand(&mut self) -> u64 {
225 +
        // xorshift64: enough to spread runners that start together.
226 +
        self.seed ^= self.seed << 13;
227 +
        self.seed ^= self.seed >> 7;
228 +
        self.seed ^= self.seed << 17;
229 +
        self.seed
230 +
    }
231 +
232 +
    fn reorder(&mut self) {
233 +
        let mut keyed: Vec<(u16, u64, usize)> = Vec::new();
234 +
        for i in 0..self.len() {
235 +
            self.holders[i] = self.peers.values().filter(|(b, _)| b.get(i)).count().min(u16::MAX as usize) as u16;
236 +
            self.coming[i] = self.peers.values().any(|(_, f)| f.get(i));
237 +
            if !self.have.get(i) {
238 +
                let r = self.next_rand();
239 +
                keyed.push((self.holders[i], r, i));
240 +
            }
241 +
        }
242 +
        keyed.sort_unstable();
243 +
        self.order = keyed.into_iter().map(|(_, _, i)| i).collect();
244 +
    }
245 +
246 +
    /// The next chunk for `src` to fetch.
247 +
    pub fn pick(&mut self, src: Source) -> Pick {
248 +
        if self.missing() == 0 {
249 +
            return Pick::Done;
250 +
        }
251 +
        let holds = |i: usize| match src {
252 +
            Source::Hub => true,
253 +
            Source::Peer(p) => self.peers.get(&p).is_some_and(|(b, _)| b.get(i)),
254 +
        };
255 +
        let stalled = self.progress.elapsed() >= STALL;
256 +
        // Not yet asked for: a peer takes the rarest it holds; the hub
257 +
        // takes what no peer holds, or anything once the peers stall.
258 +
        let fresh = self.order.iter().copied().find(|&i| {
259 +
            !self.have.get(i)
260 +
                && self.asked[i] == 0
261 +
                && holds(i)
262 +
                && (src != Source::Hub || stalled || self.peers.is_empty() || (self.holders[i] == 0 && !self.coming[i]))
263 +
        });
264 +
        if let Some(i) = fresh {
265 +
            self.asked[i] += 1;
266 +
            self.first[i] = Some(src);
267 +
            return Pick::Chunk(i);
268 +
        }
269 +
        // The endgame: everything missing is asked for already. The hub
270 +
        // joins it only once the peers stall.
271 +
        if src == Source::Hub && !stalled && !self.peers.is_empty() {
272 +
            return Pick::Wait;
273 +
        }
274 +
        let mut left = self.order.iter().copied().filter(|&i| !self.have.get(i));
275 +
        if left.clone().all(|i| self.asked[i] > 0) {
276 +
            if let Some(i) = left.find(|&i| self.asked[i] < ENDGAME_COPIES && self.first[i] != Some(src) && holds(i)) {
277 +
                self.asked[i] += 1;
278 +
                return Pick::Chunk(i);
279 +
            }
280 +
        }
281 +
        Pick::Wait
282 +
    }
283 +
284 +
    /// Chunk `i` arrived and was checked.
285 +
    pub fn got(&mut self, i: usize) {
286 +
        self.have.set(i);
287 +
        self.asked[i] = self.asked[i].saturating_sub(1);
288 +
        self.first[i] = None;
289 +
        self.progress = Instant::now();
290 +
    }
291 +
292 +
    /// Chunk `i` did not come: it can be asked for again.
293 +
    pub fn failed(&mut self, i: usize) {
294 +
        self.asked[i] = self.asked[i].saturating_sub(1);
295 +
        if self.asked[i] == 0 {
296 +
            self.first[i] = None;
297 +
        }
298 +
    }
299 +
}
300 +
301 +
#[cfg(test)]
302 +
mod tests {
303 +
    use super::*;
304 +
305 +
    #[test]
306 +
    fn bits_travel_as_bytes() {
307 +
        let mut b = Bits::new(70);
308 +
        [0, 9, 63, 64, 69].iter().for_each(|&i| b.set(i));
309 +
        let back = Bits::from_bytes(70, &b.to_bytes());
310 +
        assert_eq!(back, b);
311 +
        assert_eq!(b.to_bytes().len(), 9);
312 +
        assert_eq!(Bits::from_bytes(10, &[0xff, 0xff, 0xff]).count(), 10, "bits past the list are ignored");
313 +
        assert_eq!(Bits::from_bytes(10, &[]).count(), 0, "a short answer leaves the rest unset");
314 +
        assert_eq!(Bits::full(130).count(), 130);
315 +
    }
316 +
317 +
    #[test]
318 +
    fn a_ticket_opens_only_with_its_hub_and_in_time() {
319 +
        let sk = SecretKey::generate();
320 +
        let hub = sk.identity("hub", "hub@example", 1);
321 +
        let t = Ticket { hub: hub.hash().to_hex(), runner: 7, name: "earth-1".into(), site: "home".into(), expires_ms: 10_000 };
322 +
        let text = t.seal(&hub.hash(), &sk);
323 +
        assert_eq!(Ticket::open(&text, &hub, 9_999).unwrap(), t);
324 +
        assert!(Ticket::open(&text, &hub, 10_000).unwrap_err().contains("expired"));
325 +
        let other_sk = SecretKey::generate();
326 +
        let other = other_sk.identity("other", "o@example", 1);
327 +
        assert!(Ticket::open(&text, &other, 1).is_err(), "another hub's key");
328 +
        let mut forged = text.clone();
329 +
        forged.replace_range(2..4, "ff");
330 +
        assert!(Ticket::open(&forged, &hub, 1).is_err(), "a changed body");
331 +
        assert!(Ticket::open("nonsense", &hub, 1).is_err());
332 +
    }
333 +
334 +
    #[test]
335 +
    fn peers_are_asked_for_the_rarest_chunk_they_hold() {
336 +
        let mut p = Picker::new(4, Bits::new(4), 42);
337 +
        let mut a = Bits::new(4);
338 +
        [0, 1, 2].iter().for_each(|&i| a.set(i));
339 +
        let mut b = Bits::new(4);
340 +
        [0, 1].iter().for_each(|&i| b.set(i));
341 +
        p.peer(0, a);
342 +
        p.peer(1, b);
343 +
        // Chunk 2 only peer 0 holds; chunk 3 nobody does.
344 +
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(2));
345 +
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(3), "the hub seeds what no peer holds");
346 +
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "and leaves the rest to the peers");
347 +
        let first = p.pick(Source::Peer(1));
348 +
        assert!(matches!(first, Pick::Chunk(0) | Pick::Chunk(1)));
349 +
    }
350 +
351 +
    #[test]
352 +
    fn the_hub_is_not_asked_for_what_a_peer_is_fetching() {
353 +
        let mut p = Picker::new(2, Bits::new(2), 4);
354 +
        let mut fetching = Bits::new(2);
355 +
        fetching.set(0);
356 +
        p.peer_fetching(0, Bits::new(2), fetching);
357 +
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(1));
358 +
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "chunk 0 is coming from the peer");
359 +
        let mut have = Bits::new(2);
360 +
        have.set(0);
361 +
        p.peer(0, have);
362 +
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0));
363 +
    }
364 +
365 +
    #[test]
366 +
    fn without_peers_the_hub_serves_everything() {
367 +
        let mut p = Picker::new(3, Bits::new(3), 1);
368 +
        let mut seen = Vec::new();
369 +
        while let Pick::Chunk(i) = p.pick(Source::Hub) {
370 +
            seen.push(i);
371 +
            p.got(i);
372 +
        }
373 +
        seen.sort();
374 +
        assert_eq!(seen, vec![0, 1, 2]);
375 +
        assert_eq!(p.pick(Source::Hub), Pick::Done);
376 +
    }
377 +
378 +
    #[test]
379 +
    fn the_endgame_never_asks_the_same_source_twice() {
380 +
        let mut p = Picker::new(1, Bits::new(1), 9);
381 +
        assert_eq!(p.pick(Source::Hub), Pick::Chunk(0));
382 +
        assert_eq!(p.pick(Source::Hub), Pick::Wait);
383 +
        p.peer(0, Bits::full(1));
384 +
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0), "a peer may race the hub for it");
385 +
        assert_eq!(p.pick(Source::Hub), Pick::Wait, "but not the hub a peer, while the peers make progress");
386 +
    }
387 +
388 +
    #[test]
389 +
    fn the_endgame_asks_a_second_source_and_a_failure_frees_the_chunk() {
390 +
        let mut p = Picker::new(1, Bits::new(1), 3);
391 +
        p.peer(0, Bits::full(1));
392 +
        p.peer(1, Bits::full(1));
393 +
        assert_eq!(p.pick(Source::Peer(0)), Pick::Chunk(0));
394 +
        assert_eq!(p.pick(Source::Peer(1)), Pick::Chunk(0), "the endgame");
395 +
        assert_eq!(p.pick(Source::Peer(1)), Pick::Wait, "at most two copies");
396 +
        p.failed(0);
397 +
        p.failed(0);
398 +
        p.drop_peer(0);
399 +
        assert_eq!(p.pick(Source::Peer(1)), Pick::Chunk(0));
400 +
        p.got(0);
401 +
        assert_eq!(p.pick(Source::Peer(1)), Pick::Done);
402 +
    }
403 +
404 +
    #[test]
405 +
    fn chunks_already_here_are_never_asked_for() {
406 +
        let mut have = Bits::new(3);
407 +
        have.set(1);
408 +
        let mut p = Picker::new(3, have, 5);
409 +
        assert_eq!(p.missing(), 2);
410 +
        let mut seen = Vec::new();
411 +
        while let Pick::Chunk(i) = p.pick(Source::Hub) {
412 +
            seen.push(i);
413 +
            p.got(i);
414 +
        }
415 +
        assert!(!seen.contains(&1));
416 +
    }
417 +
418 +
    #[test]
419 +
    fn runners_starting_together_ask_the_hub_for_different_chunks() {
420 +
        let a: Vec<_> = {
421 +
            let mut p = Picker::new(64, Bits::new(64), 11);
422 +
            (0..8).filter_map(|_| if let Pick::Chunk(i) = p.pick(Source::Hub) { Some(i) } else { None }).collect()
423 +
        };
424 +
        let b: Vec<_> = {
425 +
            let mut p = Picker::new(64, Bits::new(64), 12);
426 +
            (0..8).filter_map(|_| if let Pick::Chunk(i) = p.pick(Source::Hub) { Some(i) } else { None }).collect()
427 +
        };
428 +
        assert_ne!(a, b);
429 +
    }
430 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Runners share images (#47, docs/jetstream.md §8.1).** A runner with a `[runner.swarm]` section serves the image chunks it holds to other runners in its site, and fetches an image it lacks from them and the hub together, rarest chunks first. The hub is asked only for chunks no peer has or is fetching, so runners starting together split its work. Peers admit each other with a ticket the hub signs, and every chunk is checked against its hash. Uploads are capped (`upload_mib_s`, default 50) at 16 connections. Without the section a runner fetches from the hub alone, as before.
8 +
7 9
- **A runner declines a job it cannot run (#36, spec §11.5).** Before fetching anything it checks the tier, the architecture, that the image is there and verifies, and that the sandbox's memory is free. If any check fails it hands the job back to the hub with the reason instead of running and failing it.
8 10
9 11
- **The syscall tier (#36, docs/m0.md §3).** `runtime "syscall"` jobs run without KVM, so boards such as the OrangePi RV2 can take them. The sandbox uses user, mount, PID, network, IPC and UTS namespaces on the image's unpacked tree, read-only. Landlock limits writes to `/src` and `/tmp`, and a seccomp filter refuses network sockets, tracing, mounts, BPF, keys, modules and io_uring. A delegated cgroup v2 group enforces the CPU and memory limits, and a job killed for memory is told so. A runner offers the tier only where all of this works. `flow tiers` shows which tiers a machine can run and why the others cannot, and `flow run` runs syscall jobs too.
…
24 unchanged lines
modified crates/flow-core/src/api.rs +42 −0
…
47 unchanged lines
48 48
    /// docs/jetstream.md §6). Absent: an older runner.
49 49
    #[serde(default, skip_serializing_if = "Option::is_none")]
50 50
    pub facts: Option<Facts>,
51 +
    /// Where this runner serves image chunks to its peers (docs/jetstream.md
52 +
    /// §8.1). Absent: it is not in a swarm.
53 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
54 +
    pub swarm: Option<SwarmAddr>,
55 +
}
56 +
57 +
/// A runner's place in the image swarm: the address peers reach it on and
58 +
/// the site it is in; peers are only ever in the same site.
59 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60 +
pub struct SwarmAddr {
61 +
    pub addr: String,
62 +
    #[serde(default)]
63 +
    pub site: String,
64 +
}
65 +
66 +
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
67 +
/// fetch of `image`.
68 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69 +
pub struct Announce {
70 +
    pub image: String,
71 +
    /// `fetching`, `done` or `stopped`.
72 +
    pub state: String,
73 +
}
74 +
75 +
/// The hub's answer to an announce: the peers to ask, and the ticket that
76 +
/// admits this runner to them.
77 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
78 +
pub struct Announced {
79 +
    pub ticket: String,
80 +
    pub peers: Vec<SwarmPeer>,
81 +
    /// Announce again after this many seconds while still fetching.
82 +
    pub interval_s: u64,
83 +
}
84 +
85 +
/// A peer in the swarm for one image.
86 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
87 +
pub struct SwarmPeer {
88 +
    pub runner: i64,
89 +
    pub name: String,
90 +
    pub addr: String,
91 +
    /// It holds the whole image (rather than fetching it now).
92 +
    pub complete: bool,
51 93
}
52 94
53 95
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
…
230 unchanged lines
modified crates/flow/src/home.rs +20 −0
…
56 unchanged lines
57 57
    pub memory_mib: Option<u32>,
58 58
    #[serde(default)]
59 59
    pub cpus: Option<u32>,
60 +
    /// Sharing images with other runners (docs/jetstream.md §8.1): absent,
61 +
    /// the runner neither serves nor asks peers.
62 +
    #[serde(default)]
63 +
    pub swarm: Option<SwarmConfig>,
64 +
}
65 +
66 +
/// `[runner.swarm]` in flow.toml, or `[swarm]` in runner.toml.
67 +
#[derive(Clone, Debug, Deserialize)]
68 +
pub struct SwarmConfig {
69 +
    /// Where peers reach this runner, e.g. `0.0.0.0:7401`.
70 +
    pub listen: String,
71 +
    /// The address peers are given (default: `listen`).
72 +
    #[serde(default)]
73 +
    pub advertise: Option<String>,
74 +
    /// Peers are only ever in the same site.
75 +
    #[serde(default)]
76 +
    pub site: String,
77 +
    /// The most this runner sends to peers, MiB a second (default 50).
78 +
    #[serde(default)]
79 +
    pub upload_mib_s: Option<u32>,
60 80
}
61 81
62 82
#[derive(Clone, Debug, Default, Deserialize)]
…
168 unchanged lines
modified crates/flow/src/images.rs +59 −13
…
17 unchanged lines
18 18
19 19
/// A hub's image endpoints, with a token.
20 20
pub struct Client {
21 +
    root: String,
21 22
    base: String,
22 23
    token: String,
23 24
    agent: ureq::Agent,
…
3 unchanged lines
27 28
pub struct Moved {
28 29
    pub chunks: usize,
29 30
    pub bytes: u64,
31 +
    /// Of `bytes`, those that came from peers rather than the hub.
32 +
    pub peer_bytes: u64,
30 33
    pub elapsed: Duration,
31 34
}
32 35
…
1 unchanged lines
34 37
    pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> {
35 38
        let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
36 39
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build();
37 -
        Ok(Client { base: format!("{}/api/v1", hub.trim_end_matches('/')), token: token.into(), agent })
40 +
        let root = hub.trim_end_matches('/').to_string();
41 +
        Ok(Client { base: format!("{root}/api/v1"), root, token: token.into(), agent })
38 42
    }
39 43
40 44
    fn auth(&self) -> String {
…
24 unchanged lines
65 69
        }
66 70
    }
67 71
68 -
    fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
72 +
    /// The hub's identity (`/.well-known/oxyhub-key`): the key its swarm
73 +
    /// tickets are signed with.
74 +
    pub fn hub_identity(&self) -> Result<oxy_core::identity::Identity> {
75 +
        let url = format!("{}/.well-known/oxyhub-key", self.root);
76 +
        let r = self.agent.get(&url).call().map_err(|e| Self::fail(&url, e))?;
77 +
        let mut out = Vec::new();
78 +
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
79 +
        oxy_core::identity::Identity::decode(&out).map_err(|e| anyhow::anyhow!("{url}: not a hub identity: {e}"))
80 +
    }
81 +
82 +
    /// Tell the hub's tracker this runner is fetching image `hash`, or has
83 +
    /// stopped: the peers to ask and the ticket they admit.
84 +
    pub fn announce(&self, hash: &str, state: &str) -> Result<flow_core::api::Announced> {
85 +
        let url = format!("{}/flow/swarm/announce", self.base);
86 +
        let body = flow_core::api::Announce { image: hash.into(), state: state.into() };
87 +
        Ok(self.agent.post(&url).set("Authorization", &self.auth()).send_json(&body).map_err(|e| Self::fail(&url, e))?.into_json()?)
88 +
    }
89 +
90 +
    pub(crate) fn chunk(&self, hash: &str) -> Result<Vec<u8>> {
69 91
        let url = format!("{}/flow/images/chunks/{hash}", self.base);
70 92
        let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?;
71 93
        let mut out = Vec::new();
…
57 unchanged lines
129 151
        let url = format!("{}/flow/images/{hash}", self.base);
130 152
        self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?;
131 153
        let bytes = *bytes.lock().unwrap();
132 -
        Ok(Moved { chunks: count, bytes, elapsed: start.elapsed() })
154 +
        Ok(Moved { chunks: count, bytes, peer_bytes: 0, elapsed: start.elapsed() })
133 155
    }
134 156
135 157
    /// Fetch image `hash` into `home`: the chunks its cache lacks, the
…
6 unchanged lines
142 164
            bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]);
143 165
        }
144 166
        let cache = chunk_cache(home);
167 +
        save_manifest(&home.root, &m)?;
145 168
        let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect();
146 169
        let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>));
147 170
        let next = Mutex::new(wanted.iter());
…
26 unchanged lines
174 197
        if let Some(e) = failed.into_inner().unwrap() {
175 198
            return Err(e);
176 199
        }
177 -
        let out = home.rootfs(hash);
178 -
        std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
179 -
        images::assemble(&m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
180 -
            .with_context(|| format!("write image {}", &hash[..12]))?;
181 -
        if let Some(arch) = &m.arch {
182 -
            let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" });
183 -
            std::fs::write(home.image_manifest(hash), serde_json::to_vec(&manifest)?)?;
184 -
        }
185 -
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), elapsed: start.elapsed() }))
200 +
        write_image(home, &m)?;
201 +
        Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), peer_bytes: 0, elapsed: start.elapsed() }))
202 +
    }
203 +
}
204 +
205 +
/// Write image `m` into `home` from the chunk cache, checked against its
206 +
/// hash, and record its architecture.
207 +
pub(crate) fn write_image(home: &Home, m: &Manifest) -> Result<()> {
208 +
    let cache = chunk_cache(home);
209 +
    let out = home.rootfs(&m.hash);
210 +
    std::fs::create_dir_all(out.parent().expect("the cas directory"))?;
211 +
    images::assemble(m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?))
212 +
        .with_context(|| format!("write image {}", &m.hash[..12]))?;
213 +
    if let Some(arch) = &m.arch {
214 +
        let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" });
215 +
        std::fs::write(home.image_manifest(&m.hash), serde_json::to_vec(&manifest)?)?;
186 216
    }
217 +
    Ok(())
218 +
}
219 +
220 +
/// Where an image's chunk manifest is kept (`manifests/<hash>.json`), so
221 +
/// the runner can serve its chunks to peers out of the image itself.
222 +
pub(crate) fn manifest_path(root: &Path, hash: &str) -> PathBuf {
223 +
    root.join("manifests").join(format!("{hash}.json"))
224 +
}
225 +
226 +
pub(crate) fn save_manifest(root: &Path, m: &Manifest) -> Result<()> {
227 +
    let p = manifest_path(root, &m.hash);
228 +
    std::fs::create_dir_all(p.parent().expect("the manifests directory"))?;
229 +
    let tmp = p.with_extension("part");
230 +
    std::fs::write(&tmp, serde_json::to_vec(m)?)?;
231 +
    std::fs::rename(&tmp, &p)?;
232 +
    Ok(())
187 233
}
188 234
189 235
fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) {
…
16 unchanged lines
206 252
    home.root.join("chunks")
207 253
}
208 254
209 -
fn cache_path(cache: &Path, hash: &str) -> PathBuf {
255 +
pub(crate) fn cache_path(cache: &Path, hash: &str) -> PathBuf {
210 256
    cache.join(&hash[..2]).join(hash)
211 257
}
212 258
…
8 unchanged lines
modified crates/flow/src/main.rs +1 −0
…
11 unchanged lines
12 12
mod microvm;
13 13
mod outputs;
14 14
mod runner;
15 +
mod swarm;
15 16
mod syscall;
16 17
mod wait;
17 18
mod workspace;
…
548 unchanged lines
modified crates/flow/src/runner.rs +23 −6
…
314 unchanged lines
315 315
/// Fetch `claim`'s image from the hub when this home lacks it
316 316
/// (oxygenflow#39), saying so in the job's log. A hub that does not hold it
317 317
/// leaves it missing, and the preflight declines the job as before.
318 -
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) {
318 +
fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, swarm: Option<&crate::swarm::Swarm>, claim: &Claim) {
319 319
    let hash = &claim.sandbox.rootfs;
320 320
    if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) {
321 321
        return;
…
12 unchanged lines
334 334
        Ok(c) => c,
335 335
        Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])),
336 336
    };
337 -
    note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
338 -
    match client.fetch(home, hash) {
337 +
    let fetched = match swarm {
338 +
        Some(sw) => {
339 +
            note(format!("image {} is not on this runner: fetching it from its peers and the hub", &hash[..12]));
340 +
            sw.fetch(home, hash)
341 +
        }
342 +
        None => {
343 +
            note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12]));
344 +
            client.fetch(home, hash)
345 +
        }
346 +
    };
347 +
    match fetched {
339 348
        Ok(Some(m)) => note(format!(
340 -
            "image {}: fetched {} chunks, {} in {:.1} s",
349 +
            "image {}: fetched {} chunks, {}{} in {:.1} s",
341 350
            &hash[..12],
342 351
            m.chunks,
343 352
            crate::images::human(m.bytes),
353 +
            if swarm.is_some() { format!(" ({} from peers)", crate::images::human(m.peer_bytes)) } else { String::new() },
344 354
            m.elapsed.as_secs_f64()
345 355
        )),
346 356
        Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])),
…
68 unchanged lines
415 425
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
416 426
    let budget = Budget::of(&cfg);
417 427
    let key = home.runner_key(&name)?;
428 +
    // The swarm (docs/jetstream.md §8.1): a runner that cannot serve its
429 +
    // peers still runs jobs, fetching images from the hub alone.
430 +
    let swarm = crate::swarm::Swarm::start(home, &cfg).unwrap_or_else(|e| {
431 +
        eprintln!("flow runner: not joining the swarm: {e:#}");
432 +
        None
433 +
    });
418 434
    eprintln!(
419 435
        "flow runner {name}: polling {} (up to {} jobs at once, tiers {}), signing receipts as {}",
420 436
        cfg.hub,
…
52 unchanged lines
473 489
                holding: Some(held.lock().unwrap().iter().copied().collect()),
474 490
                fetches_images: true,
475 491
                facts: Some(crate::facts::now(home, Some(speed))),
492 +
                swarm: swarm.as_ref().map(|s| s.addr.clone()),
476 493
            };
477 494
            match hub.claim(&req) {
478 495
                Ok(Some(claim)) => {
…
6 unchanged lines
485 502
                        u.cpus += cpus;
486 503
                    }
487 504
                    held.lock().unwrap().insert(claim.job_id);
488 -
                    let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held);
505 +
                    let (home, hub, cfg, used, key, held, swarm) = (home, &hub, &cfg, &used, &key, &held, swarm.as_ref());
489 506
                    scope.spawn(move || {
490 507
                        let id = claim.job_id;
491 -
                        fetch_image(home, hub, cfg, &claim);
508 +
                        fetch_image(home, hub, cfg, swarm, &claim);
492 509
                        if let Some(why) = preflight(home, &claim) {
493 510
                            eprintln!("job {id}: declined: {why}");
494 511
                            if let Err(e) = hub.decline(id, &why) {
…
586 unchanged lines
modified crates/jet/src/lib.rs +3 −1
…
1 unchanged lines
2 2
//! its own. Flow says what to run; Jet carries it to where it should run.
3 3
//!
4 4
//! It starts with images by hash (oxygenflow#39): a rootfs image is kept by
5 -
//! a hub as content-defined chunks, sent and fetched by what is missing.
5 +
//! a hub as content-defined chunks, sent and fetched by what is missing,
6 +
//! and shared between runners in a swarm (oxygenflow#47).
6 7
7 8
pub mod images;
9 +
pub mod swarm;
modified docs/jetstream.md +56 −0
…
238 unchanged lines
239 239
- **Architecture recorded at build**, so an x86 image never reaches a
240 240
  RISC-V runner.
241 241
242 +
### 8.1 The swarm: runners share images
243 +
244 +
Runners that are configured for it fetch image chunks from each other as
245 +
well as from the hub (oxygenflow #47, oxygenhub #144). Fifty fresh
246 +
runners asking for the same 2 GiB image then cost the hub about one copy,
247 +
not fifty, and a site behind a thin link pulls each chunk across it once.
248 +
249 +
- **Trust comes from the hashes.** The manifest comes from the hub, and
250 +
  every chunk is checked against its name before it is kept. A peer can
251 +
  fail to help; it can never change what a job runs.
252 +
- **The hub is the tracker.** A runner reports its swarm address and site
253 +
  with every claim. `POST /api/v1/flow/swarm/announce {image, state}`
254 +
  (`fetching`, `done` or `stopped`) returns the peers in the same site that
255 +
  hold the image or are fetching it, and a **ticket**.
256 +
- **Tickets admit peers.** A ticket names the runner, its site and an
257 +
  expiry (15 minutes), signed with the hub key that `/.well-known/oxyhub-key`
258 +
  publishes. A runner shows its ticket once to each peer
259 +
  (`POST /swarm/v1/hello`) and gets a session for the ticket's life; no
260 +
  other runner and no account can take chunks from a peer.
261 +
- **The peer protocol** is plain HTTP on the runner's swarm address:
262 +
  - `POST /swarm/v1/hello` with the ticket: a session token.
263 +
  - `GET /swarm/v1/images/{image}/have`: `complete`, or two bit maps over
264 +
    the manifest's needed list (`Manifest::needed`), in its order: the
265 +
    chunks it has, then the chunks it is fetching now.
266 +
  - `GET /swarm/v1/chunks/{chunk}`: the chunk's bytes, from the chunk
267 +
    cache or read out of a whole image; `503` when the peer is at its
268 +
    connection limit.
269 +
- **Rarest first.** The fetcher refreshes every peer's maps twice a
270 +
  second and asks each peer, four requests at a time, for the chunk the
271 +
  fewest peers hold. The hub is a seed that holds everything: it is asked
272 +
  only for chunks no peer has or is fetching, or any chunk once the peers
273 +
  have made no progress for two seconds. Runners that start together so
274 +
  split the hub's chunks between them rather than each taking them all. When every missing chunk is already asked
275 +
  for, idle workers ask a second source for it (the endgame; the hub joins
276 +
  it only once the peers stall). A peer that
277 +
  fails is dropped and its chunks go back to the pool.
278 +
- **Serving while fetching.** A chunk is offered to others as soon as it
279 +
  is checked and kept, so runners starting together share the load.
280 +
- **Bounded.** A runner serves at most 16 connections and `upload_mib_s`
281 +
  (default 50 MiB/s), so serving never starves its own jobs.
282 +
- **Configured, never assumed.** Without a `[runner.swarm]` section a
283 +
  runner neither listens nor asks peers. Traffic is plain HTTP: chunks are
284 +
  public within the fleet and checked by hash, so keep the swarm address
285 +
  on a private network.
286 +
287 +
In flow.toml (or as `[swarm]` in runner.toml, which stands in for the
288 +
`[runner]` section):
289 +
290 +
```toml
291 +
[runner.swarm]
292 +
listen = "0.0.0.0:7401"        # where peers reach this runner
293 +
advertise = "10.0.0.5:7401"    # the address peers are given (default: listen)
294 +
site = "home"                  # peers are only ever in the same site
295 +
upload_mib_s = 50
296 +
```
297 +
242 298
## 9. Pools
243 299
244 300
A **pool** is one place runners come from. All pools report the same
…
165 unchanged lines