1use std::collections::BTreeMap;
7
8use crate::api::{self, Advice, Peaks, Spread};
9use crate::config::Config;
10use crate::observe::metrics::SAMPLE_INTERVAL;
11
12pub(crate) const GRANULE: u64 = 256 << 20;
14
15const AT_CAP: u64 = 95;
17
18const WORTH_LOWERING: u64 = 10;
20
21const WAITED: u64 = 60;
24const BUSY: u32 = 50;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Ran {
29 pub repository: String,
31 pub workflow: Option<String>,
33 pub name: String,
35 pub profile: String,
37 pub memory_cap: Option<u64>,
39 pub memory_peak: u64,
41 pub cpu_peak: u64,
43 pub queued_at: u64,
45 pub started_at: Option<u64>,
47 pub finished_at: Option<u64>,
49 pub oom_killed: bool,
51 pub reserved: Option<u64>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Held {
58 pub profile: String,
60 pub started_at: u64,
62 pub removed_at: Option<u64>,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct History {
69 pub jobs: Vec<Ran>,
71 pub cancelled: Vec<Ran>,
74 pub runners: Vec<Held>,
76}
77
78#[derive(Debug, Clone, Copy)]
80pub struct Window {
81 pub since: u64,
83 pub now: u64,
85 pub sample_interval: u64,
87}
88
89impl Window {
90 pub fn new(since: Option<u64>, now: u64) -> Window {
93 Window {
94 since: since
95 .unwrap_or_else(|| now.saturating_sub(30 * 24 * 3600))
96 .min(now)
97 .saturating_mul(1000),
98 now: now.saturating_mul(1000),
99 sample_interval: SAMPLE_INTERVAL.as_secs(),
100 }
101 }
102}
103
104pub fn advise(
106 config: &Config,
107 memory_max: Option<u64>,
108 history: &History,
109 window: Window,
110) -> api::Sizing {
111 let since = history
112 .runners
113 .iter()
114 .map(|runner| runner.started_at)
115 .min()
116 .map_or(window.since, |first| first.max(window.since));
117 let span = window.now.saturating_sub(since).max(1);
118 let caps = config.memory_caps().map_or(u64::MAX, |caps| caps.0);
119 let rules = &config.sizing;
120 let profiles = config
121 .profiles
122 .iter()
123 .map(|(name, profile)| {
124 let jobs: Vec<&Ran> = history
125 .jobs
126 .iter()
127 .filter(|job| job.profile == *name)
128 .collect();
129 let share = profile.memory.0.saturating_mul(u64::from(profile.slots));
130 let held: u64 = history
131 .runners
132 .iter()
133 .filter(|runner| runner.profile == *name)
134 .map(|runner| {
135 let end = runner.removed_at.unwrap_or(window.now).min(window.now);
136 end.saturating_sub(runner.started_at.max(since))
137 })
138 .sum();
139 let busy = u32::try_from(
140 (held.saturating_mul(100) / span.saturating_mul(u64::from(profile.slots).max(1)))
141 .min(100),
142 )
143 .unwrap_or(100);
144 let mut keys: BTreeMap<(&str, Option<&str>, &str), Vec<&Ran>> = BTreeMap::new();
145 for job in &jobs {
146 keys.entry((&job.repository, job.workflow.as_deref(), &job.name))
147 .or_default()
148 .push(job);
149 }
150 let sized = Sized {
151 cap: profile.memory.0,
152 slots: profile.slots,
153 share,
154 room: memory_max.map(|max| max.saturating_sub(caps.saturating_sub(share))),
156 margin: rules.margin,
157 min_jobs: rules.min_jobs,
158 };
159 let job_sizings: Vec<api::JobSizing> = keys
160 .into_iter()
161 .map(|((repository, workflow, job_name), runs)| {
162 let peaks = peaks(&runs);
163 let wait = waits(&runs);
164 api::JobSizing {
165 repository: repository.to_owned(),
166 workflow: workflow.map(str::to_owned),
167 name: job_name.to_owned(),
168 advice: sized.advice(&peaks, &wait, busy),
169 peaks,
170 wait,
171 }
172 })
173 .collect();
174 let peaks = peaks(&jobs);
175 let wait = waits(&jobs);
176 api::ProfileSizing {
177 name: name.clone(),
178 memory: profile.memory.0,
179 cpus: profile.cpus,
180 slots: profile.slots,
181 advice: sized.advice(&peaks, &wait, busy),
182 peaks,
183 wait,
184 busy,
185 jobs: job_sizings,
186 }
187 })
188 .collect();
189 api::Sizing {
190 since: since / 1000,
191 until: window.now / 1000,
192 margin: rules.margin,
193 sample_interval: window.sample_interval,
194 min_jobs: rules.min_jobs,
195 memory_max,
196 profiles,
197 }
198}
199
200struct Sized {
202 cap: u64,
203 slots: u32,
204 share: u64,
206 room: Option<u64>,
208 margin: u32,
209 min_jobs: u32,
210}
211
212impl Sized {
213 fn need(&self, peaks: &Peaks) -> u64 {
216 let largest = peaks.memory.max;
217 let need = largest.saturating_add(largest.saturating_mul(u64::from(self.margin)) / 100);
218 need.div_ceil(GRANULE).saturating_mul(GRANULE).max(GRANULE)
219 }
220
221 fn advice(&self, peaks: &Peaks, wait: &Spread, busy: u32) -> Advice {
222 if peaks.jobs < u64::from(self.min_jobs) {
223 return Advice::TooFew { jobs: peaks.jobs };
224 }
225 let need = self.need(peaks);
226 let fit = |memory: u64| u32::try_from(memory / need).unwrap_or(u32::MAX);
227 if need > self.cap {
228 let slots = match self.room {
229 Some(room) => fit(room).min(self.slots),
230 None => self.slots,
231 };
232 if slots == 0 {
233 return Advice::SliceTooSmall { memory: need };
234 }
235 return Advice::Raise {
236 memory: need,
237 slots,
238 };
239 }
240 if peaks.at_cap == 0 && self.cap - need >= self.cap * WORTH_LOWERING / 100 {
243 let wanted = wait.p95 >= WAITED || busy >= BUSY;
244 return Advice::Lower {
245 memory: need,
246 slots: if wanted {
247 fit(self.share).max(self.slots)
248 } else {
249 self.slots
250 },
251 };
252 }
253 Advice::Keep { memory: need }
254 }
255}
256
257fn peaks(jobs: &[&Ran]) -> Peaks {
258 let at_cap = jobs
259 .iter()
260 .filter(|job| {
261 job.memory_cap.is_some_and(|cap| {
262 job.memory_peak.saturating_mul(100) >= cap.saturating_mul(AT_CAP)
263 })
264 })
265 .count();
266 Peaks {
267 jobs: jobs.len() as u64,
268 at_cap: at_cap as u64,
269 memory: spread(jobs.iter().map(|job| job.memory_peak).collect()),
270 cpu: spread(jobs.iter().map(|job| job.cpu_peak).collect()),
271 }
272}
273
274fn waits(jobs: &[&Ran]) -> Spread {
276 spread(
277 jobs.iter()
278 .filter_map(|job| {
279 job.started_at
280 .map(|at| at.saturating_sub(job.queued_at) / 1000)
281 })
282 .collect(),
283 )
284}
285
286pub(crate) fn spread(mut values: Vec<u64>) -> Spread {
288 values.sort_unstable();
289 let rank = |percent: usize| {
290 let index = (values.len() * percent).div_ceil(100).saturating_sub(1);
291 values.get(index).copied().unwrap_or(0)
292 };
293 Spread {
294 p50: rank(50),
295 p95: rank(95),
296 p99: rank(99),
297 max: values.last().copied().unwrap_or(0),
298 }
299}
300
301#[cfg(test)]
302mod tests;