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 went past what it reserved lately: the job's largest peak plus the margin.
50    Overran {
51        /// When the job is predicted from its p95 again.
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 lately.
57    Unpredictable {
58        /// When the job may be predicted again.
59        until: u64,
60    },
61    /// The cap: measured admission had no recent sample of the slice's memory to check.
62    Unmeasured,
63}
64
65/// Which of a job's own peaks `Basis::Job` goes by.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum JobPeak {
69    /// Their p95.
70    #[default]
71    P95,
72    /// Their largest: fewer than `[sizing] min_jobs` runs.
73    Largest,
74}
75
76impl JobPeak {
77    #[expect(
78        clippy::trivially_copy_pass_by_ref,
79        reason = "serde's skip_serializing_if passes a reference"
80    )]
81    fn is_p95(&self) -> bool {
82        *self == JobPeak::P95
83    }
84}
85
86/// How a job's runner was admitted.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Admission {
89    /// The policy it was admitted by.
90    pub policy: AdmissionPolicy,
91    /// What it reserved of the budget: under measured admission the largest prediction of its repository's
92    /// queued jobs, since GitHub may have given it any of them.
93    pub reserved: u64,
94    /// What that went by.
95    pub basis: Basis,
96    /// The budget the other runners, the mirror and any shell left before it started; `None` for an unlimited
97    /// slice.
98    pub free: Option<u64>,
99    /// The slice's memory then, less its inactive page cache (`HostSample::memory`); `None` without a recent sample.
100    pub slice: Option<u64>,
101    /// What it reserved of the CPU budget, under `[host] cpu_overcommit`. Absent without one, and from a daemon
102    /// before CPU admission.
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 10 runs of its job + 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!("reserved {reserved}, the p95 peak of {runs} runs of its job + the margin")
135            }
136            Basis::Job {
137                runs,
138                peak: JobPeak::Largest,
139            } => {
140                format!(
141                    "reserved {reserved}, the largest peak of {runs} runs of its job + the margin"
142                )
143            }
144            Basis::Profile { runs } => format!(
145                "reserved {reserved}, from {runs} runs of its profile: its job had too few of its own"
146            ),
147            Basis::ProfileMax { runs } => format!(
148                "reserved {reserved}, the largest peak of {runs} runs of its profile + the margin: its job had no \
149                 runs of its own"
150            ),
151            Basis::Overran { .. } => format!(
152                "reserved {reserved}, its job's largest peak + the margin: a run of it went past what it reserved \
153                 lately"
154            ),
155            Basis::Cap if self.policy == AdmissionPolicy::Caps => {
156                format!("reserved its {reserved} cap")
157            }
158            Basis::Cap => format!("reserved its {reserved} cap: too few runs to predict from"),
159            Basis::Unpredictable { .. } => format!(
160                "reserved its {reserved} cap: a run of its job ran out of memory at its cap or was killed for memory lately"
161            ),
162            Basis::Unmeasured => {
163                format!("reserved its {reserved} cap: no recent sample of the slice's memory")
164            }
165        };
166        let policy = match self.policy {
167            AdmissionPolicy::Caps => "caps",
168            AdmissionPolicy::Measured => "measured peaks",
169        };
170        let mut line = format!("admitted by {policy}: {why}");
171        if let Some(free) = self.free {
172            let _ = write!(line, "; {} of the budget was free", size(free));
173        }
174        if let Some(slice) = self.slice {
175            let _ = write!(line, ", the slice at {}", size(slice));
176        }
177        if let Some(cpu) = self.cpu {
178            let _ = write!(line, ". {}", cpu.line());
179        }
180        line
181    }
182}
183
184impl CpuAdmission {
185    /// One sentence on what the runner reserved of the CPU budget, as `Admission::line` ends, such as `Reserved 2.5
186    /// CPUs, the p95 average of 10 runs of its job; 20 CPUs of the budget were free; it waited 1m30s for CPU`.
187    pub fn line(&self) -> String {
188        let reserved = cpus(self.reserved);
189        let why = match self.basis {
190            Basis::Job {
191                runs,
192                peak: JobPeak::P95,
193            } => format!("the p95 average of {runs} runs of its job"),
194            Basis::Job {
195                runs,
196                peak: JobPeak::Largest,
197            } => format!("the largest average of {runs} runs of its job"),
198            Basis::Profile { runs } => {
199                format!("from {runs} runs of its profile: its job had too few of its own")
200            }
201            Basis::ProfileMax { runs } => format!(
202                "the largest average of {runs} runs of its profile: its job had no runs of its own"
203            ),
204            Basis::Cap
205            | Basis::Overran { .. }
206            | Basis::Unpredictable { .. }
207            | Basis::Unmeasured => "its profile's cpus".to_owned(),
208        };
209        let mut line = format!(
210            "Reserved {reserved}, {why}; {} of the budget were free",
211            cpus(self.free)
212        );
213        if let Some(waited) = self.waited {
214            let _ = write!(line, "; it waited {} for CPU", wait(waited));
215        }
216        line
217    }
218}
219
220/// Millicores as a number of CPUs to a tenth, rounded up: `2.5`, `32`.
221pub fn cores(millicores: u64) -> String {
222    let tenths = millicores.div_ceil(100);
223    if tenths.is_multiple_of(10) {
224        (tenths / 10).to_string()
225    } else {
226        format!("{}.{}", tenths / 10, tenths % 10)
227    }
228}
229
230/// Millicores as `2.5 CPUs` or `1 CPU`, to a tenth rounded up.
231pub fn cpus(millicores: u64) -> String {
232    match cores(millicores).as_str() {
233        "1" => "1 CPU".to_owned(),
234        number => format!("{number} CPUs"),
235    }
236}
237
238/// Seconds as `45s`, `1m30s` or `2h05m`.
239pub(super) fn wait(secs: u64) -> String {
240    match secs {
241        0..60 => format!("{secs}s"),
242        60..3600 => format!("{}m{:02}s", secs / 60, secs % 60),
243        _ => format!("{}h{:02}m", secs / 3600, secs % 3600 / 60),
244    }
245}
246
247// The wire format is JSON, which only the host and client features read.
248#[cfg(all(test, any(feature = "host", feature = "client")))]
249mod tests;