1use std::collections::{BTreeMap, BTreeSet};
7use std::time::Duration;
8
9use super::{IDLE_TIMEOUT, Pool};
10
11pub const ACTIVE_AFTER: Duration = Duration::from_secs(3600);
13
14pub const LOOKBACK: u64 = 14 * DAY;
16
17const USUAL_DAYS: usize = 3;
19
20const MIN_RUNS: usize = 3;
22
23const FORGET_AFTER: Duration = Duration::from_hours(24);
25
26const UNKNOWN_SPAN: Duration = Duration::from_secs(3600);
28
29const HOUR: u64 = 3600 * 1000;
30
31type RunKey<'seen> = (&'seen str, &'seen str, &'seen str, u64, u32);
34
35type WorkflowKey<'seen> = (&'seen str, &'seen str, &'seen str);
37const DAY: u64 = 24 * HOUR;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Seen {
42 pub repository: String,
44 pub labels: Vec<String>,
46 pub workflow: Option<String>,
48 pub run_id: u64,
50 pub run_attempt: u32,
52 pub queued_at: u64,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct Burst {
59 pub jobs: usize,
61 pub span: Duration,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct Learned {
69 usual: BTreeMap<String, BTreeSet<u64>>,
71 last: BTreeMap<String, u64>,
73 bursts: BTreeMap<(Pool, String), Burst>,
75}
76
77impl Learned {
78 pub fn new(seen: &[Seen], now: u64, profile: impl Fn(&Seen) -> Option<String>) -> Learned {
81 let since = now.saturating_sub(LOOKBACK);
82 let mut learned = Learned::default();
83 let mut days: BTreeMap<(&str, u64), BTreeSet<u64>> = BTreeMap::new();
84 let profiles: Vec<Option<String>> = seen.iter().map(profile).collect();
85 let mut runs: BTreeMap<RunKey<'_>, (usize, u64, u64)> = BTreeMap::new();
87 let mut attempts: BTreeMap<(&str, u64, u32), u64> = BTreeMap::new();
90 for (job, profile) in seen
91 .iter()
92 .zip(&profiles)
93 .filter(|(job, _)| job.queued_at >= since)
94 {
95 learned.note(&job.repository, job.queued_at);
96 days.entry((&job.repository, job.queued_at % DAY / HOUR))
97 .or_default()
98 .insert(job.queued_at / DAY);
99 if let (Some(workflow), Some(profile)) = (&job.workflow, profile) {
100 let run = runs
101 .entry((
102 &job.repository,
103 profile,
104 workflow,
105 job.run_id,
106 job.run_attempt,
107 ))
108 .or_insert((0, job.queued_at, job.queued_at));
109 run.0 += 1;
110 run.1 = run.1.min(job.queued_at);
111 run.2 = run.2.max(job.queued_at);
112 let first = attempts
113 .entry((&job.repository, job.run_id, job.run_attempt))
114 .or_insert(job.queued_at);
115 *first = (*first).min(job.queued_at);
116 }
117 }
118 for ((repository, hour), days) in days {
119 if days.len() >= USUAL_DAYS {
120 learned
121 .usual
122 .entry(repository.to_owned())
123 .or_default()
124 .insert(hour);
125 }
126 }
127 let mut workflows: BTreeMap<WorkflowKey<'_>, Vec<(usize, u64)>> = BTreeMap::new();
129 for ((repository, profile, workflow, run_id, run_attempt), (jobs, first, last)) in runs {
130 let first = attempts
131 .get(&(repository, run_id, run_attempt))
132 .copied()
133 .unwrap_or(first);
134 workflows
135 .entry((repository, profile, workflow))
136 .or_default()
137 .push((jobs, last - first));
138 }
139 for ((repository, profile, workflow), runs) in workflows {
140 if runs.len() < MIN_RUNS {
141 continue;
142 }
143 let mut jobs: Vec<usize> = runs.iter().map(|(jobs, _)| *jobs).collect();
144 let mut spans: Vec<u64> = runs.iter().map(|(_, span)| *span).collect();
145 jobs.sort_unstable();
146 spans.sort_unstable();
147 let pool = Pool {
148 repository: repository.to_owned(),
149 profile: profile.to_owned(),
150 };
151 learned.bursts.insert(
152 (pool, workflow.to_owned()),
153 Burst {
154 jobs: jobs[(jobs.len() - 1) / 2],
155 span: Duration::from_millis(spans[(spans.len() * 90).div_ceil(100) - 1]),
156 },
157 );
158 }
159 learned
160 }
161
162 pub fn absorb(&mut self, older: &Learned) {
164 for (repository, at) in &older.last {
165 self.note(repository, *at);
166 }
167 }
168
169 pub fn note(&mut self, repository: &str, at: u64) {
171 let last = self.last.entry(repository.to_owned()).or_default();
172 *last = (*last).max(at);
173 }
174
175 pub fn active(&self, repository: &str, now: u64) -> bool {
178 let after = u64::try_from(ACTIVE_AFTER.as_millis()).unwrap_or(u64::MAX);
179 self.last
180 .get(repository)
181 .is_some_and(|last| now.saturating_sub(*last) < after)
182 || self
183 .usual
184 .get(repository)
185 .is_some_and(|hours| hours.contains(&(now % DAY / HOUR)))
186 }
187
188 pub fn burst(&self, pool: &Pool, workflow: &str) -> Option<Burst> {
190 self.bursts
191 .get(&(pool.clone(), workflow.to_owned()))
192 .copied()
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198struct Run {
199 repository: String,
200 workflow: Option<String>,
201 jobs: BTreeMap<u64, String>,
203 first: u64,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
209pub struct Attempt {
210 pub run_id: u64,
212 pub run_attempt: u32,
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Eq)]
218pub struct Runs(BTreeMap<Attempt, Run>);
219
220impl Runs {
221 pub fn note(
223 &mut self,
224 pool: &Pool,
225 attempt: Attempt,
226 job: u64,
227 workflow: Option<&str>,
228 at: u64,
229 ) {
230 let run = self.0.entry(attempt).or_insert_with(|| Run {
231 repository: pool.repository.clone(),
232 workflow: None,
233 jobs: BTreeMap::new(),
234 first: at,
235 });
236 run.jobs.insert(job, pool.profile.clone());
237 run.first = run.first.min(at);
238 if run.workflow.is_none() {
239 run.workflow = workflow.map(str::to_owned);
240 }
241 }
242
243 pub fn place(
249 &mut self,
250 pool: &Pool,
251 attempt: Attempt,
252 job: u64,
253 workflow: Option<&str>,
254 at: u64,
255 ) {
256 let mut seen: Option<u64> = None;
258 let mut emptied = Vec::new();
259 for (other, run) in self.attempts_mut(attempt.run_id) {
260 if *other != attempt && run.jobs.remove(&job).is_some() {
261 if run.jobs.is_empty() {
262 emptied.push(*other);
263 seen = Some(run.first.min(seen.unwrap_or(at)));
264 } else {
265 seen = Some(seen.unwrap_or(at));
266 }
267 }
268 }
269 for other in emptied {
270 self.0.remove(&other);
271 }
272 if let Some(seen) = seen {
273 self.note(pool, attempt, job, workflow, seen);
274 }
275 }
276
277 pub fn name(&mut self, run_id: u64, workflow: &str) {
279 for (_, run) in self.attempts_mut(run_id) {
280 if run.workflow.is_none() {
281 run.workflow = Some(workflow.to_owned());
282 }
283 }
284 }
285
286 pub fn latest(&self, run_id: u64) -> Attempt {
288 self.0
289 .range(
290 Attempt {
291 run_id,
292 run_attempt: 0,
293 }..=Attempt {
294 run_id,
295 run_attempt: u32::MAX,
296 },
297 )
298 .next_back()
299 .map_or(
300 Attempt {
301 run_id,
302 run_attempt: 1,
303 },
304 |(attempt, _)| *attempt,
305 )
306 }
307
308 fn attempts_mut(&mut self, run_id: u64) -> impl Iterator<Item = (&Attempt, &mut Run)> {
309 self.0.range_mut(
310 Attempt {
311 run_id,
312 run_attempt: 0,
313 }..=Attempt {
314 run_id,
315 run_attempt: u32::MAX,
316 },
317 )
318 }
319
320 pub fn expire(&mut self, now: u64) {
323 let after = u64::try_from(FORGET_AFTER.as_millis()).unwrap_or(u64::MAX);
324 self.0
325 .retain(|_, run| now.saturating_sub(run.first) < after);
326 }
327
328 pub fn remaining(&self, pool: &Pool, learned: &Learned, now: u64) -> usize {
331 self.0
332 .values()
333 .filter(|run| run.repository == pool.repository && run.counts(pool, learned, now))
334 .filter_map(|run| {
335 let burst = learned.burst(pool, run.workflow.as_deref()?)?;
336 let seen = run
337 .jobs
338 .values()
339 .filter(|profile| **profile == pool.profile)
340 .count();
341 Some(burst.jobs.saturating_sub(seen))
342 })
343 .sum()
344 }
345}
346
347impl Run {
348 fn counts(&self, pool: &Pool, learned: &Learned, now: u64) -> bool {
351 let span = self
352 .workflow
353 .as_deref()
354 .and_then(|workflow| learned.burst(pool, workflow))
355 .map_or(UNKNOWN_SPAN, |burst| burst.span + IDLE_TIMEOUT);
356 now.saturating_sub(self.first) < u64::try_from(span.as_millis()).unwrap_or(u64::MAX)
357 }
358}
359
360pub fn target(warm: u32, active: bool, remaining: usize) -> usize {
363 if warm == 0 {
364 return 0;
365 }
366 let base = if active {
367 usize::try_from(warm).unwrap_or(usize::MAX)
368 } else {
369 0
370 };
371 base.saturating_add(remaining)
372}
373
374#[cfg(test)]
375mod tests;