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;