Skip to main content

runnerd_core/api/
admission.rs

1//! How a runner was admitted, as the job history keeps it for its job: the policy, what the runner reserved of
2//! the slice's budget and what that went by, and the budget and the slice when it started. Sizes are bytes, times
3//! seconds since the Unix epoch.
4
5use std::fmt::Write as _;
6
7use serde::{Deserialize, Serialize};
8
9use crate::config::ByteSize;
10
11/// `[host] admission`, as a job's record and the budget name it.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum AdmissionPolicy {
15    /// Every runner reserves its memory cap.
16    #[default]
17    Caps,
18    /// Every runner reserves its predicted peak.
19    Measured,
20}
21
22/// What a runner reserved goes by, in the order measured admission falls back.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case", tag = "kind")]
25pub enum Basis {
26    /// The job's own runs, plus the margin: with `[sizing] min_jobs` of them, their p95 peak; with fewer, their
27    /// largest, once `admission::OWN_RUNS` of them succeeded, its profile has `min_jobs` runs and no other copy of
28    /// the job is queued or running.
29    Job {
30        /// How many.
31        runs: u64,
32        /// Which of their peaks. Absent on the wire for the p95, so a reader without the field reads it as the
33        /// basis it always was.
34        #[serde(default, skip_serializing_if = "JobPeak::is_p95")]
35        peak: JobPeak,
36    },
37    /// Too few runs of the job for `Job` but enough of its profile: the larger of the profile's p95 peak and the
38    /// job's own largest, plus the margin.
39    Profile {
40        /// How many runs of the profile.
41        runs: u64,
42    },
43    /// No run of the job yet but enough of its profile: the profile's largest peak plus the margin, since a new
44    /// job may be its heaviest.
45    ProfileMax {
46        /// How many runs of the profile.
47        runs: u64,
48    },
49    /// A run of the job among its recent runs went past what it reserved: the job's largest peak plus the margin.
50    Overran {
51        /// When the job is predicted from its p95 again at the latest: sooner once that run leaves its recent runs.
52        until: u64,
53    },
54    /// The cap: admission by caps, or too few runs of the job and of its profile.
55    Cap,
56    /// The cap: a run of the job ran out of memory at its cap or was killed for memory, and fewer than 3 of its runs
57    /// since succeeded clear of it.
58    Unpredictable {
59        /// When the job may be predicted again at the latest: sooner after 3 clear runs.
60        until: u64,
61    },
62    /// The cap: measured admission had no recent sample of the slice's memory to check.
63    Unmeasured,
64}
65
66/// Which of a job's own peaks `Basis::Job` goes by.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum JobPeak {
70    /// Their p95.
71    #[default]
72    P95,
73    /// Their largest: fewer than `[sizing] min_jobs` runs.
74    Largest,
75}
76
77impl JobPeak {
78    #[expect(
79        clippy::trivially_copy_pass_by_ref,
80        reason = "serde's skip_serializing_if passes a reference"
81    )]
82    fn is_p95(&self) -> bool {
83        *self == JobPeak::P95
84    }
85}
86
87/// How a job's runner was admitted.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Admission {
90    /// The policy it was admitted by.
91    pub policy: AdmissionPolicy,
92    /// What it reserved of the budget: under measured admission the largest prediction of its repository's
93    /// queued jobs, since GitHub may have given it any of them.
94    pub reserved: u64,
95    /// What that went by.
96    pub basis: Basis,
97    /// The budget the other runners, the mirror and any shell left before it started; `None` for an unlimited
98    /// slice.
99    pub free: Option<u64>,
100    /// The slice's memory then, less its inactive page cache (`HostSample::memory`); `None` without a recent sample.
101    pub slice: Option<u64>,
102    /// What it reserved of the CPU budget, under `[host] cpu_overcommit`. Absent without one, and from a daemon
103    /// before CPU admission.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub cpu: Option<CpuAdmission>,
106}
107
108/// What a runner reserved of the CPU budget. CPU in millicores, times in seconds.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub struct CpuAdmission {
111    /// The CPU it reserved: its profile's `cpus` under caps, its predicted average CPU under measured admission.
112    pub reserved: u64,
113    /// What that went by: `Basis::Job`, `Profile`, `ProfileMax` or `Cap`.
114    pub basis: Basis,
115    /// The CPU budget the other runners left before it started.
116    pub free: u64,
117    /// How long its repository's next job waited for CPU that memory and a slot had room for; absent when it
118    /// did not.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub waited: Option<u64>,
121}
122
123impl Admission {
124    /// One line on how the runner was admitted, as `runnerctl job` and the dashboard say it, such as `admitted by
125    /// measured peaks: reserved 1.25G, the p95 peak of its job's last 10 runs + the margin; 10G of the budget was free,
126    /// the slice at 2G`.
127    pub fn line(&self) -> String {
128        let size = |bytes: u64| ByteSize(bytes).to_string();
129        let reserved = size(self.reserved);
130        let why = match self.basis {
131            Basis::Job {
132                runs,
133                peak: JobPeak::P95,
134            } => {
135                format!(
136                    "reserved {reserved}, the p95 peak of its job's last {runs} runs + the margin"
137                )
138            }
139            Basis::Job {
140                runs,
141                peak: JobPeak::Largest,
142            } => {
143                format!(
144                    "reserved {reserved}, the largest peak of its job's last {runs} runs + the margin"
145                )
146            }
147            Basis::Profile { runs } => format!(
148                "reserved {reserved}, from {runs} runs of its profile: its job had too few of its own"
149            ),
150            Basis::ProfileMax { runs } => format!(
151                "reserved {reserved}, the largest peak of {runs} runs of its profile + the margin: its job had no \
152                 runs of its own"
153            ),
154            Basis::Overran { .. } => format!(
155                "reserved {reserved}, its job's largest peak + the margin: a run of it went past what it reserved \
156                 lately"
157            ),
158            Basis::Cap if self.policy == AdmissionPolicy::Caps => {
159                format!("reserved its {reserved} cap")
160            }
161            Basis::Cap => format!("reserved its {reserved} cap: too few runs to predict from"),
162            Basis::Unpredictable { .. } => format!(
163                "reserved its {reserved} cap: a run of its job ran out of memory at its cap or was killed for memory lately"
164            ),
165            Basis::Unmeasured => {
166                format!("reserved its {reserved} cap: no recent sample of the slice's memory")
167            }
168        };
169        let policy = match self.policy {
170            AdmissionPolicy::Caps => "caps",
171            AdmissionPolicy::Measured => "measured peaks",
172        };
173        let mut line = format!("admitted by {policy}: {why}");
174        if let Some(free) = self.free {
175            let _ = write!(line, "; {} of the budget was free", size(free));
176        }
177        if let Some(slice) = self.slice {
178            let _ = write!(line, ", the slice at {}", size(slice));
179        }
180        if let Some(cpu) = self.cpu {
181            let _ = write!(line, ". {}", cpu.line());
182        }
183        line
184    }
185}
186
187impl CpuAdmission {
188    /// One sentence on what the runner reserved of the CPU budget, as `Admission::line` ends, such as `Reserved 2.5
189    /// CPUs, the p95 average of 10 runs of its job; 20 CPUs of the budget were free; it waited 1m 30s for CPU`.
190    pub fn line(&self) -> String {
191        let reserved = cpus(self.reserved);
192        let why = match self.basis {
193            Basis::Job {
194                runs,
195                peak: JobPeak::P95,
196            } => format!("the p95 average of {runs} runs of its job"),
197            Basis::Job {
198                runs,
199                peak: JobPeak::Largest,
200            } => format!("the largest average of {runs} runs of its job"),
201            Basis::Profile { runs } => {
202                format!("from {runs} runs of its profile: its job had too few of its own")
203            }
204            Basis::ProfileMax { runs } => format!(
205                "the largest average of {runs} runs of its profile: its job had no runs of its own"
206            ),
207            Basis::Cap
208            | Basis::Overran { .. }
209            | Basis::Unpredictable { .. }
210            | Basis::Unmeasured => "its profile's cpus".to_owned(),
211        };
212        let mut line = format!(
213            "Reserved {reserved}, {why}; {} of the budget were free",
214            cpus(self.free)
215        );
216        if let Some(waited) = self.waited {
217            let _ = write!(line, "; it waited {} for CPU", super::duration(waited));
218        }
219        line
220    }
221}
222
223/// Millicores as a number of CPUs to a tenth, rounded up: `2.5`, `32`.
224pub fn cores(millicores: u64) -> String {
225    tenths(millicores.div_ceil(100))
226}
227
228/// Millicores as `cores` writes them, to a tenth rounded down, so free CPU never reads as more than it is.
229pub(super) fn free_cores(millicores: u64) -> String {
230    tenths(millicores / 100)
231}
232
233fn tenths(tenths: u64) -> String {
234    if tenths.is_multiple_of(10) {
235        (tenths / 10).to_string()
236    } else {
237        format!("{}.{}", tenths / 10, tenths % 10)
238    }
239}
240
241/// Millicores as `2.5 CPUs` or `1 CPU`, to a tenth rounded up.
242pub fn cpus(millicores: u64) -> String {
243    match cores(millicores).as_str() {
244        "1" => "1 CPU".to_owned(),
245        number => format!("{number} CPUs"),
246    }
247}
248
249// The wire format is JSON, which only the host and client features read.
250#[cfg(all(test, any(feature = "host", feature = "client")))]
251mod tests;