1use std::collections::BTreeMap;
7
8use crate::api::{self, Advice, Peaks, Spread};
9use crate::config::{Admission, Config};
10use crate::observe::metrics::SAMPLE_INTERVAL;
11
12pub(crate) use crate::api::GRANULE;
13
14const AT_CAP: u64 = 95;
16
17const WORTH_LOWERING: u64 = 10;
19
20const WAITED: u64 = 60;
23const BUSY: u32 = 50;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Ran {
28 pub repository: String,
30 pub workflow: Option<String>,
32 pub name: String,
34 pub profile: String,
36 pub memory_cap: Option<u64>,
38 pub cpu_cap: Option<u64>,
40 pub memory_peak: u64,
42 pub cpu_peak: u64,
44 pub cpu_average: Option<u64>,
46 pub queued_at: u64,
48 pub started_at: Option<u64>,
50 pub runner_started_at: u64,
52 pub finished_at: Option<u64>,
54 pub oom_killed: bool,
56 pub hit_cap: Option<bool>,
59 pub measure: Measure,
61 pub succeeded: bool,
63 pub reserved: Option<u64>,
65 pub host_waited: u64,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Measure {
73 WithActiveFile,
76 WithoutPageCache,
79}
80
81impl Ran {
82 pub fn met_cap(&self, cap: u64) -> bool {
85 self.oom_killed
86 || self.hit_cap.unwrap_or_else(|| {
87 self.memory_peak.saturating_mul(100) >= cap.saturating_mul(AT_CAP)
88 })
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Held {
95 pub profile: String,
97 pub started_at: u64,
100 pub removed_at: Option<u64>,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct History {
107 pub jobs: Vec<Ran>,
109 pub cancelled: Vec<Ran>,
112 pub runners: Vec<Held>,
114}
115
116#[derive(Debug, Clone, Copy)]
118pub struct Window {
119 pub since: u64,
121 pub now: u64,
123 pub sample_interval: u64,
125}
126
127impl Window {
128 pub fn new(since: Option<u64>, now: u64) -> Window {
131 Window {
132 since: since
133 .unwrap_or_else(|| now.saturating_sub(30 * 24 * 3600))
134 .min(now)
135 .saturating_mul(1000),
136 now: now.saturating_mul(1000),
137 sample_interval: SAMPLE_INTERVAL.as_secs(),
138 }
139 }
140}
141
142pub fn advise(
145 config: &Config,
146 memory_max: Option<u64>,
147 history: &History,
148 window: Window,
149) -> api::Sizing {
150 let since = first_runner(history, window.since);
151 let caps = config.memory_caps().map_or(u64::MAX, |caps| caps.0);
152 let mirror = config.mirror_memory();
153 let rules = &config.sizing;
154 let profiles = config
155 .profiles
156 .iter()
157 .map(|(name, profile)| {
158 let jobs: Vec<&Ran> = history
159 .jobs
160 .iter()
161 .filter(|job| job.profile == *name)
162 .collect();
163 let share = profile.memory.0.saturating_mul(u64::from(profile.slots));
164 let busy = busy(history, name, profile.slots, since, window.now);
165 let mut keys: BTreeMap<(&str, Option<&str>, &str), Vec<&Ran>> = BTreeMap::new();
166 for job in &jobs {
167 keys.entry((&job.repository, job.workflow.as_deref(), &job.name))
168 .or_default()
169 .push(job);
170 }
171 let wait = waits(&jobs);
172 let starts = starts(&jobs);
173 let sized = Sized {
174 cap: profile.memory.0,
175 slots: profile.slots,
176 share,
177 admission: config.host.admission,
178 room: memory_max.map(|max| match config.host.admission {
179 Admission::Caps => max.saturating_sub(caps.saturating_sub(share)),
181 Admission::Measured => max.saturating_sub(mirror),
184 }),
185 wants_slots: wait.p95 >= WAITED || busy >= BUSY,
186 margin: rules.margin,
187 min_jobs: rules.min_jobs,
188 };
189 let job_sizings: Vec<api::JobSizing> = keys
190 .into_iter()
191 .map(|((repository, workflow, job_name), runs)| {
192 let peaks = peaks(&runs);
193 let wait = waits(&runs);
194 api::JobSizing {
195 repository: repository.to_owned(),
196 workflow: workflow.map(str::to_owned),
197 name: job_name.to_owned(),
198 advice: sized.advice(&peaks, wait.p95 >= WAITED || busy >= BUSY),
199 peaks,
200 wait,
201 }
202 })
203 .collect();
204 let peaks = peaks(&jobs);
205 api::ProfileSizing {
206 name: name.clone(),
207 memory: profile.memory.0,
208 cpus: profile.cpus,
209 slots: profile.slots,
210 advice: sized.advice(&peaks, sized.wants_slots),
211 peaks,
212 wait,
213 starts,
214 busy,
215 wants_slots: sized.wants_slots,
216 jobs: job_sizings,
217 }
218 })
219 .collect();
220 api::Sizing {
221 since: since / 1000,
222 until: window.now / 1000,
223 margin: rules.margin,
224 sample_interval: window.sample_interval,
225 min_jobs: rules.min_jobs,
226 memory_max,
227 admission: config.host.admission.policy(),
228 profiles,
229 auto: config.sizing.auto,
230 changes: Vec::new(),
231 auto_plan: None,
232 }
233}
234
235fn first_runner(history: &History, start: u64) -> u64 {
238 history
239 .runners
240 .iter()
241 .map(|runner| runner.started_at)
242 .min()
243 .map_or(start, |first| first.max(start))
244}
245
246fn busy(history: &History, profile: &str, slots: u32, since: u64, now: u64) -> u32 {
248 let span = now.saturating_sub(since).max(1);
249 let held: u64 = history
250 .runners
251 .iter()
252 .filter(|runner| runner.profile == profile)
253 .map(|runner| {
254 let end = runner.removed_at.unwrap_or(now).min(now);
255 end.saturating_sub(runner.started_at.max(since))
256 })
257 .sum();
258 u32::try_from(
259 (held.saturating_mul(100) / span.saturating_mul(u64::from(slots).max(1))).min(100),
260 )
261 .unwrap_or(100)
262}
263
264struct Sized {
266 cap: u64,
267 slots: u32,
268 share: u64,
270 admission: Admission,
271 room: Option<u64>,
274 wants_slots: bool,
276 margin: u32,
277 min_jobs: u32,
278}
279
280impl Sized {
281 fn need(&self, peaks: &Peaks) -> u64 {
285 api::need(peaks.memory.max, self.margin)
286 }
287
288 fn advice(&self, peaks: &Peaks, wants_slots: bool) -> Advice {
289 if peaks.jobs < u64::from(self.min_jobs) {
290 return Advice::TooFew { jobs: peaks.jobs };
291 }
292 let need = self.need(peaks);
293 let fit = |memory: u64| u32::try_from(memory / need).unwrap_or(u32::MAX);
294 if need > self.cap {
295 let slots = match (self.admission, self.room) {
296 (Admission::Caps, Some(room)) => fit(room).min(self.slots),
297 (Admission::Measured, Some(room)) if room < need => 0,
298 (_, _) => self.slots,
299 };
300 if slots == 0 {
301 return Advice::SliceTooSmall { memory: need };
302 }
303 return Advice::Raise {
304 memory: need,
305 slots,
306 };
307 }
308 if peaks.at_cap == 0 && self.cap - need >= self.cap * WORTH_LOWERING / 100 {
311 return Advice::Lower {
313 memory: need,
314 slots: if wants_slots && self.admission == Admission::Caps {
315 fit(self.share).max(self.slots)
316 } else {
317 self.slots
318 },
319 };
320 }
321 Advice::Keep { memory: need }
322 }
323}
324
325fn peaks(jobs: &[&Ran]) -> Peaks {
326 let at_cap = jobs
327 .iter()
328 .filter(|job| job.memory_cap.is_some_and(|cap| job.met_cap(cap)))
329 .count();
330 Peaks {
331 jobs: jobs.len() as u64,
332 at_cap: at_cap as u64,
333 memory: spread(jobs.iter().map(|job| job.memory_peak).collect()),
334 cpu: spread(jobs.iter().map(|job| job.cpu_peak).collect()),
335 }
336}
337
338fn waits(jobs: &[&Ran]) -> Spread {
340 spread(
341 jobs.iter()
342 .filter_map(|job| {
343 job.started_at.map(|at| {
344 at.saturating_sub(job.queued_at)
345 .saturating_sub(job.host_waited)
346 / 1000
347 })
348 })
349 .collect(),
350 )
351}
352
353fn starts(jobs: &[&Ran]) -> api::Starts {
355 let (warm, cold): (Vec<&Ran>, Vec<&Ran>) = jobs
356 .iter()
357 .filter(|job| job.started_at.is_some())
358 .partition(|job| job.runner_started_at < job.queued_at);
359 let count = |jobs: &[&Ran]| u32::try_from(jobs.len()).unwrap_or(u32::MAX);
360 api::Starts {
361 warm: count(&warm),
362 warm_p50: waits(&warm).p50,
363 cold: count(&cold),
364 cold_p50: waits(&cold).p50,
365 }
366}
367
368pub(crate) fn spread(mut values: Vec<u64>) -> Spread {
370 values.sort_unstable();
371 let rank = |percent: usize| {
372 let index = (values.len() * percent).div_ceil(100).saturating_sub(1);
373 values.get(index).copied().unwrap_or(0)
374 };
375 Spread {
376 p50: rank(50),
377 p95: rank(95),
378 p99: rank(99),
379 max: values.last().copied().unwrap_or(0),
380 }
381}
382
383pub mod auto;
384
385#[cfg(test)]
386mod tests;