Skip to main content

runnerd_core/config/
budget.rs

1//! What a config check says of the memory budget as every client shows it: its verdict, what the admission rule
2//! holds to `MemoryMax` and what that leaves, the blocks a preview draws, and doctor's check. Derived from
3//! [`api::ConfigCheck`] and the [`Config`] it checked; clients only lay them out.
4
5use super::{ByteSize, Config, ConfigError};
6use crate::api::{AdmissionPolicy, Check, CheckStatus, ConfigCheck, Tone};
7
8/// A config check's verdict, from `ConfigCheck::verdict`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Verdict {
11    /// Valid, and what its admission rule holds fits `MemoryMax`.
12    Fits,
13    /// Valid, but the slice has no `MemoryMax`: nothing holds runners to a budget.
14    NoBudget,
15    /// Not valid: what its admission rule holds is more than `MemoryMax`.
16    DoesNotFit,
17    /// Not valid for another reason, given in `ConfigCheck::message`: it does not parse, a setting is wrong, or the
18    /// slice could not be read.
19    Invalid,
20}
21
22impl Verdict {
23    /// As a heading says it: `Fits the budget`, `Valid, with no MemoryMax to fit`, `Does not fit` or `Not valid`.
24    pub fn label(self) -> &'static str {
25        match self {
26            Verdict::Fits => "Fits the budget",
27            Verdict::NoBudget => "Valid, with no MemoryMax to fit",
28            Verdict::DoesNotFit => "Does not fit",
29            Verdict::Invalid => "Not valid",
30        }
31    }
32
33    /// What it means for the reader.
34    pub fn tone(self) -> Tone {
35        match self {
36            Verdict::Fits => Tone::Good,
37            Verdict::NoBudget => Tone::Warn,
38            Verdict::DoesNotFit | Verdict::Invalid => Tone::Bad,
39        }
40    }
41}
42
43/// What a checked config's admission rule holds to `MemoryMax`, from `ConfigCheck::budget`.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct CheckBudget {
46    /// The admission rule it was checked under.
47    pub admission: AdmissionPolicy,
48    /// What the rule must fit in `MemoryMax`, in bytes.
49    pub needed: u64,
50    /// The slice's `MemoryMax`; `None` when unlimited or unknown.
51    pub memory_max: Option<u64>,
52    /// Every slot and the mirror at their caps at once, in bytes, when known.
53    pub memory_caps: Option<u64>,
54}
55
56impl CheckBudget {
57    /// What `needed` is, as a figure's label: `Memory caps` or `Largest runner + mirror`.
58    pub fn needed_label(&self) -> &'static str {
59        match self.admission {
60            AdmissionPolicy::Caps => "Memory caps",
61            AdmissionPolicy::Measured => "Largest runner + mirror",
62        }
63    }
64
65    /// Whether `needed` is more than `MemoryMax`.
66    pub fn over(&self) -> bool {
67        self.memory_max.is_some_and(|max| self.needed > max)
68    }
69
70    /// What `MemoryMax` leaves past `needed`, or how far `needed` goes past it. `None` without `MemoryMax`.
71    pub fn remainder(&self) -> Option<Remainder> {
72        let max = self.memory_max?;
73        Some(if self.over() {
74            Remainder {
75                label: "Over by",
76                memory: self.needed - max,
77            }
78        } else {
79            Remainder {
80                label: "Left",
81                memory: max - self.needed,
82            }
83        })
84    }
85
86    /// Under measured admission, what that means for the slots: `Admission is measured: runners reserve their
87    /// predicted peaks, and slots only bound how many run at once. Every slot at its cap at once would take 40G.`
88    pub fn admission_note(&self) -> Option<String> {
89        if self.admission != AdmissionPolicy::Measured {
90            return None;
91        }
92        let mut note = "Admission is measured: runners reserve their predicted peaks, and slots only bound how many \
93                        run at once."
94            .to_owned();
95        if let Some(caps) = self.memory_caps {
96            note = format!(
97                "{note} Every slot at its cap at once would take {}.",
98                ByteSize(caps)
99            );
100        }
101        Some(note)
102    }
103}
104
105/// What `MemoryMax` leaves past a check's budget, or how far the budget goes past it.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Remainder {
108    /// `Left` or `Over by`.
109    pub label: &'static str,
110    /// In bytes.
111    pub memory: u64,
112}
113
114impl ConfigCheck {
115    /// Its verdict: whether it is valid, and whether its budget fits. With `parsed`, the config it checked, only a
116    /// budget past `MemoryMax` that is the reason it is not valid does not fit; without it, as when the text does not
117    /// parse, a budget the check reports past `MemoryMax`.
118    pub fn verdict(&self, parsed: Option<&Config>) -> Verdict {
119        if self.valid {
120            return if self.memory_max.is_none() {
121                Verdict::NoBudget
122            } else {
123                Verdict::Fits
124            };
125        }
126        let over = match parsed {
127            Some(config) => matches!(
128                config.validate(self.memory_max.map(ByteSize)),
129                Err(ConfigError::OverBudget { .. } | ConfigError::ProfileOverBudget { .. })
130            ),
131            None => self.budget(None).is_some_and(|budget| budget.over()),
132        };
133        if over {
134            Verdict::DoesNotFit
135        } else {
136            Verdict::Invalid
137        }
138    }
139
140    /// What its admission rule holds to `MemoryMax`, when known: from the check, or from a daemon before it said,
141    /// from `parsed`, the config it checked. `None` when neither knows, as when the caps overflow.
142    pub fn budget(&self, parsed: Option<&Config>) -> Option<CheckBudget> {
143        let (admission, needed) = match (self.memory_needed, parsed) {
144            (Some(needed), _) => (self.admission, needed),
145            (None, Some(config)) => (
146                config.host.admission.policy(),
147                config.memory_needed().ok()?.0,
148            ),
149            (None, None) if self.admission == AdmissionPolicy::Caps => {
150                (self.admission, self.memory_caps?)
151            }
152            (None, None) => return None,
153        };
154        Some(CheckBudget {
155            admission,
156            needed,
157            memory_max: self.memory_max,
158            memory_caps: self.memory_caps,
159        })
160    }
161
162    /// Doctor's check of the config the daemon runs with: it passes with its message, or with its
163    /// warnings, or fails with why.
164    pub fn doctor_check(&self) -> Check {
165        if !self.valid {
166            return Check {
167                name: "config".to_owned(),
168                status: CheckStatus::Fail,
169                detail: self.message.clone(),
170                fix: Some("change runnerd.toml or the slice's limits".to_owned()),
171            };
172        }
173        let detail = if self.warnings.is_empty() {
174            self.message.clone()
175        } else {
176            format!(
177                "{}, with warnings: {}",
178                self.message,
179                self.warnings.join("; ")
180            )
181        };
182        Check {
183            name: "config".to_owned(),
184            status: CheckStatus::Pass,
185            detail,
186            fix: None,
187        }
188    }
189}
190
191/// A profile's slots or the mirror, as a budget preview draws it, from `Config::budget_blocks`.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct BudgetBlock {
194    /// What it is: `jvm ×2` or `mirror`.
195    pub label: String,
196    /// In bytes.
197    pub memory: u64,
198    /// The profile's index in the config's order; `None` for the mirror.
199    pub profile: Option<usize>,
200}
201
202impl Config {
203    /// What `admission` holds to `MemoryMax`, a block per profile: under caps, every slot of each profile at its
204    /// cap; under measured, one runner of the largest profile. The mirror with either. `None` when the caps
205    /// overflow, as `Config::memory_caps` refuses them.
206    pub fn budget_blocks(&self, admission: AdmissionPolicy) -> Option<Vec<BudgetBlock>> {
207        let profiles = self
208            .profiles
209            .iter()
210            .enumerate()
211            .map(|(index, (name, profile))| (index, name, profile));
212        let mut blocks: Vec<BudgetBlock> = match admission {
213            AdmissionPolicy::Caps => profiles
214                .map(|(index, name, profile)| {
215                    Some(BudgetBlock {
216                        label: format!("{name} ×{}", profile.slots),
217                        memory: profile.memory.0.checked_mul(u64::from(profile.slots))?,
218                        profile: Some(index),
219                    })
220                })
221                .collect::<Option<_>>()?,
222            AdmissionPolicy::Measured => profiles
223                .max_by_key(|(_, _, profile)| profile.memory.0)
224                .map(|(index, name, profile)| BudgetBlock {
225                    label: format!("{name} ×1"),
226                    memory: profile.memory.0,
227                    profile: Some(index),
228                })
229                .into_iter()
230                .collect(),
231        };
232        if self.caches.mirror.enabled {
233            blocks.push(BudgetBlock {
234                label: "mirror".to_owned(),
235                memory: self.mirror_memory(),
236                profile: None,
237            });
238        }
239        let total = blocks
240            .iter()
241            .try_fold(0u64, |total, block| total.checked_add(block.memory));
242        total.map(|_| blocks)
243    }
244
245    /// Validates the parsed config against `memory_max`, the slice's `MemoryMax`: what `Config::check` answers once
246    /// the text parses and the slice is read, and what doctor checks the config the daemon runs with by.
247    pub fn check_against(&self, memory_max: Option<ByteSize>) -> ConfigCheck {
248        let figures = self
249            .validate(memory_max)
250            .and_then(|warnings| Ok((warnings, self.memory_caps()?, self.memory_needed()?)));
251        match figures {
252            Ok((warnings, total, needed)) => {
253                let message = match (self.host.admission, memory_max) {
254                    (super::Admission::Measured, Some(max)) => format!(
255                        "valid under admission = \"measured\": the largest runner with the mirror needs {needed} \
256                         of the {max} MemoryMax; every slot at its cap at once would take {total}"
257                    ),
258                    _ => format!("valid, memory caps sum to {total}"),
259                };
260                ConfigCheck {
261                    valid: true,
262                    message,
263                    warnings: warnings.iter().map(ToString::to_string).collect(),
264                    admission: self.host.admission.policy(),
265                    memory_caps: Some(total.0),
266                    memory_needed: Some(needed.0),
267                    memory_max: memory_max.map(|max| max.0),
268                    revision: None,
269                }
270            }
271            Err(error) => invalid(error.to_string(), Some(self), memory_max),
272        }
273    }
274}
275
276/// A check that is not valid for `message`, with what `config`, when it parsed, says of its budget.
277pub(super) fn invalid(
278    message: String,
279    config: Option<&Config>,
280    max: Option<ByteSize>,
281) -> ConfigCheck {
282    ConfigCheck {
283        valid: false,
284        message,
285        warnings: Vec::new(),
286        admission: config
287            .map(|config| config.host.admission.policy())
288            .unwrap_or_default(),
289        memory_caps: config
290            .and_then(|config| config.memory_caps().ok())
291            .map(|caps| caps.0),
292        memory_needed: config
293            .and_then(|config| config.memory_needed().ok())
294            .map(|need| need.0),
295        memory_max: max.map(|max| max.0),
296        revision: None,
297    }
298}
299
300#[cfg(test)]
301mod tests;