OXYGENHUB

The runner runs up to max_jobs jobs at once within a memory and vCPU budget, asking the hub only for jobs that fit (a lone job may use the whole budget); preparing workspaces stays one at a time bb643c252515

closes #6
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 20:29 UTC
Signature
✔ signed by Andy McCall
Hash
bb643c25251524e1dee035068eeb94d18303a6381aa7e2ec4c3be350de36fa5c
Depends on
a083cc802892 e0918b887850
Depended on by
1a50d4dae89c 0d663e91c598 32f54b393396 77c8c24066ef cedd12342afa f201e1bc5bcd 4be5e4cd0af6 a4b3f8cb9aae ed4aef8b70ca 46fe2c815cc3 89171fca079c b14aeaae683a b11e30f5031d ea56c56eaf90 c5b702e59c1f f53e6d47b56a 280ef951d193 1daca0b6bb8c 3007d8f011fa f98a8f7881e0 47c1d64dec8a 1b2229dab1a4
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
14 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified README.md +3 −0
…
73 unchanged lines
74 74
token = "…"                  # listed in the hub's [flow] runner_tokens
75 75
ca = "/path/to/hub-ca.pem"   # when the hub's certificate is self-signed
76 76
name = "runner-1"
77 +
max_jobs = 3                 # jobs at once (default 1)
78 +
memory_mib = 24576           # what they may use together; a lone job may use it all
79 +
cpus = 8
77 80
```
78 81
79 82
Setting one up needs Linux x86-64 with KVM and e2fsprogs 1.43 or later:
…
27 unchanged lines
modified crates/flow-core/src/api.rs +7 −0
…
11 unchanged lines
12 12
    pub runner: String,
13 13
    /// Tiers this runner can run (`microvm`, `syscall`).
14 14
    pub runtimes: Vec<String>,
15 +
    /// Memory the runner has free for another job, in MiB: the hub hands
16 +
    /// out only a job whose sandbox fits. Absent: no limit.
17 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
18 +
    pub free_memory_mib: Option<u32>,
19 +
    /// vCPUs the runner has free for another job. Absent: no limit.
20 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
21 +
    pub free_cpus: Option<u32>,
15 22
}
16 23
17 24
/// A claimed job: everything the runner needs, nothing more.
…
51 unchanged lines
modified crates/flow/src/home.rs +8 −0
…
39 unchanged lines
40 40
    pub token: String,
41 41
    pub ca: Option<PathBuf>,
42 42
    pub name: Option<String>,
43 +
    /// Jobs run at once (default 1).
44 +
    #[serde(default)]
45 +
    pub max_jobs: Option<usize>,
46 +
    /// Memory (MiB) and vCPUs the jobs running at once may use together.
47 +
    #[serde(default)]
48 +
    pub memory_mib: Option<u32>,
49 +
    #[serde(default)]
50 +
    pub cpus: Option<u32>,
43 51
}
44 52
45 53
#[derive(Clone, Debug, Default, Deserialize)]
…
69 unchanged lines
modified crates/flow/src/runner.rs +110 −16
…
53 unchanged lines
54 54
    }
55 55
}
56 56
57 +
/// What the runner may run at once (flow.toml `[runner]`): at most
58 +
/// `max_jobs` jobs, whose sandboxes together fit `memory_mib` and `cpus`.
59 +
#[derive(Debug, Default, PartialEq, Eq)]
60 +
pub struct Budget {
61 +
    pub max_jobs: usize,
62 +
    pub memory_mib: Option<u32>,
63 +
    pub cpus: Option<u32>,
64 +
}
65 +
66 +
/// What is in use now.
67 +
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
68 +
pub struct Used {
69 +
    pub jobs: usize,
70 +
    pub memory_mib: u32,
71 +
    pub cpus: u32,
72 +
}
73 +
74 +
impl Budget {
75 +
    pub fn of(cfg: &RunnerConfig) -> Budget {
76 +
        Budget { max_jobs: cfg.max_jobs.unwrap_or(1).max(1), memory_mib: cfg.memory_mib, cpus: cfg.cpus }
77 +
    }
78 +
79 +
    /// The room to ask the hub for, or `None` when no job more can start.
80 +
    /// A lone job may use the whole budget even if it asks for more, so a
81 +
    /// large sandbox is never starved.
82 +
    pub fn room(&self, used: Used) -> Option<(Option<u32>, Option<u32>)> {
83 +
        if used.jobs >= self.max_jobs {
84 +
            return None;
85 +
        }
86 +
        if used.jobs == 0 {
87 +
            return Some((None, None));
88 +
        }
89 +
        let mem = self.memory_mib.map(|m| m.saturating_sub(used.memory_mib));
90 +
        let cpus = self.cpus.map(|c| c.saturating_sub(used.cpus));
91 +
        if mem == Some(0) || cpus == Some(0) {
92 +
            return None;
93 +
        }
94 +
        Some((mem, cpus))
95 +
    }
96 +
}
97 +
57 98
pub fn serve(home: &Home) -> Result<()> {
58 99
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
59 100
    let hub = Hub::new(&cfg)?;
60 101
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
61 -
    let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()] };
62 -
    eprintln!("flow runner {name}: polling {}", cfg.hub);
102 +
    let budget = Budget::of(&cfg);
103 +
    eprintln!("flow runner {name}: polling {} (up to {} jobs at once)", cfg.hub, budget.max_jobs);
104 +
    let used = std::sync::Mutex::new(Used::default());
63 105
    let mut idle = Duration::from_secs(2);
64 -
    loop {
106 +
    std::thread::scope(|scope| loop {
107 +
        let room = budget.room(*used.lock().unwrap());
108 +
        let Some((free_memory_mib, free_cpus)) = room else {
109 +
            std::thread::sleep(Duration::from_millis(500));
110 +
            continue;
111 +
        };
112 +
        let req = ClaimRequest { runner: name.clone(), runtimes: vec!["microvm".into()], free_memory_mib, free_cpus };
65 113
        match hub.claim(&req) {
66 114
            Ok(Some(claim)) => {
67 115
                idle = Duration::from_secs(2);
68 -
                let id = claim.job_id;
69 -
                eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-"));
70 -
                let finish = run_claim(home, &hub, &cfg, &claim);
71 -
                let finish =
72 -
                    finish.unwrap_or_else(|e| Finish { exit_code: None, duration_ms: 0, receipt: None, error: Some(format!("{e:#}")) });
73 -
                eprintln!(
74 -
                    "job {id}: exit {:?}{}",
75 -
                    finish.exit_code,
76 -
                    finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
77 -
                );
78 -
                if let Err(e) = hub.finish(id, &finish) {
79 -
                    eprintln!("job {id}: reporting failed: {e:#}");
116 +
                let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
117 +
                {
118 +
                    let mut u = used.lock().unwrap();
119 +
                    u.jobs += 1;
120 +
                    u.memory_mib += mem;
121 +
                    u.cpus += cpus;
80 122
                }
123 +
                let (home, hub, cfg, used) = (home, &hub, &cfg, &used);
124 +
                scope.spawn(move || {
125 +
                    let id = claim.job_id;
126 +
                    eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-"));
127 +
                    let finish = run_claim(home, hub, cfg, &claim).unwrap_or_else(|e| Finish {
128 +
                        exit_code: None,
129 +
                        duration_ms: 0,
130 +
                        receipt: None,
131 +
                        error: Some(format!("{e:#}")),
132 +
                    });
133 +
                    eprintln!(
134 +
                        "job {id}: exit {:?}{}",
135 +
                        finish.exit_code,
136 +
                        finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
137 +
                    );
138 +
                    if let Err(e) = hub.finish(id, &finish) {
139 +
                        eprintln!("job {id}: reporting failed: {e:#}");
140 +
                    }
141 +
                    let mut u = used.lock().unwrap();
142 +
                    u.jobs -= 1;
143 +
                    u.memory_mib -= mem;
144 +
                    u.cpus -= cpus;
145 +
                });
81 146
            }
82 147
            Ok(None) => std::thread::sleep(idle),
83 148
            Err(e) => {
…
2 unchanged lines
86 151
                idle = (idle * 2).min(Duration::from_secs(60));
87 152
            }
88 153
        }
89 -
    }
154 +
    })
90 155
}
91 156
92 157
/// Batches records to the hub every second (or every 200 lines).
…
28 unchanged lines
121 186
        self.last = Instant::now();
122 187
    }
123 188
}
189 +
190 +
/// Held while a job's workspace is prepared (see `run_claim`).
191 +
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
124 192
125 193
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) -> Result<Finish> {
126 194
    let started = Instant::now();
…
2 unchanged lines
129 197
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
130 198
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
131 199
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
200 +
    // Preparing a workspace (the cached repositories, the export, the
201 +
    // image) is one job at a time: fetches into a cached repository must not
202 +
    // overlap, and the first clone passes its token through the process
203 +
    // environment. Only the microVMs run side by side.
204 +
    let mut preparing = Some(PREPARE.lock().unwrap_or_else(|p| p.into_inner()));
132 205
    let repo = cached_repo(home, cfg, claim)?;
133 206
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
134 207
    let _ = std::fs::remove_dir_all(&scratch);
…
3 unchanged lines
138 211
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
139 212
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
140 213
        std::fs::remove_dir_all(scratch.join("tree"))?;
214 +
        preparing.take();
141 215
        note(
142 216
            &mut sink,
143 217
            format!(
…
75 unchanged lines
219 293
    })?;
220 294
    set_remote(&mut repo)?;
221 295
    Ok(repo)
296 +
}
297 +
298 +
#[cfg(test)]
299 +
mod tests {
300 +
    use super::*;
301 +
302 +
    #[test]
303 +
    fn the_budget_admits_jobs_that_fit() {
304 +
        let b = Budget { max_jobs: 3, memory_mib: Some(24_576), cpus: Some(8) };
305 +
        let used = |jobs, memory_mib, cpus| Used { jobs, memory_mib, cpus };
306 +
        assert_eq!(b.room(used(0, 0, 0)), Some((None, None)), "a lone job may take everything");
307 +
        assert_eq!(b.room(used(1, 8192, 4)), Some((Some(16_384), Some(4))));
308 +
        assert_eq!(b.room(used(2, 16_384, 8)), None, "no vCPU left");
309 +
        assert_eq!(b.room(used(3, 1024, 1)), None, "at max_jobs");
310 +
        let one = Budget { max_jobs: 1, memory_mib: None, cpus: None };
311 +
        assert_eq!(one.room(used(0, 0, 0)), Some((None, None)));
312 +
        assert_eq!(one.room(used(1, 0, 0)), None, "the default runs one job at a time");
313 +
        let open = Budget { max_jobs: 2, memory_mib: None, cpus: None };
314 +
        assert_eq!(open.room(used(1, 8192, 4)), Some((None, None)), "no limits configured");
315 +
    }
222 316
}