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.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub cpu: Option<CpuAdmission>,
105}
106
107/// What a runner reserved of the CPU budget. CPU in millicores, times in seconds.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109pub struct CpuAdmission {
110 /// The CPU it reserved: its profile's `cpus` under caps, its predicted average CPU under measured admission.
111 pub reserved: u64,
112 /// What that went by: `Basis::Job`, `Profile`, `ProfileMax` or `Cap`.
113 pub basis: Basis,
114 /// The CPU budget the other runners left before it started.
115 pub free: u64,
116 /// How long its repository's next job waited for CPU that memory and a slot had room for; absent when it
117 /// did not.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub waited: Option<u64>,
120}
121
122impl Admission {
123 /// One line on how the runner was admitted, as `runnerctl job` and the dashboard say it, such as `admitted by
124 /// measured peaks: reserved 1.25G, the p95 peak of its job's last 10 runs + the margin; 10G of the budget was free,
125 /// the slice at 2G`.
126 pub fn line(&self) -> String {
127 let size = |bytes: u64| ByteSize(bytes).to_string();
128 let reserved = size(self.reserved);
129 let why = match self.basis {
130 Basis::Job {
131 runs,
132 peak: JobPeak::P95,
133 } => {
134 format!(
135 "reserved {reserved}, the p95 peak of its job's last {runs} runs + the margin"
136 )
137 }
138 Basis::Job {
139 runs,
140 peak: JobPeak::Largest,
141 } => {
142 format!(
143 "reserved {reserved}, the largest peak of its job's last {runs} runs + the margin"
144 )
145 }
146 Basis::Profile { runs } => format!(
147 "reserved {reserved}, from {runs} runs of its profile: its job had too few of its own"
148 ),
149 Basis::ProfileMax { runs } => format!(
150 "reserved {reserved}, the largest peak of {runs} runs of its profile + the margin: its job had no \
151 runs of its own"
152 ),
153 Basis::Overran { .. } => format!(
154 "reserved {reserved}, its job's largest peak + the margin: a run of it went past what it reserved \
155 lately"
156 ),
157 Basis::Cap if self.policy == AdmissionPolicy::Caps => {
158 format!("reserved its {reserved} cap")
159 }
160 Basis::Cap => format!("reserved its {reserved} cap: too few runs to predict from"),
161 Basis::Unpredictable { .. } => format!(
162 "reserved its {reserved} cap: a run of its job ran out of memory at its cap or was killed for memory lately"
163 ),
164 Basis::Unmeasured => {
165 format!("reserved its {reserved} cap: no recent sample of the slice's memory")
166 }
167 };
168 let policy = match self.policy {
169 AdmissionPolicy::Caps => "caps",
170 AdmissionPolicy::Measured => "measured peaks",
171 };
172 let mut line = format!("admitted by {policy}: {why}");
173 if let Some(free) = self.free {
174 let _ = write!(line, "; {} of the budget was free", size(free));
175 }
176 if let Some(slice) = self.slice {
177 let _ = write!(line, ", the slice at {}", size(slice));
178 }
179 if let Some(cpu) = self.cpu {
180 let _ = write!(line, ". {}", cpu.line());
181 }
182 line
183 }
184}
185
186impl CpuAdmission {
187 /// One sentence on what the runner reserved of the CPU budget, as `Admission::line` ends, such as `Reserved 2.5
188 /// CPUs, the p95 average of 10 runs of its job; 20 CPUs of the budget were free; it waited 1m 30s for CPU`.
189 pub fn line(&self) -> String {
190 let reserved = cpus(self.reserved);
191 let why = match self.basis {
192 Basis::Job {
193 runs,
194 peak: JobPeak::P95,
195 } => format!("the p95 average of {runs} runs of its job"),
196 Basis::Job {
197 runs,
198 peak: JobPeak::Largest,
199 } => format!("the largest average of {runs} runs of its job"),
200 Basis::Profile { runs } => {
201 format!("from {runs} runs of its profile: its job had too few of its own")
202 }
203 Basis::ProfileMax { runs } => format!(
204 "the largest average of {runs} runs of its profile: its job had no runs of its own"
205 ),
206 Basis::Cap
207 | Basis::Overran { .. }
208 | Basis::Unpredictable { .. }
209 | Basis::Unmeasured => "its profile's cpus".to_owned(),
210 };
211 let mut line = format!(
212 "Reserved {reserved}, {why}; {} of the budget were free",
213 cpus(self.free)
214 );
215 if let Some(waited) = self.waited {
216 let _ = write!(line, "; it waited {} for CPU", super::duration(waited));
217 }
218 line
219 }
220}
221
222/// Millicores as a number of CPUs to a tenth, rounded up: `2.5`, `32`.
223pub fn cores(millicores: u64) -> String {
224 tenths(millicores.div_ceil(100))
225}
226
227/// Millicores as `cores` writes them, to a tenth rounded down, so free CPU never reads as more than it is.
228pub(super) fn free_cores(millicores: u64) -> String {
229 tenths(millicores / 100)
230}
231
232fn tenths(tenths: u64) -> String {
233 if tenths.is_multiple_of(10) {
234 (tenths / 10).to_string()
235 } else {
236 format!("{}.{}", tenths / 10, tenths % 10)
237 }
238}
239
240/// Millicores as `2.5 CPUs` or `1 CPU`, to a tenth rounded up.
241pub fn cpus(millicores: u64) -> String {
242 match cores(millicores).as_str() {
243 "1" => "1 CPU".to_owned(),
244 number => format!("{number} CPUs"),
245 }
246}
247
248// The wire format is JSON, which only the host and client features read.
249#[cfg(all(test, any(feature = "host", feature = "client")))]
250mod tests;