OXYGENHUB
# bb643c252515 oxygenflow / crates / flow / src / runner.rs
12.4 KiB · Rust · last changed 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 1 month ago source blame history raw
e0918b88 Andy McCall 1
//! `flow runner` (docs/m0.md §5): claim a job from OxygenHub, fetch the
2
//! repository into a cache with the job's token, write the state into a
3
//! workspace, run it in its tier, stream the log, report the receipt.
4
//! One job at a time.
5
6
use crate::home::{Home, RunnerConfig};
7
use crate::{microvm, workspace};
8
use anyhow::{bail, Context, Result};
9
use flow_core::api::{Claim, ClaimRequest, Finish, LogBatch, Record, MAX_RECORD_TEXT};
10
use flow_core::{scope_tree_hash, Receipt};
11
use oxy_core::hash::ChangeHash;
12
use oxy_core::repo::Repo;
13
use std::path::PathBuf;
14
use std::time::{Duration, Instant};
15
16
pub struct Hub {
17
    base: String,
18
    token: String,
19
    agent: ureq::Agent,
20
}
21
22
impl Hub {
23
    pub fn new(cfg: &RunnerConfig) -> Result<Hub> {
24
        let tls = oxy_core::protocol::tls_client_config(cfg.ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?;
25
        let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(60)).build();
26
        Ok(Hub { base: cfg.hub.trim_end_matches('/').to_string(), token: cfg.token.clone(), agent })
27
    }
28
29
    fn post(&self, path: &str, body: &impl serde::Serialize) -> Result<Option<ureq::Response>> {
30
        let url = format!("{}/api/v1{path}", self.base);
31
        match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(body) {
32
            Ok(r) if r.status() == 204 => Ok(None),
33
            Ok(r) => Ok(Some(r)),
34
            Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
35
            Err(e) => bail!("{url}: {e}"),
36
        }
37
    }
38
39
    pub fn claim(&self, req: &ClaimRequest) -> Result<Option<Claim>> {
40
        match self.post("/flow/claim", req)? {
41
            Some(r) => Ok(Some(r.into_json()?)),
42
            None => Ok(None),
43
        }
44
    }
45
46
    pub fn log(&self, job: i64, records: Vec<Record>) -> Result<()> {
47
        self.post(&format!("/flow/jobs/{job}/log"), &LogBatch { records })?;
48
        Ok(())
49
    }
50
51
    pub fn finish(&self, job: i64, f: &Finish) -> Result<()> {
52
        self.post(&format!("/flow/jobs/{job}/finish"), f)?;
53
        Ok(())
54
    }
55
}
56
bb643c25 Andy McCall ↶ 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
e0918b88 Andy McCall 98
pub fn serve(home: &Home) -> Result<()> {
99
    let cfg = home.config.runner.clone().context("flow.toml has no [runner] section")?;
100
    let hub = Hub::new(&cfg)?;
101
    let name = cfg.name.clone().unwrap_or_else(|| "flow".into());
bb643c25 Andy McCall ↶ 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());
e0918b88 Andy McCall 105
    let mut idle = Duration::from_secs(2);
bb643c25 Andy McCall ↶ 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 };
e0918b88 Andy McCall 113
        match hub.claim(&req) {
114
            Ok(Some(claim)) => {
115
                idle = Duration::from_secs(2);
bb643c25 Andy McCall ↶ 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;
e0918b88 Andy McCall 122
                }
bb643c25 Andy McCall ↶ 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
                });
e0918b88 Andy McCall 146
            }
147
            Ok(None) => std::thread::sleep(idle),
148
            Err(e) => {
149
                eprintln!("claim: {e:#}");
150
                std::thread::sleep(idle);
151
                idle = (idle * 2).min(Duration::from_secs(60));
152
            }
153
        }
bb643c25 Andy McCall ↶ 154
    })
e0918b88 Andy McCall 155
}
156
157
/// Batches records to the hub every second (or every 200 lines).
158
struct LogSink<'a> {
159
    hub: &'a Hub,
160
    job: i64,
161
    buf: Vec<Record>,
162
    last: Instant,
163
}
164
165
impl LogSink<'_> {
166
    fn push(&mut self, mut r: Record) {
167
        if r.text.len() > MAX_RECORD_TEXT {
168
            let mut cut = MAX_RECORD_TEXT;
169
            while !r.text.is_char_boundary(cut) {
170
                cut -= 1;
171
            }
172
            r.text.truncate(cut);
173
        }
174
        self.buf.push(r);
175
        if self.buf.len() >= 200 || self.last.elapsed() >= Duration::from_secs(1) {
176
            self.flush();
177
        }
178
    }
179
180
    fn flush(&mut self) {
181
        if !self.buf.is_empty() {
182
            if let Err(e) = self.hub.log(self.job, std::mem::take(&mut self.buf)) {
183
                eprintln!("job {}: log: {e:#}", self.job);
184
            }
185
        }
186
        self.last = Instant::now();
187
    }
