OXYGENHUB

A job's finish carries its peak disk and memory 8f211e20bea5

The runner reads the blocks the job's scratch directory took at the end
(its disks are sparse files there, so that is their peak) and samples
the sandbox's peak resident memory while it runs (the VMM's, or the
syscall tier's helper's), and sends both with the finish, for the hub to
learn what the job's next run needs.

refs #41
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 22:04 UTC
Signature
✔ signed by Andy McCall
Hash
8f211e20bea5e995d04f82b9aab8961aea9b729aafe87a2894cadba90cd0c722
Depends on
280ef951d193 3007d8f011fa 62829d15ef09 a67b97e9a717 e0918b887850 ea56c56eaf90
Depended on by
1215f05ff8d0 3554afefb6aa 337afb8f6043 077a65242359 ea81796bb757 d60aac6c3f8a
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
15 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/flow-core/src/api.rs +7 −0
…
274 unchanged lines
275 275
    pub signed_receipt: Option<String>,
276 276
    /// Why the runner could not run the job (rootfs missing, VM failed).
277 277
    pub error: Option<String>,
278 +
    /// The most disk the job's disks and workspace took, in MiB, and the
279 +
    /// most memory its sandbox held (oxygenflow#41): the hub learns from
280 +
    /// them what the job's next run needs.
281 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
282 +
    pub peak_disk_mib: Option<u64>,
283 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
284 +
    pub peak_memory_mib: Option<u64>,
278 285
}
279 286
280 287
/// Longest text of one record the hub keeps; longer lines are cut.
…
3 unchanged lines
modified crates/flow/src/facts.rs +33 −0
…
32 unchanged lines
33 33
    Some((s.f_bavail as u64).saturating_mul(s.f_frsize as u64) >> 20)
34 34
}
35 35
36 +
/// The disk the files under `dir` take (their blocks, not their sizes: a
37 +
/// job's disks are sparse), in MiB; links are not followed.
38 +
pub fn allocated_mib(dir: &std::path::Path) -> Option<u64> {
39 +
    use std::os::unix::fs::MetadataExt;
40 +
    let mut total: u64 = 0;
41 +
    let mut stack = vec![dir.to_path_buf()];
42 +
    while let Some(d) = stack.pop() {
43 +
        for e in std::fs::read_dir(&d).ok()?.flatten() {
44 +
            let m = std::fs::symlink_metadata(e.path()).ok()?;
45 +
            if m.is_dir() {
46 +
                stack.push(e.path());
47 +
            } else {
48 +
                total = total.saturating_add(m.blocks() * 512);
49 +
            }
50 +
        }
51 +
    }
52 +
    Some(total >> 20)
53 +
}
54 +
55 +
/// A process's peak resident memory (`VmHWM`), in MiB.
56 +
pub fn peak_rss_mib(pid: u32) -> Option<u64> {
57 +
    let text = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
58 +
    let kib: u64 = text.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
59 +
    Some(kib / 1024)
60 +
}
61 +
36 62
/// The 1-minute load average from /proc/loadavg, times 100.
37 63
pub fn load_x100() -> Option<u32> {
38 64
    let text = std::fs::read_to_string("/proc/loadavg").ok()?;
…
36 unchanged lines
75 101
    fn the_cheap_facts_are_read_and_classes_double() {
76 102
        assert!(free_disk_mib(std::path::Path::new("/")).is_some());
77 103
        assert!(load_x100().is_some());
104 +
        assert!(peak_rss_mib(std::process::id()).is_some_and(|m| m > 0));
105 +
        let dir = tempfile::tempdir().unwrap();
106 +
        let f = std::fs::File::create(dir.path().join("sparse")).unwrap();
107 +
        f.set_len(1 << 30).unwrap();
108 +
        std::fs::write(dir.path().join("full"), vec![7u8; 3 << 20]).unwrap();
109 +
        let mib = allocated_mib(dir.path()).unwrap();
110 +
        assert!((3..=4).contains(&mib), "the sparse gigabyte takes nothing, the written 3 MiB do: {mib}");
78 111
        assert_eq!(Facts::class_of(100), 3, "the reference machine");
79 112
        assert_eq!((Facts::class_of(50), Facts::class_of(200), Facts::class_of(500), Facts::class_of(10)), (2, 4, 5, 1));
80 113
    }
…
1 unchanged lines
modified crates/flow/src/microvm.rs +9 −0
…
19 unchanged lines
20 20
    pub timed_out: bool,
21 21
    /// Stopped because the run was cancelled (oxygenflow#32).
22 22
    pub cancelled: bool,
23 +
    /// The most memory the sandbox held, in MiB (oxygenflow#41).
24 +
    pub peak_memory_mib: Option<u64>,
23 25
}
24 26
25 27
/// Where a job's records go, and whether it is still wanted: asked a few
…
122 unchanged lines
148 150
    let mut cancelled = false;
