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 refusal(&self, profile: &str) -> Option<String> {
253 self.refused
254 .iter()
255 .find(|refused| refused.profile == profile)
256 .map(|refused| format!("No CPU raise: {}", refused.reason))
257 }
258
259 pub fn profile_plan(&self, profile: &str, now: u64) -> ProfilePlan<'_> {
261 if let Some(change) = self.next.iter().find(|change| change.profile == profile) {
262 return ProfilePlan::Next {
263 change,
264 blocked: self.blocked.is_some(),
265 };
266 }
267 match self.held.iter().find(|hold| hold.profile == profile) {
268 Some(hold) if now < hold.until => ProfilePlan::Held { hold },
269 Some(hold) if now < hold.lowered_after() => ProfilePlan::LowerHeld { hold },
270 _ => ProfilePlan::Nothing,
271 }
272 }
273}
274
275impl ProfilePlan<'_> {
276 pub fn until(&self) -> Option<u64> {
278 match self {
279 ProfilePlan::Held { hold } => Some(hold.until),
280 ProfilePlan::LowerHeld { hold } => Some(hold.lowered_after()),
281 ProfilePlan::Next { .. } | ProfilePlan::Nothing => None,
282 }
283 }
284
285 pub fn headline(&self, plan: &AutoPlan) -> String {
288 match self {
289 ProfilePlan::Next {
290 change,
291 blocked: true,
292 } => format!("Once that is fixed: {}", change.line()),
293 ProfilePlan::Next { change, .. } => format!(
294 "Next look, within {}: {}",
295 super::period(plan.every),
296 change.line()
297 ),
298 ProfilePlan::Held { .. } => "No change".to_owned(),
299 ProfilePlan::LowerHeld { .. } => "No lowering".to_owned(),
300 ProfilePlan::Nothing => "Nothing to change".to_owned(),
301 }
302 }
303
304 pub fn reason(&self, sizing: &Sizing, profile: &ProfileSizing) -> String {
307 match self {
308 ProfilePlan::Next { change, .. } => change.reason.clone(),
309 ProfilePlan::Held { .. } => "the interval holds it until then".to_owned(),
310 ProfilePlan::LowerHeld { .. } => "a raise may come before then".to_owned(),
311 ProfilePlan::Nothing => sizing.advice_line(profile),
312 }
313 }
314
315 pub fn changed(&self) -> Option<u64> {
317 match self {
318 ProfilePlan::Held { hold } | ProfilePlan::LowerHeld { hold } => Some(hold.changed),
319 ProfilePlan::Next { .. } | ProfilePlan::Nothing => None,
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests;