|
|
… |
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 |
|
} |