1use std::collections::{BTreeMap, BTreeSet};
6use std::time::Duration;
7
8use super::IDLE_TIMEOUT;
9
10pub const ACTIVE_AFTER: Duration = Duration::from_secs(3600);
12
13pub const LOOKBACK: u64 = 14 * DAY;
15
16const USUAL_DAYS: usize = 3;
18
19const MIN_RUNS: usize = 3;
21
22const FORGET_AFTER: Duration = Duration::from_hours(24);
24
25const UNKNOWN_SPAN: Duration = Duration::from_secs(3600);
27
28const HOUR: u64 = 3600 * 1000;
29
30type RunKey<'seen> = (&'seen str, &'seen str, u64, u32);
32const DAY: u64 = 24 * HOUR;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Seen {
37 pub repository: String,
39 pub workflow: Option<String>,
41 pub run_id: u64,
43 pub run_attempt: u32,
45 pub queued_at: u64,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct Burst {
52 pub jobs: usize,
54 pub span: Duration,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct Learned {
62 usual: BTreeMap<String, BTreeSet<u64>>,
64 last: BTreeMap<String, u64>,
66 bursts: BTreeMap<(String, String), Burst>,
68}
69
70impl Learned {
71 pub fn new(seen: &[Seen], now: u64) -> Learned {
73 let since = now.saturating_sub(LOOKBACK);
74 let mut learned = Learned::default();
75 let mut days: BTreeMap<(&str, u64), BTreeSet<u64>> = BTreeMap::new();
76 let mut runs: BTreeMap<RunKey<'_>, (usize, u64, u64)> = BTreeMap::new();
78 for job in seen.iter().filter(|job| job.queued_at >= since) {
79 learned.note(&job.repository, job.queued_at);
80 days.entry((&job.repository, job.queued_at % DAY / HOUR))
81 .or_default()
82 .insert(job.queued_at / DAY);
83 if let Some(workflow) = &job.workflow {
84 let run = runs
85 .entry((&job.repository, workflow, job.run_id, job.run_attempt))
86 .or_insert((0, job.queued_at, job.queued_at));
87 run.0 += 1;
88 run.1 = run.1.min(job.queued_at);
89 run.2 = run.2.max(job.queued_at);
90 }
91 }
92 for ((repository, hour), days) in days {
93 if days.len() >= USUAL_DAYS {
94 learned
95 .usual
96 .entry(repository.to_owned())
97 .or_default()
98 .insert(hour);
99 }
100 }
101 let mut workflows: BTreeMap<(&str, &str), Vec<(usize, u64)>> = BTreeMap::new();
102 for ((repository, workflow, ..), (jobs, first, last)) in runs {
103 workflows
104 .entry((repository, workflow))
105 .or_default()
106 .push((jobs, last - first));
107 }
108 for ((repository, workflow), runs) in workflows {
109 if runs.len() < MIN_RUNS {
110 continue;
111 }
112 let mut jobs: Vec<usize> = runs.iter().map(|(jobs, _)| *jobs).collect();
113 let mut spans: Vec<u64> = runs.iter().map(|(_, span)| *span).collect();
114 jobs.sort_unstable();
115 spans.sort_unstable();
116 learned.bursts.insert(
117 (repository.to_owned(), workflow.to_owned()),
118 Burst {
119 jobs: jobs[(jobs.len() - 1) / 2],
120 span: Duration::from_millis(spans[(spans.len() * 90).div_ceil(100) - 1]),
121 },
122 );
123 }
124 learned
125 }
126
127 pub fn absorb(&mut self, older: &Learned) {
129 for (repository, at) in &older.last {
130 self.note(repository, *at);
131 }
132 }
133
134 pub fn note(&mut self, repository: &str, at: u64) {
136 let last = self.last.entry(repository.to_owned()).or_default();
137 *last = (*last).max(at);
138 }
139
140 pub fn active(&self, repository: &str, now: u64) -> bool {
143 let after = u64::try_from(ACTIVE_AFTER.as_millis()).unwrap_or(u64::MAX);
144 self.last
145 .get(repository)
146 .is_some_and(|last| now.saturating_sub(*last) < after)
147 || self
148 .usual
149 .get(repository)
150 .is_some_and(|hours| hours.contains(&(now % DAY / HOUR)))
151 }
152
153 pub fn burst(&self, repository: &str, workflow: &str) -> Option<Burst> {
155 self.bursts
156 .get(&(repository.to_owned(), workflow.to_owned()))
157 .copied()
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163struct Run {
164 repository: String,
165 workflow: Option<String>,
166 jobs: BTreeSet<u64>,
167 first: u64,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
173pub struct Attempt {
174 pub run_id: u64,
176 pub run_attempt: u32,
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
182pub struct Runs(BTreeMap<Attempt, Run>);
183
184impl Runs {
185 pub fn note(
187 &mut self,
188 repository: &str,
189 attempt: Attempt,
190 job: u64,
191 workflow: Option<&str>,
192 at: u64,
193 ) {
194 let run = self.0.entry(attempt).or_insert_with(|| Run {
195 repository: repository.to_owned(),
196 workflow: None,
197 jobs: BTreeSet::new(),
198 first: at,
199 });
200 run.jobs.insert(job);
201 run.first = run.first.min(at);
202 if run.workflow.is_none() {
203 run.workflow = workflow.map(str::to_owned);
204 }
205 }
206
207 pub fn place(
213 &mut self,
214 repository: &str,
215 attempt: Attempt,
216 job: u64,
217 workflow: Option<&str>,
218 at: u64,
219 ) {
220 let mut seen: Option<u64> = None;
222 let mut emptied = Vec::new();
223 for (other, run) in self.attempts_mut(attempt.run_id) {
224 if *other != attempt && run.jobs.remove(&job) {
225 if run.jobs.is_empty() {
226 emptied.push(*other);
227 seen = Some(run.first.min(seen.unwrap_or(at)));
228 } else {
229 seen = Some(seen.unwrap_or(at));
230 }
231 }
232 }
233 for other in emptied {
234 self.0.remove(&other);
235 }
236 if let Some(seen) = seen {
237 self.note(repository, attempt, job, workflow, seen);
238 }
239 }
240
241 pub fn name(&mut self, run_id: u64, workflow: &str) {
243 for (_, run) in self.attempts_mut(run_id) {
244 if run.workflow.is_none() {
245 run.workflow = Some(workflow.to_owned());
246 }
247 }
248 }
249
250 pub fn latest(&self, run_id: u64) -> Attempt {
252 self.0
253 .range(
254 Attempt {
255 run_id,
256 run_attempt: 0,
257 }..=Attempt {
258 run_id,
259 run_attempt: u32::MAX,
260 },
261 )
262 .next_back()
263 .map_or(
264 Attempt {
265 run_id,
266 run_attempt: 1,
267 },
268 |(attempt, _)| *attempt,
269 )
270 }
271
272 fn attempts_mut(&mut self, run_id: u64) -> impl Iterator<Item = (&Attempt, &mut Run)> {
273 self.0.range_mut(
274 Attempt {
275 run_id,
276 run_attempt: 0,
277 }..=Attempt {
278 run_id,
279 run_attempt: u32::MAX,
280 },
281 )
282 }
283
284 pub fn expire(&mut self, now: u64) {
287 let after = u64::try_from(FORGET_AFTER.as_millis()).unwrap_or(u64::MAX);
288 self.0
289 .retain(|_, run| now.saturating_sub(run.first) < after);
290 }
291
292 pub fn remaining(&self, repository: &str, learned: &Learned, now: u64) -> usize {
295 self.0
296 .values()
297 .filter(|run| run.repository == repository && run.counts(learned, now))
298 .filter_map(|run| {
299 let burst = learned.burst(repository, run.workflow.as_deref()?)?;
300 Some(burst.jobs.saturating_sub(run.jobs.len()))
301 })
302 .sum()
303 }
304}
305
306impl Run {
307 fn counts(&self, learned: &Learned, now: u64) -> bool {
309 let span = self
310 .workflow
311 .as_deref()
312 .and_then(|workflow| learned.burst(&self.repository, workflow))
313 .map_or(UNKNOWN_SPAN, |burst| burst.span + IDLE_TIMEOUT);
314 now.saturating_sub(self.first) < u64::try_from(span.as_millis()).unwrap_or(u64::MAX)
315 }
316}
317
318pub fn target(warm: u32, active: bool, remaining: usize) -> usize {
321 if warm == 0 {
322 return 0;
323 }
324 let base = if active {
325 usize::try_from(warm).unwrap_or(usize::MAX)
326 } else {
327 0
328 };
329 base.saturating_add(remaining)
330}
331
332#[cfg(test)]
333mod tests;