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