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