Skip to main content

runnerd_core/
admission.rs

1//! Admission by measured peaks: a job's memory peak and average CPU predicted from the job history (`Predictor`),
2//! and the history replayed through admission by caps and by predictions, with or without a CPU budget
3//! (`simulate`), to show what each would have done with the same jobs; and what the host's memory leaves runners
4//! (`host`). Pure: no I/O, no clock.
5
6pub mod host;
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::api::Spread;
11use crate::api::{Basis, JobPeak};
12use crate::config::{self, Config};
13use crate::sizing::{GRANULE, Ran, spread};
14
15/// For this long after a run at its cap or killed for memory, in milliseconds, its job is admitted at its cap: 7
16/// days. After a run past what it reserved, its job reserves its largest peak for as long.
17pub const UNPREDICTABLE_FOR: u64 = 7 * 24 * 3600 * 1000;
18
19/// From this many successful runs of its own, fewer than `[sizing] min_jobs` runs, a job reserves its own largest
20/// peak while no other copy of it is queued or running; otherwise the larger of that and its profile's p95.
21pub const OWN_RUNS: u64 = 2;
22
23/// While the slice uses this percent of its `MemoryMax` or more, measured admission starts nothing.
24pub const HIGH_WATER: u64 = 90;
25
26/// CPU predictions round up to this many millicores: a tenth of a CPU.
27pub const CPU_GRANULE: u64 = 100;
28
29/// A job as admission tells jobs apart: its repository, workflow and name.
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
31pub struct Key {
32    /// `owner/name`.
33    pub repository: String,
34    /// The workflow's name, when known.
35    pub workflow: Option<String>,
36    /// The job's name in the workflow.
37    pub name: String,
38}
39
40impl Key {
41    /// A finished run's key.
42    pub fn of(run: &Ran) -> Key {
43        Key {
44            repository: run.repository.clone(),
45            workflow: run.workflow.clone(),
46            name: run.name.clone(),
47        }
48    }
49}
50
51/// Whether other copies of a job may run beside the one predicted: queued with it, or running. A few light runs
52/// say little about a burst of copies, after a cache was invalidated, so only one copy at a time goes by them.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Copies {
55    /// No other copy is queued or running.
56    Alone,
57    /// Another copy is queued or running.
58    Several,
59}
60
61/// The memory admission reserves for a job, at most its cap, and what it goes by.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Prediction {
64    /// Bytes.
65    pub memory: u64,
66    /// Why that much.
67    pub basis: Basis,
68}
69
70/// The CPU a runner reserves for a job, in millicores, at most its profile's `cpus`, and what it goes by.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct CpuPrediction {
73    /// Millicores.
74    pub cpu: u64,
75    /// Why that much: `Basis::Job`, `Profile`, `ProfileMax` or `Cap`.
76    pub basis: Basis,
77}
78
79/// The CPU averages of a set of runs, sorted, and how many of those runs succeeded.
80#[derive(Debug, Clone, Default)]
81struct Averages {
82    sorted: Vec<u64>,
83    succeeded: u64,
84}
85
86impl Averages {
87    fn add(&mut self, average: u64, succeeded: bool) {
88        let at = self.sorted.partition_point(|known| *known <= average);
89        self.sorted.insert(at, average);
90        self.succeeded += u64::from(succeeded);
91    }
92
93    fn runs(&self) -> u64 {
94        self.sorted.len() as u64
95    }
96
97    fn p95(&self) -> u64 {
98        spread(self.sorted.clone()).p95
99    }
100
101    fn max(&self) -> u64 {
102        self.sorted.last().copied().unwrap_or(0)
103    }
104}
105
106/// The peaks of a set of runs, sorted, until when a run at its cap makes them unpredictable, and until when a run
107/// past what it reserved makes the largest of them, or that run's peak if more, the prediction.
108#[derive(Debug, Clone, Default)]
109struct Peaks {
110    sorted: Vec<u64>,
111    /// How many of those runs succeeded.
112    succeeded: u64,
113    unpredictable_until: u64,
114    overran_until: u64,
115    /// The largest peak of a run past what it reserved, cancelled runs' included.
116    overran_peak: u64,
117}
118
119impl Peaks {
120    fn add(&mut self, peak: u64) {
121        let at = self.sorted.partition_point(|known| *known <= peak);
122        self.sorted.insert(at, peak);
123    }
124
125    fn runs(&self) -> u64 {
126        self.sorted.len() as u64
127    }
128
129    fn p95(&self) -> u64 {
130        spread(self.sorted.clone()).p95
131    }
132
133    fn max(&self) -> u64 {
134        self.sorted.last().copied().unwrap_or(0)
135    }
136}
137
138/// Predicts a job's peak from the runs it learned.
139#[derive(Debug, Clone)]
140pub struct Predictor {
141    margin: u32,
142    min_jobs: u32,
143    jobs: BTreeMap<Key, Peaks>,
144    profiles: BTreeMap<String, Peaks>,
145    /// The CPU averages of the runs that have one, apart from the peaks: a run whose peak says nothing of its
146    /// memory still says what CPU it used, and must not make its job known to memory predictions.
147    cpu_jobs: BTreeMap<Key, Averages>,
148    cpu_profiles: BTreeMap<String, Averages>,
149}
150
151impl Predictor {
152    /// A predictor that knows no run yet, with `[sizing]`'s margin and minimum runs.
153    pub fn new(rules: &config::Sizing) -> Predictor {
154        Predictor {
155            margin: rules.margin,
156            min_jobs: rules.min_jobs,
157            jobs: BTreeMap::new(),
158            profiles: BTreeMap::new(),
159            cpu_jobs: BTreeMap::new(),
160            cpu_profiles: BTreeMap::new(),
161        }
162    }
163
164    /// Learns a run of `profile` that finished at `at` (ms), having run under `cap`: its peak, and how it ended.
165    pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
166        self.mark(run, cap, at);
167        let peak = run.memory_peak;
168        if let Some(job) = self.jobs.get_mut(&Key::of(run)) {
169            job.add(peak);
170            job.succeeded += u64::from(run.succeeded);
171        }
172        self.profiles
173            .entry(profile.to_owned())
174            .or_default()
175            .add(peak);
176        self.learn_cpu(run, profile);
177    }
178
179    /// Learns the CPU a finished run of `profile` used, without its peak: as `learn` does, and alone for a run whose
180    /// peak says nothing of its memory, as one sampled with page cache counted.
181    pub fn learn_cpu(&mut self, run: &Ran, profile: &str) {
182        let Some(average) = run.cpu_average else {
183            return;
184        };
185        self.cpu_profiles
186            .entry(profile.to_owned())
187            .or_default()
188            .add(average, run.succeeded);
189        self.cpu_jobs
190            .entry(Key::of(run))
191            .or_default()
192            .add(average, run.succeeded);
193    }
194
195    /// The CPU to reserve for a run of `key` in `profile`, whose `cpus` are `cap` millicores: the job's average
196    /// CPU (its CPU time over its run time), by the same fallbacks as memory without the margin. CPU is shared, not
197    /// a kill limit, so neither a run at its cap nor copies of the job beside it change it.
198    pub fn predict_cpu(&self, key: &Key, profile: &str, cap: u64) -> CpuPrediction {
199        let job = self.cpu_jobs.get(key);
200        let min_jobs = u64::from(self.min_jobs);
201        let peers = self
202            .cpu_profiles
203            .get(profile)
204            .filter(|peers| peers.runs() >= min_jobs);
205        let (cpu, basis) = match (job.filter(|job| job.runs() > 0), peers) {
206            (Some(job), _) if job.runs() >= min_jobs => (
207                job.p95(),
208                Basis::Job {
209                    runs: job.runs(),
210                    peak: JobPeak::P95,
211                },
212            ),
213            (Some(job), Some(_)) if job.succeeded >= OWN_RUNS => (
214                job.max(),
215                Basis::Job {
216                    runs: job.runs(),
217                    peak: JobPeak::Largest,
218                },
219            ),
220            (Some(job), Some(peers)) => (
221                job.max().max(peers.p95()),
222                Basis::Profile { runs: peers.runs() },
223            ),
224            (None, Some(peers)) => (peers.max(), Basis::ProfileMax { runs: peers.runs() }),
225            _ => (cap, Basis::Cap),
226        };
227        CpuPrediction {
228            cpu: cpu
229                .div_ceil(CPU_GRANULE)
230                .saturating_mul(CPU_GRANULE)
231                .clamp(CPU_GRANULE, cap.max(CPU_GRANULE)),
232            basis,
233        }
234    }
235
236    /// Learns only how a run ended, not its peak, as for a cancelled one: a run that met its cap (`Ran::met_cap`)
237    /// makes its job unpredictable for `UNPREDICTABLE_FOR`, a peak past what it reserved makes its job reserve its
238    /// largest peak for as long.
239    pub fn mark(&mut self, run: &Ran, cap: u64, at: u64) {
240        let peak = run.memory_peak;
241        let job = self.jobs.entry(Key::of(run)).or_default();
242        if run.met_cap(cap) {
243            job.unpredictable_until = job.unpredictable_until.max(at + UNPREDICTABLE_FOR);
244        }
245        if run.reserved.is_some_and(|reserved| peak > reserved) {
246            job.overran_until = job.overran_until.max(at + UNPREDICTABLE_FOR);
247            job.overran_peak = job.overran_peak.max(peak);
248        }
249    }
250
251    /// The memory to reserve for a run of `key` in `profile`, whose cap is `cap`, at `now` (ms), with `copies`
252    /// of it beside.
253    pub fn predict(
254        &self,
255        key: &Key,
256        profile: &str,
257        cap: u64,
258        now: u64,
259        copies: Copies,
260    ) -> Prediction {
261        let job = self.jobs.get(key);
262        if let Some(until) = job
263            .map(|job| job.unpredictable_until)
264            .filter(|until| *until > now)
265        {
266            return Prediction {
267                memory: cap,
268                basis: Basis::Unpredictable {
269                    until: until / 1000,
270                },
271            };
272        }
273        let min_jobs = u64::from(self.min_jobs);
274        let (memory, basis) = match (job, self.profiles.get(profile)) {
275            // Seen only cancelled, so its peaks say nothing.
276            (Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
277            (Some(job), _) if job.overran_until > now => (
278                self.with_margin(job.max().max(job.overran_peak)),
279                Basis::Overran {
280                    until: job.overran_until / 1000,
281                },
282            ),
283            (Some(job), _) if job.runs() >= min_jobs => (
284                self.with_margin(job.p95()),
285                Basis::Job {
286                    runs: job.runs(),
287                    peak: JobPeak::P95,
288                },
289            ),
290            (Some(job), Some(peers))
291                if peers.runs() >= min_jobs
292                    && job.succeeded >= OWN_RUNS
293                    && copies == Copies::Alone =>
294            {
295                (
296                    self.with_margin(job.max()),
297                    Basis::Job {
298                        runs: job.runs(),
299                        peak: JobPeak::Largest,
300                    },
301                )
302            }
303            (Some(job), Some(peers)) if peers.runs() >= min_jobs => (
304                self.with_margin(job.max().max(peers.p95())),
305                Basis::Profile { runs: peers.runs() },
306            ),
307            // A job never seen may be the profile's heaviest.
308            (None, Some(peers)) if peers.runs() >= min_jobs => (
309                self.with_margin(peers.max()),
310                Basis::ProfileMax { runs: peers.runs() },
311            ),
312            _ => (cap, Basis::Cap),
313        };
314        Prediction {
315            memory: memory.min(cap),
316            basis,
317        }
318    }
319
320    /// `peak` plus the margin, rounded up to 256M.
321    fn with_margin(&self, peak: u64) -> u64 {
322        let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
323        memory
324            .div_ceil(GRANULE)
325            .saturating_mul(GRANULE)
326            .max(GRANULE)
327    }
328}
329
330/// How a replay admits a queued job.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Policy {
333    /// When a slot is free and the caps of the running jobs and its own fit the budget: what runnerd does now.
334    Caps,
335    /// As the daemon admits: when a slot is free, the running jobs (each at the larger of its prediction and its
336    /// peak) and the largest prediction among the repository's waiting jobs fit the budget, and the running jobs'
337    /// peaks are under `HIGH_WATER` percent of it. A started job then reserves its own prediction.
338    Measured,
339}
340
341/// What a replay did with the history's jobs.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct Replay {
344    /// The jobs replayed: finished ones of a configured profile.
345    pub jobs: u64,
346    /// Of those, the ones the policy never started: more than the budget holds.
347    pub never_started: u64,
348    /// Seconds from queued to started.
349    pub wait: Spread,
350    /// The mean of those, in seconds.
351    pub mean_wait: u64,
352    /// The most jobs that ran at once.
353    pub most_running: u64,
354    /// The most memory, in bytes, the running jobs' peaks and the mirror add up to at one moment: as if every
355    /// running job peaked at once, which is more than the slice ever holds. Not a bound on the daemon, which can
356    /// overlap more: it sees what jobs use, less than their peaks while they warm up, and the replay leaves out
357    /// start latency, seeds, shells and cancelled jobs.
358    pub worst_overlap: u64,
359    /// Seconds from the first job queued to the last one finished.
360    pub makespan: u64,
361    /// Jobs whose peak was above what admission reserved for them when they ended.
362    pub under_predicted: u64,
363    /// How many times the high-water mark held a job back that the predictions had room for.
364    pub held_back: u64,
365    /// How many times the CPU budget held a job back that memory and its slots had room for.
366    pub cpu_held: u64,
367    /// The most CPU, in millicores, the running jobs' averages add up to at one moment.
368    pub worst_cpu: u64,
369}
370
371/// A job the replay started.
372struct Running<'run> {
373    run: &'run Ran,
374    profile: &'run str,
375    ends: u64,
376    reserved: u64,
377    peak: u64,
378    /// The CPU it reserves, in millicores.
379    cpu: u64,
380}
381
382/// Replays `jobs` through `policy`: each arrives when it was queued and runs as long as it ran, in a profile with
383/// `slots` (by name; the config's where absent) under the slice's `memory_max`. Jobs start as soon as the policy
384/// admits them, oldest first, a job that does not fit letting a later one past, as `scheduler::plan` does.
385/// Measured admission predicts from the runs finished before it decides, as the daemon would, and holds each
386/// running job at its peak for its whole run, which the daemon does not see. A job with copies waiting or running
387/// beside it is predicted as one with `Copies::Several`. Each moment, the running jobs are predicted again, the
388/// first running copy of a job going by its own runs, as the daemon does each tick. Unlike the daemon, the replay
389/// knows a job from its start: it never sees a runner whose job is not known yet, and the order jobs started
390/// stands for the daemon's slot order.
391///
392/// With a `cpu_budget` in millicores, a job also starts only when the CPU the running jobs reserve and its own fit
393/// it: by caps, each its profile's `cpus`; measured, each its predicted average (`Predictor::predict_cpu`), at most
394/// the budget. The replay holds each job's run time whatever the CPU it shares: it shows what admission holds back,
395/// not how much a busy CPU slows the jobs.
396pub fn simulate(
397    config: &Config,
398    memory_max: Option<u64>,
399    cpu_budget: Option<u64>,
400    jobs: &[Ran],
401    policy: Policy,
402    slots: &BTreeMap<String, u32>,
403) -> Replay {
404    let mirror = config.mirror_memory();
405    let budget = memory_max.map(|max| max.saturating_sub(mirror));
406    let mut queue: Vec<&Ran> = jobs
407        .iter()
408        .filter(|run| config.profiles.contains_key(&run.profile))
409        .filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
410        .collect();
411    queue.sort_by_key(|run| run.queued_at);
412    let mut replayer = Replayer {
413        config,
414        budget,
415        cpu_budget,
416        policy,
417        slots,
418        predictor: Predictor::new(&config.sizing),
419        running: Vec::new(),
420        waits: Vec::new(),
421        most_running: 0,
422        worst_overlap: 0,
423        last_end: 0,
424        under_predicted: 0,
425        held_back: 0,
426        cpu_held: 0,
427        worst_cpu: 0,
428    };
429    let mut waiting: Vec<&Ran> = Vec::new();
430    let mut next = 0;
431    loop {
432        let arrival = queue.get(next).map(|run| run.queued_at);
433        let end = replayer.running.iter().map(|running| running.ends).min();
434        let Some(now) = [arrival, end].into_iter().flatten().min() else {
435            break;
436        };
437        replayer.finish(now);
438        replayer.rereserve(now);
439        while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
440            waiting.push(run);
441            next += 1;
442        }
443        let mut index = 0;
444        while let Some(run) = waiting.get(index).copied() {
445            if replayer.admit(run, &waiting, now) {
446                waiting.remove(index);
447            } else {
448                index += 1;
449            }
450        }
451        replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
452        let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
453        replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
454        let cpu: u64 = replayer
455            .running
456            .iter()
457            .map(|running| running.run.cpu_average.unwrap_or(0))
458            .sum();
459        replayer.worst_cpu = replayer.worst_cpu.max(cpu);
460    }
461    let waits = replayer.waits;
462    let first = queue.first().map_or(0, |run| run.queued_at);
463    Replay {
464        jobs: queue.len() as u64,
465        never_started: waiting.len() as u64,
466        mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
467        wait: spread(waits),
468        most_running: replayer.most_running,
469        worst_overlap: replayer.worst_overlap,
470        makespan: replayer.last_end.saturating_sub(first) / 1000,
471        under_predicted: replayer.under_predicted,
472        held_back: replayer.held_back,
473        cpu_held: replayer.cpu_held,
474        worst_cpu: replayer.worst_cpu,
475    }
476}
477
478/// A replay's state between two moments.
479struct Replayer<'run> {
480    config: &'run Config,
481    budget: Option<u64>,
482    cpu_budget: Option<u64>,
483    policy: Policy,
484    slots: &'run BTreeMap<String, u32>,
485    predictor: Predictor,
486    running: Vec<Running<'run>>,
487    waits: Vec<u64>,
488    most_running: u64,
489    worst_overlap: u64,
490    last_end: u64,
491    under_predicted: u64,
492    held_back: u64,
493    cpu_held: u64,
494    worst_cpu: u64,
495}
496
497impl<'run> Replayer<'run> {
498    /// Ends the jobs that end by `now`; measured admission learns their peaks.
499    fn finish(&mut self, now: u64) {
500        let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
501            .into_iter()
502            .partition(|running| running.ends <= now);
503        self.running = running;
504        for ended in ended {
505            let cap = self.config.profiles[ended.profile].memory.0;
506            self.under_predicted += u64::from(ended.peak > ended.reserved);
507            let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
508            let run = Ran {
509                reserved,
510                ..ended.run.clone()
511            };
512            self.predictor.learn(&run, ended.profile, cap, ended.ends);
513            self.last_end = self.last_end.max(ended.ends);
514        }
515    }
516
517    /// Under measured admission, predicts each running job again, as the daemon's runners follow their job's
518    /// prediction every tick: the first running copy of a job, in the order they started, goes by its own runs, and
519    /// the others have it beside them.
520    fn rereserve(&mut self, now: u64) {
521        if self.policy != Policy::Measured {
522            return;
523        }
524        let cpus: Vec<u64> = self
525            .running
526            .iter()
527            .map(|running| self.cpu(&Key::of(running.run), running.profile))
528            .collect();
529        let mut seen = BTreeSet::new();
530        for (running, cpu) in self.running.iter_mut().zip(cpus) {
531            let key = Key::of(running.run);
532            let copies = if seen.insert(key.clone()) {
533                Copies::Alone
534            } else {
535                Copies::Several
536            };
537            let limits = &self.config.profiles[running.profile];
538            running.reserved = self
539                .predictor
540                .predict(&key, running.profile, limits.memory.0, now, copies)
541                .memory;
542            running.cpu = cpu;
543        }
544    }
545
546    /// The CPU a run of `key` in `profile` reserves, in millicores: its profile's `cpus` by caps, its predicted
547    /// average measured, at most the CPU budget.
548    fn cpu(&self, key: &Key, profile: &str) -> u64 {
549        let cap = u64::from(self.config.profiles[profile].cpus).saturating_mul(1000);
550        let cpu = match self.policy {
551            Policy::Caps => cap,
552            Policy::Measured => self.predictor.predict_cpu(key, profile, cap).cpu,
553        };
554        self.cpu_budget.map_or(cpu, |budget| cpu.min(budget))
555    }
556
557    /// What a running job takes of the budget: under measured admission, as the daemon counts it, the larger of
558    /// what it reserved and what it uses, here its peak for the whole run.
559    fn taken(&self) -> u64 {
560        self.running
561            .iter()
562            .map(|running| match self.policy {
563                Policy::Caps => running.reserved,
564                Policy::Measured => running.reserved.max(running.peak),
565            })
566            .sum()
567    }
568
569    /// Starts `run` at `now` if the policy admits it, `waiting` being every job not started yet; whether it did.
570    /// As in the daemon, the runner starts reserving the largest prediction among its repository's waiting jobs,
571    /// then its job's own.
572    fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
573        let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
574            return false;
575        };
576        let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
577        let in_profile = self
578            .running
579            .iter()
580            .filter(|running| running.profile == profile.as_str())
581            .count();
582        if in_profile >= slots as usize {
583            return false;
584        }
585        let cap = limits.memory.0;
586        let running = |key: &Key| {
587            self.running
588                .iter()
589                .any(|running| Key::of(running.run) == *key)
590        };
591        // A waiting job with a copy waiting or running beside it, as the daemon sees its queue.
592        let queued = |job: &Ran| {
593            let key = Key::of(job);
594            let waiting = waiting.iter().filter(|other| Key::of(other) == key).count();
595            let copies = if waiting > 1 || running(&key) {
596                Copies::Several
597            } else {
598                Copies::Alone
599            };
600            self.predictor
601                .predict(&key, profile, cap, now, copies)
602                .memory
603        };
604        // The started job, once known: the first of its copies to run goes by its own runs.
605        let started = || {
606            let key = Key::of(run);
607            let copies = if running(&key) {
608                Copies::Several
609            } else {
610                Copies::Alone
611            };
612            self.predictor
613                .predict(&key, profile, cap, now, copies)
614                .memory
615        };
616        let (starting, reserved) = match self.policy {
617            Policy::Caps => (cap, cap),
618            Policy::Measured => (
619                waiting
620                    .iter()
621                    .filter(|job| job.repository == run.repository)
622                    .map(|job| queued(job))
623                    .max()
624                    .unwrap_or(0)
625                    .max(started()),
626                started(),
627            ),
628        };
629        if let Some(budget) = self.budget {
630            if self.taken() + starting > budget {
631                return false;
632            }
633            let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
634            if self.policy == Policy::Measured
635                && peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
636            {
637                self.held_back += 1;
638                return false;
639            }
640        }
641        let cpu = self.cpu(&Key::of(run), profile);
642        if let Some(cpu_budget) = self.cpu_budget {
643            // As the daemon starts a runner: at the most CPU any of its repository's waiting jobs reserves.
644            let starting = waiting
645                .iter()
646                .filter(|job| job.repository == run.repository)
647                .map(|job| self.cpu(&Key::of(job), profile))
648                .max()
649                .unwrap_or(0)
650                .max(cpu);
651            let taken: u64 = self.running.iter().map(|running| running.cpu).sum();
652            if taken + starting > cpu_budget {
653                self.cpu_held += 1;
654                return false;
655            }
656        }
657        // A job over its cap is killed at it.
658        let peak = run.memory_peak.min(cap);
659        let ran_for = run
660            .finished_at
661            .unwrap_or_default()
662            .saturating_sub(run.started_at.unwrap_or_default());
663        self.waits.push(now.saturating_sub(run.queued_at) / 1000);
664        self.running.push(Running {
665            run,
666            profile,
667            ends: now + ran_for,
668            reserved,
669            peak,
670            cpu,
671        });
672        true
673    }
674}
675
676#[cfg(test)]
677mod tests;