188
}
bb643c25 Andy McCall ↶ 189
190
/// Held while a job's workspace is prepared (see `run_claim`).
191
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
e0918b88 Andy McCall 192
193
fn run_claim(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) -> Result<Finish> {
194
    let started = Instant::now();
195
    let mut sink = LogSink { hub, job: claim.job_id, buf: Vec::new(), last: Instant::now() };
196
    let note =
197
        |sink: &mut LogSink, text: String| sink.push(Record { t: started.elapsed().as_millis() as u64, stream: "flow".into(), text });
198
    let heads: Vec<ChangeHash> = claim.heads.iter().map(|h| ChangeHash::from_hex(h).context("bad head")).collect::<Result<_>>()?;
199
    note(&mut sink, format!("fetching {} for state {}", claim.repo, claim.heads.iter().map(|h| &h[..12]).collect::<Vec<_>>().join("+")));
bb643c25 Andy McCall ↶ 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()));
e0918b88 Andy McCall 205
    let repo = cached_repo(home, cfg, claim)?;
206
    let scratch = home.root.join("work").join(format!("job-{}", claim.job_id));
207
    let _ = std::fs::remove_dir_all(&scratch);
208
    std::fs::create_dir_all(scratch.join("tree"))?;
209
    let result = (|| -> Result<Finish> {
210
        let files = workspace::export_state(&repo, &heads, &scratch.join("tree"))?;
211
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
212
        workspace::image(&scratch.join("tree"), &scratch.join("workspace.ext4"))?;
213
        std::fs::remove_dir_all(scratch.join("tree"))?;
bb643c25 Andy McCall ↶ 214
        preparing.take();
e0918b88 Andy McCall 215
        note(
216
            &mut sink,
217
            format!(
218
                "booting a microVM: {} vCPU, {} MiB, rootfs {}",
219
                claim.sandbox.cpus,
220
                claim.sandbox.memory_mib,
221
                &claim.sandbox.rootfs[..12]
222
            ),
223
        );
224
        let outcome = microvm::run(
225
            home,
226
            &claim.sandbox,
227
            &claim.job,
228
            &scratch.join("workspace.ext4"),
229
            &scratch,
230
            Duration::from_secs(claim.timeout_s),
231
            &mut |r| sink.push(r),
232
        )?;
233
        let receipt = outcome.exit_code.map(|code| Receipt {
234
            job_name: claim.job.name.clone(),
235
            pipeline_hash: hex32(&claim.pipeline_hash),
236
            scope_tree_hash: scope_hash,
237
            inputs: vec![],
238
            environment: outcome.environment.hash(),
239
            outputs: vec![],
240
            exit_code: code as u32,
241
            duration_ms: outcome.duration_ms,
242
        });
243
        Ok(Finish {
244
            exit_code: outcome.exit_code,
245
            duration_ms: started.elapsed().as_millis() as u64,
246
            receipt,
247
            error: outcome.timed_out.then(|| "stopped at the time limit".to_string()),
248
        })
249
    })();
250
    let _ = std::fs::remove_dir_all(&scratch);
251
    sink.flush();
252
    result
253
}
254
255
fn hex32(s: &str) -> [u8; 32] {
256
    let mut out = [0; 32];
257
    if let Ok(v) = hex::decode(s) {
258
        if v.len() == 32 {
259
            out.copy_from_slice(&v);
260
        }
261
    }
262
    out
263
}
264
265
/// The runner's copy of a repository, fetched up to date with the job's token.
266
fn cached_repo(home: &Home, cfg: &RunnerConfig, claim: &Claim) -> Result<Repo> {
267
    let dir: PathBuf = home.root.join("repos").join(claim.repo.replace('/', "__"));
268
    let set_remote = |repo: &mut Repo| -> Result<()> {
269
        repo.config.set("remote.origin.url", &claim.clone_url).map_err(|e| anyhow::anyhow!("{e}"))?;
270
        repo.config.set("remote.origin.token", &claim.token).map_err(|e| anyhow::anyhow!("{e}"))?;
271
        if let Some(ca) = &cfg.ca {
272
            repo.config.set("remote.origin.ca", &ca.to_string_lossy()).map_err(|e| anyhow::anyhow!("{e}"))?;
273
        }
274
        repo.save_config().map_err(|e| anyhow::anyhow!("{e}"))
275
    };
276
    if dir.join(".oxy").is_dir() {
277
        let mut repo = Repo::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
278
        set_remote(&mut repo)?;
279
        repo.fetch("origin").map_err(|e| anyhow::anyhow!("fetch {}: {e}", claim.clone_url))?;
280
        return Ok(repo);
281
    }
282
    // First job for this repository: clone with the job's token.
283
    std::env::set_var("OXY_TOKEN", &claim.token);
284
    if let Some(ca) = &cfg.ca {
285
        std::env::set_var("OXY_CA", ca);
286
    }
287
    let cloned = Repo::clone(&claim.clone_url, &dir);
288
    std::env::remove_var("OXY_TOKEN");
289
    std::env::remove_var("OXY_CA");
290
    let mut repo = cloned.map_err(|e| {
291
        let _ = std::fs::remove_dir_all(&dir);
292
        anyhow::anyhow!("clone {}: {e}", claim.clone_url)
293
    })?;
294
    set_remote(&mut repo)?;
295
    Ok(repo)
bb643c25 Andy McCall ↶ 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
    }
e0918b88 Andy McCall 316
}