OXYGENHUB

jet: the Jetstream crate, starting with images by hash 59078d2b8d46

An image is cut with Oxygen's FastCDC as it streams (the same cuts as
over the whole), each chunk named by its BLAKE3, and described by a
manifest of its hash, size, architecture and chunks. assemble writes it
back from fetched chunks, leaving zeros as holes without fetching them,
checking each chunk and then the whole hash before it is renamed into
place. On Metal Gear's 1761 MiB image: 16105 chunks, 744 MiB of zeros
never sent, 991 MiB of distinct chunks, a 1.2 MB manifest, 5 s to scan.

refs #39
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 17:18 UTC
Signature
✔ signed by Andy McCall
Hash
59078d2b8d46ac771ab994acfee19435d59761da77c9738138e8f996d3299370
Depends on
e0918b887850 f849c7889471
Depended on by
e3fb0e9b5e6b 412808f9f769 48ed8a1db369 5544c8998aa7 9296a15494d3 cce63af9ec54 1408324d5d55 6a2d31568cf2 077a65242359 da72c3b192c3 0077c7488e5d ecce3719da80 b14aeaae683a
In bookmarks
main newer-pipeline push-uses-login
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
8 atoms, 7 tree operations, 6 files

Browse the tree at this change

added crates/jet/Cargo.toml +17 −0
1 +
[package]
2 +
name = "jet"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "Jetstream: OxygenFlow's runner fleet (docs/jetstream.md); images by hash first"
7 +
8 +
[dependencies]
9 +
oxy-core.workspace = true
10 +
serde.workspace = true
11 +
serde_json.workspace = true
12 +
blake3.workspace = true
13 +
hex.workspace = true
14 +
thiserror.workspace = true
15 +
16 +
[dev-dependencies]
17 +
tempfile.workspace = true
added crates/jet/examples/scan.rs +20 −0
1 +
fn main() {
2 +
    let p = std::env::args().nth(1).unwrap();
3 +
    let t = std::time::Instant::now();
4 +
    let m = jet::images::scan_file(std::path::Path::new(&p), None, |_, _| Ok(())).unwrap();
5 +
    let zeros: u64 = m.chunks.iter().filter(|c| c.is_zeros()).map(|c| c.1 as u64).sum();
6 +
    let need = m.needed();
7 +
    let need_bytes: u64 = need.iter().map(|h| m.chunks.iter().find(|c| c.hash() == *h).unwrap().1 as u64).sum();
8 +
    let json = serde_json::to_vec(&m).unwrap();
9 +
    println!(
10 +
        "hash {}… size {} MiB, {} chunks, zeros {} MiB, distinct non-zero {} ({} MiB), manifest {} KiB, {:.1} s",
11 +
        &m.hash[..12],
12 +
        m.size >> 20,
13 +
        m.chunks.len(),
14 +
        zeros >> 20,
15 +
        need.len(),
16 +
        need_bytes >> 20,
17 +
        json.len() >> 10,
18 +
        t.elapsed().as_secs_f64()
19 +
    );
20 +
}
added crates/jet/src/images.rs +282 −0
1 +
//! Images by hash (oxygenflow#39, docs/jetstream.md §8).
2 +
//!
3 +
//! A rootfs image is named by the BLAKE3 of its bytes, as pipelines pin it
4 +
//! (`cas://blob/HASH`). To move it, it is cut into chunks with Oxygen's
5 +
//! FastCDC (32 KiB to 256 KiB, `oxy_core::chunk`), each named by its own
6 +
//! BLAKE3, and described by a [`Manifest`]: the image's hash, size and
7 +
//! architecture, and its chunks in order. A sender sends only the chunks a
8 +
//! receiver lacks; a receiver writes the image back, leaving runs of zeros
9 +
//! as holes without fetching them, and checks the whole hash before using
10 +
//! it. Chunk boundaries depend only on content, so an image rebuilt with a
11 +
//! small change shares nearly all its chunks with the one before.
12 +
13 +
use oxy_core::chunk::{cut_point, MAX_SIZE};
14 +
use serde::{Deserialize, Serialize};
15 +
use std::collections::HashSet;
16 +
use std::io::{Read, Seek, SeekFrom, Write};
17 +
use std::path::Path;
18 +
19 +
/// The manifest format this crate writes and reads.
20 +
pub const VERSION: u32 = 1;
21 +
/// Largest image accepted: 64 GiB.
22 +
pub const MAX_IMAGE: u64 = 64 << 30;
23 +
24 +
#[derive(Debug, thiserror::Error)]
25 +
pub enum Error {
26 +
    #[error("{0}")]
27 +
    Io(#[from] std::io::Error),
28 +
    #[error("{0}")]
29 +
    Bad(String),
30 +
}
31 +
32 +
pub type Result<T> = std::result::Result<T, Error>;
33 +
34 +
/// One chunk: its BLAKE3 (hex) and length.
35 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36 +
pub struct Chunk(pub String, pub u32);
37 +
38 +
impl Chunk {
39 +
    pub fn hash(&self) -> &str {
40 +
        &self.0
41 +
    }
42 +
43 +
    pub fn len(&self) -> usize {
44 +
        self.1 as usize
45 +
    }
46 +
47 +
    pub fn is_empty(&self) -> bool {
48 +
        self.1 == 0
49 +
    }
50 +
51 +
    /// Whether this is a chunk of zeros, which a receiver writes as a hole
52 +
    /// without fetching it.
53 +
    pub fn is_zeros(&self) -> bool {
54 +
        self.0 == zeros_hash(self.len())
55 +
    }
56 +
}
57 +
58 +
/// An image: its hash, size, architecture and chunks.
59 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60 +
pub struct Manifest {
61 +
    pub version: u32,
62 +
    /// BLAKE3 of the whole image, hex: its name.
63 +
    pub hash: String,
64 +
    pub size: u64,
65 +
    /// `x86_64`, `riscv64`, …, as its import recorded (OxygenFlow spec §11.2).
66 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
67 +
    pub arch: Option<String>,
68 +
    pub chunks: Vec<Chunk>,
69 +
}
70 +
71 +
impl Manifest {
72 +
    /// The distinct chunks a receiver must fetch: all but zeros.
73 +
    pub fn needed(&self) -> Vec<&str> {
74 +
        let mut seen = HashSet::new();
75 +
        self.chunks.iter().filter(|c| !c.is_zeros() && seen.insert(c.hash())).map(|c| c.hash()).collect()
76 +
    }
77 +
78 +
    /// Check its shape: a known version, hex names, chunks in the
79 +
    /// chunker's bounds that add up to the size.
80 +
    pub fn check(&self) -> Result<()> {
81 +
        if self.version != VERSION {
82 +
            return Err(Error::Bad(format!("image manifest version {} is not known; this reads {VERSION}", self.version)));
83 +
        }
84 +
        if !is_hash(&self.hash) {
85 +
            return Err(Error::Bad(format!("{:?} is not an image hash", self.hash)));
86 +
        }
87 +
        if self.size > MAX_IMAGE {
88 +
            return Err(Error::Bad(format!("the image is {} GiB; at most {} GiB", self.size >> 30, MAX_IMAGE >> 30)));
89 +
        }
90 +
        let mut total = 0u64;
91 +
        for c in &self.chunks {
92 +
            if !is_hash(c.hash()) || c.is_empty() || c.len() > MAX_SIZE {
93 +
                return Err(Error::Bad(format!("chunk {:?} of {} bytes is not a chunk", c.hash(), c.len())));
94 +
            }
95 +
            total += c.1 as u64;
96 +
        }
97 +
        if total != self.size {
98 +
            return Err(Error::Bad(format!("the chunks hold {total} bytes; the image is {}", self.size)));
99 +
        }
100 +
        Ok(())
101 +
    }
102 +
}
103 +
104 +
/// Whether `s` is 64 lower-case hex digits.
105 +
pub fn is_hash(s: &str) -> bool {
106 +
    s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
107 +
}
108 +
109 +
/// The BLAKE3 of `len` zero bytes, hex; remembered per length.
110 +
pub fn zeros_hash(len: usize) -> String {
111 +
    use std::sync::Mutex;
112 +
    static MEMO: Mutex<Vec<(usize, String)>> = Mutex::new(Vec::new());
113 +
    let mut memo = MEMO.lock().unwrap_or_else(|p| p.into_inner());
114 +
    if let Some((_, h)) = memo.iter().find(|(l, _)| *l == len) {
115 +
        return h.clone();
116 +
    }
117 +
    let h = blake3::hash(&vec![0u8; len]).to_hex().to_string();
118 +
    if memo.len() < 64 {
119 +
        memo.push((len, h.clone()));
120 +
    }
121 +
    h
122 +
}
123 +
124 +
/// Cut `r` into chunks as Oxygen's chunker would cut it whole, calling
125 +
/// `each` with every chunk's bytes, and answer the manifest. Reads in
126 +
/// bounded memory: the chunker never looks past [`MAX_SIZE`].
127 +
pub fn scan<R: Read>(mut r: R, arch: Option<String>, mut each: impl FnMut(&Chunk, &[u8]) -> Result<()>) -> Result<Manifest> {
128 +
    const FILL: usize = 8 << 20;
129 +
    let mut buf: Vec<u8> = Vec::with_capacity(FILL + MAX_SIZE);
130 +
    let (mut pos, mut eof, mut size) = (0usize, false, 0u64);
131 +
    let mut whole = blake3::Hasher::new();
132 +
    let mut chunks = Vec::new();
133 +
    loop {
134 +
        if !eof && buf.len() - pos < MAX_SIZE {
135 +
            buf.drain(..pos);
136 +
            pos = 0;
137 +
            let start = buf.len();
138 +
            buf.resize(start + FILL, 0);
139 +
            let mut got = 0;
140 +
            while got < FILL {
141 +
                match r.read(&mut buf[start + got..])? {
142 +
                    0 => {
143 +
                        eof = true;
144 +
                        break;
145 +
                    }
146 +
                    n => got += n,
147 +
                }
148 +
            }
149 +
            buf.truncate(start + got);
150 +
        }
151 +
        if pos == buf.len() {
152 +
            break;
153 +
        }
154 +
        let n = cut_point(&buf[pos..]);
155 +
        let bytes = &buf[pos..pos + n];
156 +
        whole.update(bytes);
157 +
        let c = Chunk(blake3::hash(bytes).to_hex().to_string(), n as u32);
158 +
        each(&c, bytes)?;
159 +
        chunks.push(c);
160 +
        size += n as u64;
161 +
        pos += n;
162 +
    }
163 +
    Ok(Manifest { version: VERSION, hash: whole.finalize().to_hex().to_string(), size, arch, chunks })
164 +
}
165 +
166 +
/// [`scan`] of a file.
167 +
pub fn scan_file(path: &Path, arch: Option<String>, each: impl FnMut(&Chunk, &[u8]) -> Result<()>) -> Result<Manifest> {
168 +
    scan(std::io::BufReader::with_capacity(1 << 20, std::fs::File::open(path)?), arch, each)
169 +
}
170 +
171 +
/// Write image `m` to `out`, getting each chunk's bytes from `fetch`
172 +
/// (zeros are holes, never fetched; a chunk seen before is fetched once
173 +
/// per run of repeats), checking every chunk and then the whole hash. The
174 +
/// image is written beside `out` and renamed into place only when whole.
175 +
pub fn assemble(m: &Manifest, out: &Path, mut fetch: impl FnMut(&Chunk) -> Result<Vec<u8>>) -> Result<()> {
176 +
    m.check()?;
177 +
    let tmp = out.with_extension("part");
178 +
    let mut f = std::fs::File::create(&tmp)?;
179 +
    let mut whole = blake3::Hasher::new();
180 +
    let mut last: Option<(String, Vec<u8>)> = None;
181 +
    for c in &m.chunks {
182 +
        if c.is_zeros() {
183 +
            whole.update(&vec![0u8; c.len()]);
184 +
            f.seek(SeekFrom::Current(c.len() as i64))?;
185 +
            continue;
186 +
        }
187 +
        let bytes = match &last {
188 +
            Some((h, b)) if h == c.hash() => b.clone(),
189 +
            _ => {
190 +
                let b = fetch(c)?;
191 +
                if b.len() != c.len() || blake3::hash(&b).to_hex().as_str() != c.hash() {
192 +
                    let _ = std::fs::remove_file(&tmp);
193 +
                    return Err(Error::Bad(format!("chunk {} came back with other bytes", &c.hash()[..12])));
194 +
                }
195 +
                last = Some((c.0.clone(), b.clone()));
196 +
                b
197 +
            }
198 +
        };
199 +
        whole.update(&bytes);
200 +
        f.write_all(&bytes)?;
201 +
    }
202 +
    f.set_len(m.size)?;
203 +
    f.sync_all()?;
204 +
    drop(f);
205 +
    let got = whole.finalize().to_hex().to_string();
206 +
    if got != m.hash {
207 +
        let _ = std::fs::remove_file(&tmp);
208 +
        return Err(Error::Bad(format!("the image came out as {}, not {}", &got[..12], &m.hash[..12])));
209 +
    }
210 +
    std::fs::rename(&tmp, out)?;
211 +
    Ok(())
212 +
}
213 +
214 +
#[cfg(test)]
215 +
mod tests {
216 +
    use super::*;
217 +
218 +
    fn image(seed: u8) -> Vec<u8> {
219 +
        // Some text-like bytes, a long run of zeros (as an ext4 image has),
220 +
        // then more bytes.
221 +
        let mut v: Vec<u8> = (0..300_000u32).map(|i| ((i * 7 + seed as u32) % 251) as u8).collect();
222 +
        v.extend(std::iter::repeat_n(0u8, 3 << 20));
223 +
        v.extend((0..200_000u32).map(|i| ((i * 13 + seed as u32) % 241) as u8));
224 +
        v
225 +
    }
226 +
227 +
    #[test]
228 +
    fn a_scanned_image_comes_back_whole_and_zeros_are_never_fetched() {
229 +
        let data = image(1);
230 +
        let mut stored = std::collections::HashMap::new();
231 +
        let m = scan(&data[..], Some("x86_64".into()), |c, b| {
232 +
            stored.insert(c.hash().to_string(), b.to_vec());
233 +
            Ok(())
234 +
        })
235 +
        .unwrap();
236 +
        m.check().unwrap();
237 +
        assert_eq!(m.hash, blake3::hash(&data).to_hex().to_string(), "named as pipelines pin it");
238 +
        assert_eq!(m.size, data.len() as u64);
239 +
        assert_eq!(
240 +
            m.chunks.iter().map(|c| c.len()).collect::<Vec<_>>(),
241 +
            oxy_core::chunk::chunks(&data).iter().map(|c| c.len()).collect::<Vec<_>>(),
242 +
            "the same cuts as Oxygen's chunker over the whole"
243 +
        );
244 +
        assert!(m.chunks.iter().any(|c| c.is_zeros()));
245 +
        let dir = tempfile::tempdir().unwrap();
246 +
        let out = dir.path().join("img.ext4");
247 +
        let mut fetched = 0;
248 +
        assemble(&m, &out, |c| {
249 +
            fetched += 1;
250 +
            assert!(!c.is_zeros());
251 +
            Ok(stored[c.hash()].clone())
252 +
        })
253 +
        .unwrap();
254 +
        assert_eq!(std::fs::read(&out).unwrap(), data);
255 +
        assert!(fetched <= m.needed().len() + 1);
256 +
257 +
        // A rebuild with a small change shares nearly every chunk.
258 +
        let mut changed = data.clone();
259 +
        changed[100] ^= 0xff;
260 +
        let m2 = scan(&changed[..], None, |_, _| Ok(())).unwrap();
261 +
        let have: HashSet<&str> = m.chunks.iter().map(|c| c.hash()).collect();
262 +
        let new = m2.needed().into_iter().filter(|h| !have.contains(h)).count();
263 +
        assert!(new <= 2, "{new} new chunks for a one-byte change");
264 +
    }
265 +
266 +
    #[test]
267 +
    fn bad_bytes_and_bad_manifests_are_refused() {
268 +
        let data = image(2);
269 +
        let m = scan(&data[..], None, |_, _| Ok(())).unwrap();
270 +
        let dir = tempfile::tempdir().unwrap();
271 +
        let out = dir.path().join("img.ext4");
272 +
        let e = assemble(&m, &out, |c| Ok(vec![1u8; c.len()])).unwrap_err();
273 +
        assert!(e.to_string().contains("other bytes"), "{e}");
274 +
        assert!(!out.exists() && !out.with_extension("part").exists());
275 +
        let mut short = m.clone();
276 +
        short.size += 1;
277 +
        assert!(short.check().unwrap_err().to_string().contains("chunks hold"));
278 +
        let mut odd = m.clone();
279 +
        odd.hash = "nope".into();
280 +
        assert!(odd.check().is_err());
281 +
    }
282 +
}
added crates/jet/src/lib.rs +7 −0
1 +
//! Jetstream (docs/jetstream.md): OxygenFlow's runner fleet as a service of
2 +
//! its own. Flow says what to run; Jet carries it to where it should run.
3 +
//!
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.
6 +
7 +
pub mod images;
modified Cargo.lock +13 −0
…
702 unchanged lines
703 703
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
704 704
705 705
[[package]]
706 +
name = "jet"
707 +
version = "0.1.0"
708 +
dependencies = [
709 +
 "blake3",
710 +
 "hex",
711 +
 "oxy-core",
712 +
 "serde",
713 +
 "serde_json",
714 +
 "tempfile",
715 +
 "thiserror",
716 +
]
717 +
718 +
[[package]]
706 719
name = "jobserver"
707 720
version = "0.1.35"
708 721
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1048 unchanged lines
modified Cargo.toml +2 −1
1 1
[workspace]
2 -
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs"]
2 +
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs", "crates/jet"]
3 3
# oxygen as a pinned subrepo (oxy submodule): the state flow builds against.
4 4
exclude = ["deps"]
5 5
resolver = "2"
…
6 unchanged lines
12 12
[workspace.dependencies]
13 13
oxy-core = { path = "deps/oxygen/crates/oxy-core" }
14 14
flow-core = { path = "crates/flow-core" }
15 +
jet = { path = "crates/jet" }
15 16
serde = { version = "1", features = ["derive"] }
16 17
serde_json = "1"
17 18
blake3 = "1"
…
7 unchanged lines