149 151
    let deadline = started + timeout;
150 152
    let t = |s: &Instant| s.elapsed().as_millis() as u64;
153 +
    // The VMM's peak resident memory is the guest memory it touched: read
154 +
    // while it runs, since it is gone once it exits.
155 +
    let mut peak_memory_mib = None;
151 156
    loop {
157 +
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
158 +
            peak_memory_mib = Some(m);
159 +
        }
152 160
        match rx.recv_timeout(Duration::from_millis(200)) {
153 161
            Ok((from_console, line)) => {
154 162
                let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
…
49 unchanged lines
204 212
        environment,
205 213
        timed_out,
206 214
        cancelled,
215 +
        peak_memory_mib,
207 216
    })
208 217
}
209 218
…
62 unchanged lines
modified crates/flow/src/runner.rs +24 −2
…
38 unchanged lines
39 39
const CANCELLED: &str = "the run was cancelled";
40 40
41 41
fn cancelled() -> Finish {
42 -
    Finish { exit_code: None, duration_ms: 0, receipt: None, signed_receipt: None, error: Some(CANCELLED.into()) }
42 +
    Finish {
43 +
        exit_code: None,
44 +
        duration_ms: 0,
45 +
        receipt: None,
46 +
        signed_receipt: None,
47 +
        error: Some(CANCELLED.into()),
48 +
        peak_disk_mib: None,
49 +
        peak_memory_mib: None,
50 +
    }
43 51
}
44 52
45 53
pub struct Hub {
…
467 unchanged lines
513 521
                            receipt: None,
514 522
                            signed_receipt: None,
515 523
                            error: Some(format!("{e:#}")),
524 +
                            peak_disk_mib: None,
525 +
                            peak_memory_mib: None,
516 526
                        });
517 527
                        eprintln!(
518 528
                            "job {id}: exit {:?}{}",
…
219 unchanged lines
738 748
            receipt,
739 749
            signed_receipt,
740 750
            error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())),
751 +
            // What the job took (oxygenflow#41): its disks are sparse files
752 +
            // in the scratch directory, so their blocks now are their peak.
753 +
            peak_disk_mib: crate::facts::allocated_mib(&scratch),
754 +
            peak_memory_mib: outcome.peak_memory_mib,
741 755
        })
742 756
    })();
743 757
    let _ = workspace::remove_tree(&scratch);
…
267 unchanged lines
1011 1025
    /// through server errors, and a refusal ends the trying at once.
1012 1026
    #[test]
1013 1027
    fn a_result_is_reported_until_the_hub_takes_it() {
1014 -
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
1028 +
        let f = Finish {
1029 +
            exit_code: Some(0),
1030 +
            duration_ms: 1,
1031 +
            receipt: None,
1032 +
            signed_receipt: None,
1033 +
            error: None,
1034 +
            peak_disk_mib: None,
1035 +
            peak_memory_mib: None,
1036 +
        };
1015 1037
        let quick = Duration::from_millis(10);
1016 1038
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
1017 1039
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
…
63 unchanged lines
modified crates/flow/src/syscall.rs +7 −0
…
220 unchanged lines
221 221
        let _ = child.kill();
222 222
        group.kill();
223 223
    };
224 +
    // The sandbox helper's peak resident memory (oxygenflow#41): the job's
225 +
    // own processes are its children, so this is a floor, not the whole.
226 +
    let mut peak_memory_mib = None;
224 227
    loop {
228 +
        if let Some(m) = crate::facts::peak_rss_mib(child.id()) {
229 +
            peak_memory_mib = Some(m);
230 +
        }
225 231
        match rx.recv_timeout(Duration::from_millis(200)) {
226 232
            Ok((stdout, line)) => {
227 233
                let text = String::from_utf8_lossy(&line).into_owned();
…
51 unchanged lines
279 285
        environment,
280 286
        timed_out,
281 287
        cancelled,
288 +
        peak_memory_mib,
282 289
    })
283 290
}
284 291
…
573 unchanged lines