1use super::{
6 Advice, AutoPlan, JobSizing, PAGE_CACHE_ROOM, Peaks, ProfileSizing, Sizing, SizingChange,
7 SizingHold, Starts,
8};
9use crate::api::{Check, CheckStatus, Tone, Toned};
10use crate::config::ByteSize;
11
12pub const GRANULE: u64 = 256 << 20;
14
15pub const SAMPLE_INTERVAL: u64 = 2;
18
19pub fn need(peak: u64, margin: u32) -> u64 {
23 let room = (peak.saturating_mul(u64::from(margin)) / 100).max(PAGE_CACHE_ROOM);
24 peak.saturating_add(room)
25 .div_ceil(GRANULE)
26 .saturating_mul(GRANULE)
27 .max(GRANULE)
28}
29
30pub fn sampled_note(interval: u64) -> String {
33 format!("memory peaks are sampled every {interval} s; spikes shorter than that do not show")
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Need {
39 pub memory: u64,
41 pub reason: String,
43}
44
45impl Sizing {
46 pub fn need(&self, peaks: &Peaks, advice: Advice) -> Need {
48 let memory = match advice {
49 Advice::Keep { memory }
50 | Advice::Lower { memory, .. }
51 | Advice::Raise { memory, .. }
52 | Advice::SliceTooSmall { memory } => memory,
53 Advice::TooFew { .. } => need(peaks.memory.max, self.margin),
54 };
55 Need {
56 memory,
57 reason: self.need_reason(peaks),
58 }
59 }
60
61 pub(crate) fn need_reason(&self, peaks: &Peaks) -> String {
64 if peaks.memory.max * u64::from(self.margin) / 100 >= PAGE_CACHE_ROOM {
65 format!("its largest peak + {}%", self.margin)
66 } else {
67 format!(
68 "its largest peak + {} for page cache",
69 ByteSize(PAGE_CACHE_ROOM)
70 )
71 }
72 }
73
74 pub fn method_line(&self) -> String {
78 format!(
79 "peaks are sampled every {} s, so a shorter spike goes unseen: advice sizes for the largest + {}%, at \
80 least {} for page cache, rounded up to {}, once a profile ran {} jobs",
81 self.sample_interval,
82 self.margin,
83 ByteSize(PAGE_CACHE_ROOM),
84 ByteSize(GRANULE),
85 self.min_jobs
86 )
87 }
88
89 pub fn method_parts(&self) -> Vec<String> {
92 vec![
93 format!("peaks sampled every {} s", self.sample_interval),
94 format!(
95 "+{}%, at least {}, in {} steps",
96 self.margin,
97 ByteSize(PAGE_CACHE_ROOM),
98 ByteSize(GRANULE)
99 ),
100 format!("advice after {} jobs", self.min_jobs),
101 ]
102 }
103
104 pub fn covered(&self) -> u64 {
106 self.until.saturating_sub(self.since)
107 }
108
109 pub fn doctor_check(&self) -> Check {
112 let changes: Vec<_> = self
113 .profiles
114 .iter()
115 .filter(|profile| {
116 profile.advice.change().is_some()
117 || matches!(profile.advice, Advice::SliceTooSmall { .. })
118 })
119 .collect();
120 let raise = changes
121 .iter()
122 .any(|profile| profile.advice.tone() == Tone::Bad);
123 let detail = if changes.is_empty() {
124 let too_few = self
125 .profiles
126 .iter()
127 .any(|profile| matches!(profile.advice, Advice::TooFew { .. }));
128 if too_few {
129 format!(
130 "some profiles ran fewer than {} jobs; runnerctl sizing shows their peaks",
131 self.min_jobs
132 )
133 } else {
134 format!(
135 "every profile's memory fits its jobs' largest peak + {}%, at least {} for page cache",
136 self.margin,
137 ByteSize(PAGE_CACHE_ROOM)
138 )
139 }
140 } else {
141 changes
142 .iter()
143 .map(|profile| format!("{}: {}", profile.name, self.advice_line(profile)))
144 .collect::<Vec<_>>()
145 .join("; ")
146 };
147 Check {
148 name: "sizing".to_owned(),
149 status: if raise {
150 CheckStatus::Warn
151 } else {
152 CheckStatus::Info
153 },
154 detail,
155 fix: (!changes.is_empty()).then(|| {
156 changes
157 .iter()
158 .map(|profile| match profile.advice {
159 Advice::SliceTooSmall { .. } => format!(
160 "raise MemoryMax in the slice's unit file, sudo systemctl daemon-reload, then: \
161 runnerctl sizing apply {}",
162 profile.name
163 ),
164 _ => format!("runnerctl sizing apply {}", profile.name),
165 })
166 .collect::<Vec<_>>()
167 .join("\n")
168 }),
169 }
170 }
171}
172
173impl Advice {
174 pub fn tone(&self) -> Tone {
177 match self {
178 Advice::Raise { .. } | Advice::SliceTooSmall { .. } => Tone::Bad,
179 Advice::Lower { .. } => Tone::Warn,
180 Advice::Keep { .. } => Tone::Good,
181 Advice::TooFew { .. } => Tone::Quiet,
182 }
183 }
184
185 pub fn word(&self) -> String {
187 match *self {
188 Advice::TooFew { .. } => "too few".to_owned(),
189 Advice::Keep { .. } => "keep".to_owned(),
190 Advice::SliceTooSmall { .. } => "grow slice".to_owned(),
191 Advice::Lower { memory, slots } | Advice::Raise { memory, slots } => {
192 format!("{} × {slots}", ByteSize(memory))
193 }
194 }
195 }
196}
197
198impl JobSizing {
199 pub fn needs(&self) -> Toned {
202 let (text, tone) = match self.advice {
203 Advice::TooFew { .. } => (
204 format!("max {}", ByteSize(self.peaks.memory.max)),
205 Tone::Quiet,
206 ),
207 Advice::Raise { memory, .. } | Advice::SliceTooSmall { memory } => {
208 (format!("{} > cap", ByteSize(memory)), Tone::Bad)
209 }
210 Advice::Keep { memory } | Advice::Lower { memory, .. } => {
211 (ByteSize(memory).to_string(), Tone::Neutral)
212 }
213 };
214 Toned { text, tone }
215 }
216}
217
218impl Starts {
219 pub fn total(&self) -> u32 {
221 self.warm + self.cold
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum ProfilePlan<'plan> {
228 Next {
230 change: &'plan SizingChange,
232 blocked: bool,
234 },
235 Held {
237 hold: &'plan SizingHold,
239 },
240 LowerHeld {
242 hold: &'plan SizingHold,
244 },
245 Nothing,
247}
248
249impl AutoPlan {
250 pub fn profile_plan(&self, profile: &str, now: u64) -> ProfilePlan<'_> {
252 if let Some(change) = self.next.iter().find(|change| change.profile == profile) {
253 return ProfilePlan::Next {
254 change,
255 blocked: self.blocked.is_some(),
256 };
257 }
258 match self.held.iter().find(|hold| hold.profile == profile) {
259 Some(hold) if now < hold.until => ProfilePlan::Held { hold },
260 Some(hold) if now < hold.lowered_after() => ProfilePlan::LowerHeld { hold },
261 _ => ProfilePlan::Nothing,
262 }
263 }
264}
265
266impl ProfilePlan<'_> {
267 pub fn until(&self) -> Option<u64> {
269 match self {
270 ProfilePlan::Held { hold } => Some(hold.until),
271 ProfilePlan::LowerHeld { hold } => Some(hold.lowered_after()),
272 ProfilePlan::Next { .. } | ProfilePlan::Nothing => None,
273 }
274 }
275
276 pub fn headline(&self, plan: &AutoPlan) -> String {
279 match self {
280 ProfilePlan::Next {
281 change,
282 blocked: true,
283 } => format!("Once that is fixed: {}", change.line()),
284 ProfilePlan::Next { change, .. } => format!(
285 "Next look, within {}: {}",
286 super::period(plan.every),
287 change.line()
288 ),
289 ProfilePlan::Held { .. } => "No change".to_owned(),
290 ProfilePlan::LowerHeld { .. } => "No lowering".to_owned(),
291 ProfilePlan::Nothing => "Nothing to change".to_owned(),
292 }
293 }
294
295 pub fn reason(&self, sizing: &Sizing, profile: &ProfileSizing) -> String {
298 match self {
299 ProfilePlan::Next { change, .. } => change.reason.clone(),
300 ProfilePlan::Held { .. } => "the interval holds it until then".to_owned(),
301 ProfilePlan::LowerHeld { .. } => "a raise may come before then".to_owned(),
302 ProfilePlan::Nothing => sizing.advice_line(profile),
303 }
304 }
305
306 pub fn changed(&self) -> Option<u64> {
308 match self {
309 ProfilePlan::Held { hold } | ProfilePlan::LowerHeld { hold } => Some(hold.changed),
310 ProfilePlan::Next { .. } | ProfilePlan::Nothing => None,
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests;