runnerd_core/api/budget.rs
1//! What the memory budget means as every client shows it: its line, what is reserved and free of it, what of it no
2//! runner may start in, and the parts a bar draws. Plain derivations of [`Status`]; clients only lay them out.
3
4use super::{AdmissionPolicy, Budget, HostMemory, Status, percent};
5use crate::config::ByteSize;
6
7/// A figure and the words that follow it, such as `8G` and `of 24G reserved in runnerd.slice`, for a client that
8/// sets the figure apart.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Reading {
11 /// Such as `8G`.
12 pub figure: String,
13 /// What follows it, such as `of 24G reserved in runnerd.slice`.
14 pub text: String,
15}
16
17impl Reading {
18 /// The figure and its words as one line.
19 pub fn line(&self) -> String {
20 format!("{} {}", self.figure, self.text)
21 }
22}
23
24impl Budget {
25 /// What is reserved of what, as every client shows it: `8G` `of 24G reserved in runnerd.slice`, or without
26 /// `MemoryMax` `8G` `reserved in runnerd.slice, which has no MemoryMax`; how runners are admitted
27 /// (`Budget::admission_note`) follows the slice.
28 pub fn memory_reading(&self) -> Reading {
29 let of = self
30 .memory_max
31 .map_or_else(String::new, |max| format!("of {} ", ByteSize(max)));
32 let mut text = format!("{of}reserved in {}", self.slice);
33 if let Some(note) = self.admission_note() {
34 text = format!("{text} {note}");
35 }
36 if self.memory_max.is_none() {
37 text.push_str(", which has no MemoryMax");
38 }
39 Reading {
40 figure: ByteSize(self.memory_in_use).to_string(),
41 text,
42 }
43 }
44
45 /// The budget's clauses in the order clients show them: memory (`Budget::memory_reading`), then CPU
46 /// (`CpuBudget::line`) and the host (`Budget::host_line`) when the daemon reports them.
47 pub fn clauses(&self) -> Vec<String> {
48 let mut clauses = vec![self.memory_reading().line()];
49 clauses.extend(self.cpu.map(|cpu| cpu.line()));
50 clauses.extend(self.host_line());
51 clauses
52 }
53
54 /// The host's line as clients show it, such as `host: 31G of 64G available, 1.5% stalled for memory; leaves the
55 /// slice 40G`; when that is less room than `MemoryMax` leaves (`Budget::host_withheld`), `…; leaves the slice 25G
56 /// of its 46G, so 6.5G is free`, and without `MemoryMax`, where the host alone bounds it, `…; leaves the slice
57 /// 25G, so 6.5G is free`; while it stalls, `…; stalling, so no runner starts`. While the slice is held
58 /// (`Budget::held`), nothing is free, so it says no figure of it. With programs outside the slice large enough to
59 /// name, `…; programs outside CI hold 30G, most java 9.3G` (`HostMemory::outside_line`). `None` without a recent
60 /// reading of the host.
61 pub fn host_line(&self) -> Option<String> {
62 let host = self.host.as_ref()?;
63 let mut line = format!("host: {}", host.memory.available_line());
64 if let Some(full) = host.memory.full.filter(|full| full.avg10 > 0) {
65 line = format!("{line}, {} stalled for memory", percent(full.avg10));
66 }
67 let free = match host.spare {
68 0 => "nothing".to_owned(),
69 spare => ByteSize(spare).to_string(),
70 };
71 let limit = ByteSize(host.limit);
72 line = if host.stalled {
73 format!("{line}; stalling, so no runner starts")
74 } else if self.closed() {
75 format!("{line}; leaves the slice {limit}")
76 } else {
77 match self.memory_max {
78 Some(max) if self.host_withheld().is_some() => format!(
79 "{line}; leaves the slice {limit} of its {}, so {free} is free",
80 ByteSize(max)
81 ),
82 Some(_) => format!("{line}; leaves the slice {limit}"),
83 None => format!("{line}; leaves the slice {limit}, so {free} is free"),
84 }
85 };
86 if !host.memory.holders.is_empty() {
87 line = format!("{line}; {}", host.memory.outside_line());
88 }
89 Some(line)
90 }
91
92 /// Whether measured admission holds the slice at its high-water mark (`Budget::held`), so no runner starts and
93 /// nothing is free.
94 fn closed(&self) -> bool {
95 self.held && self.admission == AdmissionPolicy::Measured
96 }
97
98 /// What the host's memory holds back of the room `MemoryMax` leaves beside what runs, in bytes: that room less
99 /// what the host has to spare (`HostBudget::spare`), when the host leaves less. `None` while it leaves as much,
100 /// while it stalls (its line says no runner starts), without a recent reading of it, and without `MemoryMax`.
101 pub fn host_withheld(&self) -> Option<u64> {
102 let host = self.host.as_ref().filter(|host| !host.stalled)?;
103 let remaining = self.remaining()?;
104 (host.spare < remaining).then(|| remaining - host.spare)
105 }
106
107 /// The budget as one line, its clauses joined by `; `.
108 pub fn line(&self) -> String {
109 self.clauses().join("; ")
110 }
111
112 /// What `MemoryMax` leaves beside every running container's reservation, in bytes: what a new runner must fit
113 /// in. `None` without `MemoryMax`.
114 pub fn remaining(&self) -> Option<u64> {
115 self.memory_max
116 .map(|max| max.saturating_sub(self.memory_in_use))
117 }
118}
119
120impl HostMemory {
121 /// What the host has available of its memory, as the budget's host clause and doctor say it: `31G of 64G
122 /// available`.
123 pub fn available_line(&self) -> String {
124 format!(
125 "{} of {} available",
126 ByteSize(self.available),
127 ByteSize(self.total)
128 )
129 }
130}
131
132impl Status {
133 /// The budget as a bar draws it: a block per slot as large as its cap (under measured admission, a block per
134 /// runner as large as what it reserves), the mirror, seed containers and shells, then what is free and what of
135 /// the room no runner may start in; and the whole the blocks are scaled against.
136 pub fn budget_parts(&self) -> BudgetParts {
137 let budget = &self.budget;
138 let slots: Vec<SlotPart> = self
139 .profiles
140 .iter()
141 .enumerate()
142 .flat_map(|(profile, status)| {
143 status.slots.iter().filter_map(move |slot| {
144 let memory = match budget.admission {
145 AdmissionPolicy::Caps => match &slot.runner {
146 Some(runner) if runner.reserved > 0 => runner.reserved,
147 _ => status.memory,
148 },
149 AdmissionPolicy::Measured => slot.runner.as_ref()?.reserved,
150 };
151 Some(SlotPart {
152 profile,
153 slot: slot.slot,
154 memory,
155 reserved: slot.runner.is_some(),
156 })
157 })
158 })
159 .collect();
160 let mirror = (budget.mirror_memory > 0).then_some(HeldPart {
161 kind: HeldKind::Mirror,
162 memory: budget.mirror_memory,
163 });
164 let seeds = budget.seed_memory.iter().map(|seed| HeldPart {
165 kind: HeldKind::Seed {
166 profile: seed.profile.clone(),
167 },
168 memory: seed.memory,
169 });
170 let shells = budget.shell_memory.iter().map(|shell| HeldPart {
171 kind: HeldKind::Shell { job: shell.job },
172 memory: shell.memory,
173 });
174 let held: Vec<HeldPart> = mirror.into_iter().chain(seeds).chain(shells).collect();
175 let drawn: u64 = slots
176 .iter()
177 .map(|part| part.memory)
178 .chain(held.iter().map(|part| part.memory))
179 .sum();
180 let whole = budget.memory_max.unwrap_or(drawn).max(drawn);
181 let unclaimed: u64 = slots
182 .iter()
183 .filter(|part| !part.reserved)
184 .map(|part| part.memory)
185 .sum();
186 let (free, closed) = budget.room_parts(drawn, unclaimed);
187 BudgetParts {
188 slots,
189 held,
190 free,
191 closed,
192 whole,
193 }
194 }
195}
196
197impl Budget {
198 /// The bar's room past `drawn` bytes of blocks, of which `unclaimed` are idle slots' caps: what is free, and what
199 /// of it no runner may start in.
200 fn room_parts(&self, drawn: u64, unclaimed: u64) -> (Option<FreePart>, Option<ClosedPart>) {
201 let Some(max) = self.memory_max else {
202 return (None, None);
203 };
204 let room = max.saturating_sub(drawn);
205 // While no runner starts, all the room is closed.
206 let shut = if self.closed() {
207 Some((
208 ClosedKind::Held,
209 "closed: the slice is past 90% of its MemoryMax, so no runner starts",
210 ))
211 } else if self.host.as_ref().is_some_and(|host| host.stalled) {
212 Some((
213 ClosedKind::Stalled,
214 "closed: the host's programs stall waiting for memory, so no runner starts",
215 ))
216 } else {
217 None
218 };
219 if let Some((kind, text)) = shut {
220 let closed = (room > 0).then(|| ClosedPart {
221 memory: room,
222 kind,
223 reading: Reading {
224 figure: ByteSize(room).to_string(),
225 text: text.to_owned(),
226 },
227 });
228 return (None, closed);
229 }
230 let withheld = self.host_withheld();
231 // The host's part ends the bar, within the free room: under caps, idle slots' blocks stay drawn though the
232 // host may hold their room too.
233 let host = withheld
234 .map(|withheld| withheld.min(room))
235 .filter(|&memory| memory > 0);
236 let (host_limit, spare) = self
237 .host
238 .as_ref()
239 .map_or((0, 0), |host| (host.limit, host.spare));
240 let memory = room - host.unwrap_or(0);
241 let text = match (unclaimed, withheld) {
242 (0, None) => format!("of {} free", ByteSize(max)),
243 (_, None) => format!(
244 "of {} free past every slot's cap; {} is free beside what runs",
245 ByteSize(max),
246 ByteSize(self.remaining().unwrap_or(0))
247 ),
248 (0, Some(_)) => format!(
249 "free; the host leaves the slice {} of its {}",
250 ByteSize(host_limit),
251 ByteSize(max)
252 ),
253 (_, Some(_)) => format!(
254 "free past every slot's cap; {} is free beside what runs, as the host leaves the slice {} of its {}",
255 ByteSize(spare),
256 ByteSize(host_limit),
257 ByteSize(max)
258 ),
259 };
260 let free = (memory > 0).then(|| FreePart {
261 memory,
262 reading: Reading {
263 figure: ByteSize(memory).to_string(),
264 text,
265 },
266 });
267 let host = host.map(|memory| ClosedPart {
268 memory,
269 kind: ClosedKind::Host,
270 reading: Reading {
271 figure: ByteSize(memory).to_string(),
272 text: format!(
273 "{} for its other programs and page cache, which leave the slice {}",
274 ClosedKind::Host.name(),
275 ByteSize(host_limit)
276 ),
277 },
278 });
279 (free, host)
280 }
281}
282
283/// The budget as a bar draws it, from `Status::budget_parts`.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct BudgetParts {
286 /// A block per slot, in config order: every slot under caps, only those with a runner under measured admission.
287 pub slots: Vec<SlotPart>,
288 /// The mirror, each seed container, then each shell open in a record.
289 pub held: Vec<HeldPart>,
290 /// What `MemoryMax` leaves past every block, less what is closed; `None` without `MemoryMax`, without room past
291 /// the blocks and the closed part, and while no runner starts: the slice is held (`Budget::held`) or the host
292 /// stalls.
293 pub free: Option<FreePart>,
294 /// What of the room past every block no runner may start in, drawn after what is free: all of it while the slice
295 /// is held or the host stalls, else what the host's memory holds back (`Budget::host_withheld`); `None` while neither closes any.
296 pub closed: Option<ClosedPart>,
297 /// What the blocks are scaled against: `MemoryMax`, or the blocks' sum when larger or without it.
298 pub whole: u64,
299}
300
301/// A slot's block of the budget.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub struct SlotPart {
304 /// Its profile's index in `Status::profiles`.
305 pub profile: usize,
306 /// The slot, 1-based.
307 pub slot: usize,
308 /// Its size in bytes: what its runner reserves, or a free slot's cap.
309 pub memory: u64,
310 /// Whether a runner reserves it; a free slot's block under caps is room its next runner takes, still free.
311 pub reserved: bool,
312}
313
314/// Memory the budget holds for something other than a slot.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct HeldPart {
317 /// What holds it.
318 pub kind: HeldKind,
319 /// Its cap in bytes.
320 pub memory: u64,
321}
322
323impl HeldPart {
324 /// Its name under its block: `mirror`, `node seed` or `job 42 shell`.
325 pub fn name(&self) -> String {
326 match &self.kind {
327 HeldKind::Mirror => "mirror".to_owned(),
328 HeldKind::Seed { profile } => format!("{profile} seed"),
329 HeldKind::Shell { job } => format!("job {job} shell"),
330 }
331 }
332
333 /// What holds it, after its size: `held by the mirror`, `held by node's seed container` or `held by the shell
334 /// in job 42's record`.
335 pub fn holder(&self) -> String {
336 match &self.kind {
337 HeldKind::Mirror => "held by the mirror".to_owned(),
338 HeldKind::Seed { profile } => format!("held by {profile}'s seed container"),
339 HeldKind::Shell { job } => format!("held by the shell in job {job}'s record"),
340 }
341 }
342}
343
344/// What holds a part of the budget beside the slots.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum HeldKind {
347 /// The registry mirror.
348 Mirror,
349 /// A profile's seed container, while it reseeds.
350 Seed {
351 /// The profile it reseeds.
352 profile: String,
353 },
354 /// A shell open in a job's record.
355 Shell {
356 /// The job.
357 job: u64,
358 },
359}
360
361/// What the bar leaves free past every block, less what the host holds back.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct FreePart {
364 /// In bytes.
365 pub memory: u64,
366 /// Its figure and what it means, such as `8G` `of 24G free`, or while the host binds `6.52G` `free; the host
367 /// leaves the slice 25.27G of its 46G`.
368 pub reading: Reading,
369}
370
371/// Room past the bar's blocks that no runner may start in.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct ClosedPart {
374 /// In bytes.
375 pub memory: u64,
376 /// What closes it.
377 pub kind: ClosedKind,
378 /// Its figure and what it means, such as `20.73G` `held by the host for its other programs and page cache,
379 /// which leave the slice 25.27G`.
380 pub reading: Reading,
381}
382
383/// What closes room past the bar's blocks.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum ClosedKind {
386 /// The host's other programs and its page cache need it.
387 Host,
388 /// The slice is past 90% of its `MemoryMax` under measured admission (`Budget::held`).
389 Held,
390 /// The host's programs stall waiting for memory (`HostBudget::stalled`).
391 Stalled,
392}
393
394impl ClosedKind {
395 /// Its name under its block or in a key: `held by the host`, `closed past 90%` or `closed while the host
396 /// stalls`.
397 pub fn name(self) -> &'static str {
398 match self {
399 Self::Host => "held by the host",
400 Self::Held => "closed past 90%",
401 Self::Stalled => "closed while the host stalls",
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests;