1use std::collections::{BTreeMap, BTreeSet};
6use std::time::{Duration, SystemTime};
7
8use crate::api::Waiting;
9use crate::github::QueuedJob;
10
11pub mod engine;
12pub mod warm;
13
14pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
17
18pub const CPU_PRIORITY_AFTER: Duration = Duration::from_secs(120);
21
22#[derive(Debug, Clone, Copy)]
24pub struct Hold {
25 pub until: SystemTime,
27 pub cause: crate::api::HoldCause,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34pub struct Pool {
35 pub repository: String,
37 pub profile: String,
39}
40
41impl From<&crate::config::Repository> for Pool {
42 fn from(repository: &crate::config::Repository) -> Pool {
43 Pool {
44 repository: repository.name.clone(),
45 profile: repository.profile.clone(),
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
53pub struct Repository {
54 pub name: String,
56 pub profile: String,
58 pub labels: Vec<String>,
60 pub paused: bool,
62 pub held: Option<Hold>,
64 pub queued: Vec<QueuedJob>,
66 pub runner_memory: Option<u64>,
69 pub runner_cpu: Option<u64>,
72 pub cpu_waiting_since: Option<SystemTime>,
74 pub warm: usize,
76}
77
78impl Repository {
79 pub fn pool(&self) -> Pool {
81 Pool {
82 repository: self.name.clone(),
83 profile: self.profile.clone(),
84 }
85 }
86
87 fn starved_of_cpu(&self, now: SystemTime) -> bool {
89 self.cpu_waiting_since
90 .and_then(|since| now.duration_since(since).ok())
91 .is_some_and(|waited| waited >= CPU_PRIORITY_AFTER)
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct Profile {
98 pub memory: u64,
100 pub cpus: u64,
103 pub preparing: Option<Preparing>,
105 pub upkeep_due: bool,
108 pub count: usize,
110 pub slots: Vec<Slot>,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Preparing {
117 Reseeding,
119 PullingImage,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Slot {
126 Free,
128 Runner(RunnerSlot),
130 Closed,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RunnerSlot {
138 pub repository: String,
140 pub busy: bool,
142 pub started_at: SystemTime,
144 pub memory: u64,
146 pub cpu: u64,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
152pub struct SlotRef {
153 pub profile: String,
155 pub index: usize,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Decision {
162 Start {
164 slot: SlotRef,
166 repository: String,
168 memory: u64,
170 cpu: u64,
172 warm: bool,
174 replacing: Vec<SlotRef>,
177 },
178 HeldForCpu {
181 repository: String,
183 profile: String,
185 },
186 Stop {
189 slot: SlotRef,
191 },
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct Plan {
197 pub decisions: Vec<Decision>,
199 pub waiting: BTreeMap<u64, Waiting>,
203 pub warm: BTreeSet<SlotRef>,
205}
206
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
209pub struct Budget {
210 pub memory: Option<i64>,
213 pub cpu: Option<i64>,
216 pub host: Option<i64>,
219 pub held: bool,
222}
223
224const GITHUB_HOSTED: &[&str] = &[
230 "ubuntu-slim",
231 "ubuntu-latest",
232 "ubuntu-26.04",
233 "ubuntu-24.04",
234 "ubuntu-22.04",
235 "ubuntu-26.04-arm",
236 "ubuntu-24.04-arm",
237 "ubuntu-22.04-arm",
238 "windows-latest",
239 "windows-2025",
240 "windows-2025-vs2026",
241 "windows-2022",
242 "windows-11-arm",
243 "windows-11-vs2026-arm",
244 "macos-latest",
245 "macos-26",
246 "macos-15",
247 "macos-14",
248 "macos-26-intel",
249 "macos-15-intel",
250 "xcode-27",
251 "macos-latest-large",
252 "macos-26-large",
253 "macos-15-large",
254 "macos-14-large",
255 "macos-latest-xlarge",
256 "macos-26-xlarge",
257 "macos-15-xlarge",
258 "macos-14-xlarge",
259 "xcode-27-xlarge",
260];
261
262fn hosted_by_github(job: &QueuedJob) -> bool {
266 matches!(&job.labels[..], [label] if GITHUB_HOSTED.iter().any(|hosted| hosted.eq_ignore_ascii_case(label)))
267}
268
269pub fn matches(job: &QueuedJob, labels: &[String]) -> bool {
272 crate::config::labels_match(&job.labels, labels)
273}
274
275pub fn plan(
290 repositories: &[Repository],
291 profiles: &BTreeMap<String, Profile>,
292 budget: Budget,
293 now: SystemTime,
294) -> Plan {
295 let mut decided = Plan::default();
296 unserved(repositories, &mut decided);
297 let mut wanted = BTreeMap::new();
298 let mut warm_wanted = Vec::new();
299 let mut jobs = Vec::new();
300 for repository in repositories {
301 if let Some(sorted) = sort_out(repository, profiles, now, &mut decided) {
302 wanted.insert(pool_key(repository), sorted.serve.len());
303 jobs.extend(sorted.serve.into_iter().map(|job| (job, repository)));
304 warm_wanted.push((repository, sorted.warm));
305 }
306 }
307 jobs.sort_by_key(|(id, _)| *id);
308
309 let mut warm = Warm::new(&decided.warm, profiles, budget.held);
310 let mut taken = taken_slots(profiles);
311 let mut left = Left {
312 memory: budget.memory,
313 cpu: budget.cpu,
314 host: budget.host,
315 };
316 let mut started = Started::new(&taken, left);
317 let mut first_for_cpu = None;
319 for (job, repository) in jobs {
320 let Some(missing) = wanted.get_mut(&pool_key(repository)) else {
321 continue;
322 };
323 if *missing == 0 {
324 continue;
325 }
326 let (Some(profile), Some(taken)) = (
327 profiles.get(&repository.profile),
328 taken.get_mut(repository.profile.as_str()),
329 ) else {
330 continue;
331 };
332 if let Some(older) = first_for_cpu {
333 decided
334 .waiting
335 .insert(job, Waiting::BehindCpu { job: older });
336 continue;
337 }
338 let memory = repository.runner_memory.unwrap_or(profile.memory);
339 let cpu = repository.runner_cpu.unwrap_or(profile.cpus);
340 let free_index = taken.iter().position(|taken| !taken);
341 let fits = free_index.is_some() && left.fits(memory, cpu);
342 let room = if fits || profile.preparing.is_some() {
343 None
344 } else {
345 warm.make_room(repository, free_index.is_none(), left, memory, cpu)
346 };
347 let index = match (room.as_ref(), free_index) {
348 (Some(room), free) => free.or(room.slot).unwrap_or_default(),
349 (None, Some(index)) if fits => index,
350 (None, free) => {
351 let (reason, short) = why_waits(
352 &repository.profile,
353 profile,
354 free.is_some(),
355 memory,
356 cpu,
357 left,
358 &started,
359 );
360 decided.waiting.insert(job, reason);
361 if short == Short::Cpu {
362 let held = held_for_cpu(repository);
363 if !decided.decisions.contains(&held) {
364 decided.decisions.push(held);
365 }
366 if repository.starved_of_cpu(now) {
368 first_for_cpu = Some(job);
369 }
370 }
371 continue;
372 }
373 };
374 let replacing = room.map_or_else(Vec::new, |room| {
376 left.give(room.memory, room.cpu);
377 for slot in &room.stopped {
378 decided.warm.remove(slot);
379 decided
380 .decisions
381 .push(Decision::Stop { slot: slot.clone() });
382 }
383 room.stopped
384 });
385 taken[index] = true;
386 *missing -= 1;
387 started.note(&repository.profile, job);
388 left.take(memory, cpu);
389 decided
390 .decisions
391 .push(start(repository, index, memory, cpu, replacing));
392 }
393 if !budget.held {
394 start_warm(&warm_wanted, profiles, &mut taken, left, &mut decided);
395 }
396 decided
397}
398
399fn held_for_cpu(repository: &Repository) -> Decision {
401 Decision::HeldForCpu {
402 repository: repository.name.clone(),
403 profile: repository.profile.clone(),
404 }
405}
406
407fn pool_key(repository: &Repository) -> (&str, &str) {
409 (&repository.name, &repository.profile)
410}
411
412fn unserved(repositories: &[Repository], decided: &mut Plan) {
414 let mut seen = BTreeSet::new();
415 for repository in repositories {
416 if !seen.insert(repository.name.as_str()) {
417 continue;
418 }
419 let pools: Vec<&Repository> = repositories
420 .iter()
421 .filter(|pool| pool.name == repository.name)
422 .collect();
423 decided.waiting.extend(
424 repository
425 .queued
426 .iter()
427 .filter(|job| {
428 !hosted_by_github(job) && !pools.iter().any(|pool| matches(job, &pool.labels))
429 })
430 .map(|job| {
431 (
432 job.id,
433 Waiting::NoPool {
434 labels: job.labels.clone(),
435 },
436 )
437 }),
438 );
439 }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444enum Short {
445 Slot,
446 Memory,
447 Cpu,
448}
449
450fn why_waits(
453 name: &str,
454 profile: &Profile,
455 free_slot: bool,
456 memory: u64,
457 cpu: u64,
458 left: Left,
459 started: &Started<'_>,
460) -> (Waiting, Short) {
461 let profile_name = || name.to_owned();
462 if !free_slot {
463 let reason = match profile.preparing {
464 Some(Preparing::Reseeding) => Waiting::Reseeding {
465 profile: profile_name(),
466 },
467 Some(Preparing::PullingImage) => Waiting::PullingImage {
468 profile: profile_name(),
469 },
470 None => started.for_slot(
471 name,
472 Waiting::NoSlot {
473 profile: profile_name(),
474 },
475 ),
476 };
477 return (reason, Short::Slot);
478 }
479 match (left.memory, left.host, left.cpu) {
480 (Some(free), _, _) if free < signed(memory) => (
481 started.for_memory(
482 memory,
483 Waiting::Memory {
484 needs: memory,
485 free: unsigned(free),
486 },
487 ),
488 Short::Memory,
489 ),
490 (_, Some(free), _) if free < signed(memory) => (
491 started.for_host(
492 memory,
493 Waiting::HostMemory {
494 needs: memory,
495 free: unsigned(free),
496 },
497 ),
498 Short::Memory,
499 ),
500 (_, _, Some(free)) => (
501 started.for_cpu(
502 cpu,
503 Waiting::Cpu {
504 needs: cpu,
505 free: unsigned(free),
506 },
507 ),
508 Short::Cpu,
509 ),
510 (_, _, None) => (
512 Waiting::NoSlot {
513 profile: profile_name(),
514 },
515 Short::Slot,
516 ),
517 }
518}
519
520fn start(
523 repository: &Repository,
524 index: usize,
525 memory: u64,
526 cpu: u64,
527 replacing: Vec<SlotRef>,
528) -> Decision {
529 Decision::Start {
530 slot: SlotRef {
531 profile: repository.profile.clone(),
532 index,
533 },
534 repository: repository.name.clone(),
535 memory,
536 cpu,
537 warm: false,
538 replacing,
539 }
540}
541
542fn start_warm(
546 wanted: &[(&Repository, usize)],
547 profiles: &BTreeMap<String, Profile>,
548 taken: &mut BTreeMap<&str, Vec<bool>>,
549 mut left: Left,
550 decided: &mut Plan,
551) {
552 let room_wanted = decided.waiting.values().any(|reason| {
553 matches!(
554 reason,
555 Waiting::NoSlot { .. }
556 | Waiting::Memory { .. }
557 | Waiting::HostMemory { .. }
558 | Waiting::Cpu { .. }
559 | Waiting::BehindCpu { .. }
560 | Waiting::BehindStart { .. }
561 )
562 });
563 if room_wanted {
564 return;
565 }
566 for round in 0.. {
567 let mut any = false;
568 for (repository, wants) in wanted {
569 if *wants <= round {
570 continue;
571 }
572 let (Some(profile), Some(taken)) = (
573 profiles.get(&repository.profile),
574 taken.get_mut(repository.profile.as_str()),
575 ) else {
576 continue;
577 };
578 let (memory, cpu) = (profile.memory, profile.cpus);
579 let Some(index) = taken.iter().position(|taken| !taken) else {
580 continue;
581 };
582 if !left.fits(memory, cpu) {
583 continue;
584 }
585 taken[index] = true;
586 left.take(memory, cpu);
587 any = true;
588 decided.decisions.push(Decision::Start {
589 slot: SlotRef {
590 profile: repository.profile.clone(),
591 index,
592 },
593 repository: repository.name.clone(),
594 memory,
595 cpu,
596 warm: true,
597 replacing: Vec::new(),
598 });
599 }
600 if !any {
601 break;
602 }
603 }
604}
605
606#[derive(Debug, Clone, Copy)]
609struct Left {
610 memory: Option<i64>,
611 cpu: Option<i64>,
612 host: Option<i64>,
614}
615
616impl Left {
617 fn fits(self, memory: u64, cpu: u64) -> bool {
618 let fits = |free: Option<i64>, needs: u64| free.is_none_or(|free| free >= signed(needs));
619 fits(self.memory, memory) && fits(self.host, memory) && fits(self.cpu, cpu)
620 }
621
622 fn short(self, memory: u64, cpu: u64) -> Left {
625 let lacks = |free: Option<i64>, needs: u64| {
626 free.map(|free| signed(needs).saturating_sub(free).max(0))
627 };
628 Left {
629 memory: lacks(self.memory, memory).max(lacks(self.host, memory)),
630 cpu: lacks(self.cpu, cpu),
631 host: None,
632 }
633 }
634
635 fn take(&mut self, memory: u64, cpu: u64) {
636 let take = |free: &mut Option<i64>, used: u64| {
637 *free = free.map(|free| free.saturating_sub(signed(used)));
638 };
639 take(&mut self.memory, memory);
640 take(&mut self.host, memory);
641 take(&mut self.cpu, cpu);
642 }
643
644 fn give(&mut self, memory: u64, cpu: u64) {
645 let give = |free: &mut Option<i64>, freed: u64| {
646 *free = free.map(|free| free.saturating_add(signed(freed)));
647 };
648 give(&mut self.memory, memory);
649 give(&mut self.host, memory);
650 give(&mut self.cpu, cpu);
651 }
652}
653
654fn left(budget: u64, in_use: u64) -> i64 {
657 signed(budget).saturating_sub(signed(in_use))
658}
659
660fn signed(amount: u64) -> i64 {
662 i64::try_from(amount).unwrap_or(i64::MAX)
663}
664
665fn unsigned(free: i64) -> u64 {
667 u64::try_from(free).unwrap_or(0)
668}
669
670struct Sorted {
672 serve: Vec<u64>,
673 warm: usize,
674}
675
676fn sort_out(
680 repository: &Repository,
681 profiles: &BTreeMap<String, Profile>,
682 now: SystemTime,
683 decided: &mut Plan,
684) -> Option<Sorted> {
685 let mut matching: Vec<&QueuedJob> = repository
686 .queued
687 .iter()
688 .filter(|job| matches(job, &repository.labels))
689 .collect();
690 let profile = profiles.get(&repository.profile);
691 let mut warm = repository.warm;
692 if repository.paused {
693 decided
694 .waiting
695 .extend(matching.iter().map(|job| (job.id, Waiting::Paused)));
696 matching.clear();
698 warm = 0;
699 }
700 let upkeep_due = profile.is_some_and(|profile| profile.upkeep_due);
703 let stop_now = upkeep_due || repository.paused;
704 if upkeep_due {
705 warm = 0;
706 }
707 let (idle, closing): (Vec<(SlotRef, &RunnerSlot)>, Vec<_>) = profile
708 .into_iter()
709 .flat_map(|profile| profile.slots.iter().enumerate())
710 .filter_map(|(index, slot)| match slot {
711 Slot::Runner(runner) if runner.repository == repository.name && !runner.busy => Some((
712 SlotRef {
713 profile: repository.profile.clone(),
714 index,
715 },
716 runner,
717 )),
718 _ => None,
719 })
720 .partition(|(slot, _)| profile.is_none_or(|profile| slot.index < profile.count));
721 decided
723 .decisions
724 .extend(closing.into_iter().map(|(slot, _)| Decision::Stop { slot }));
725 let surplus = idle.len().saturating_sub(matching.len());
726 let stops: Vec<SlotRef> = idle
727 .iter()
728 .filter(|(_, runner)| {
729 stop_now
730 || now
731 .duration_since(runner.started_at)
732 .is_ok_and(|waited| waited >= IDLE_TIMEOUT)
733 })
734 .take(surplus.saturating_sub(warm))
735 .map(|(slot, _)| slot.clone())
736 .collect();
737 let kept: Vec<&SlotRef> = idle
739 .iter()
740 .map(|(slot, _)| slot)
741 .filter(|slot| !stops.contains(slot))
742 .collect();
743 decided
744 .warm
745 .extend(kept.iter().skip(matching.len()).map(|slot| (*slot).clone()));
746 let warm = warm.saturating_sub(kept.len().saturating_sub(matching.len()));
747 decided
748 .decisions
749 .extend(stops.into_iter().map(|slot| Decision::Stop { slot }));
750 let missing = matching.len().saturating_sub(idle.len());
751 let skip = matching.len() - missing;
753 decided.waiting.extend(
754 matching
755 .iter()
756 .take(skip)
757 .map(|job| (job.id, Waiting::RunnerStarting)),
758 );
759 let serve = matching.iter().skip(skip).map(|job| job.id);
760 match repository.held.filter(|held| held.until > now) {
761 Some(held) => {
762 let until = crate::time::ms(held.until) / 1000;
763 decided.waiting.extend(serve.map(|job| {
764 (
765 job,
766 Waiting::Backoff {
767 until,
768 cause: held.cause,
769 },
770 )
771 }));
772 None
773 }
774 None => Some(Sorted {
775 serve: serve.collect(),
776 warm,
777 }),
778 }
779}
780
781struct Warm<'plan> {
783 runners: Vec<Option<(SlotRef, &'plan RunnerSlot)>>,
785}
786
787struct Room {
789 stopped: Vec<SlotRef>,
791 slot: Option<usize>,
793 memory: u64,
794 cpu: u64,
795}
796
797impl<'plan> Warm<'plan> {
798 fn new(
800 warm: &BTreeSet<SlotRef>,
801 profiles: &'plan BTreeMap<String, Profile>,
802 held: bool,
803 ) -> Warm<'plan> {
804 let runners = warm
805 .iter()
806 .filter(|_| !held)
807 .filter_map(
808 |slot| match profiles.get(&slot.profile)?.slots.get(slot.index)? {
809 Slot::Runner(runner) => Some(Some((slot.clone(), runner))),
810 Slot::Free | Slot::Closed => None,
811 },
812 )
813 .collect();
814 Warm { runners }
815 }
816
817 fn make_room(
822 &mut self,
823 repository: &Repository,
824 slot: bool,
825 left: Left,
826 needs_memory: u64,
827 needs_cpu: u64,
828 ) -> Option<Room> {
829 let short = left.short(needs_memory, needs_cpu);
830 let (memory, cpu) = (
831 unsigned(short.memory.unwrap_or(0)),
832 unsigned(short.cpu.unwrap_or(0)),
833 );
834 let mut candidates: Vec<usize> = self
835 .runners
836 .iter()
837 .enumerate()
838 .filter_map(|(at, runner)| {
839 runner
840 .as_ref()
841 .filter(|(slot, runner)| {
842 runner.repository != repository.name || slot.profile != repository.profile
843 })
844 .map(|_| at)
845 })
846 .collect();
847 candidates.sort_by_key(|at| {
849 self.runners[*at]
850 .as_ref()
851 .is_some_and(|(slot, _)| slot.profile != repository.profile)
852 });
853 let mut room = Room {
854 stopped: Vec::new(),
855 slot: None,
856 memory: 0,
857 cpu: 0,
858 };
859 let mut picked = Vec::new();
860 for at in candidates {
861 let short = (slot && room.slot.is_none()) || room.memory < memory || room.cpu < cpu;
862 if !short {
863 break;
864 }
865 let Some((ref_, runner)) = &self.runners[at] else {
866 continue;
867 };
868 let gives_slot = ref_.profile == repository.profile;
869 let helps = (gives_slot && slot && room.slot.is_none())
871 || (room.memory < memory && runner.memory > 0)
872 || (room.cpu < cpu && runner.cpu > 0);
873 if !helps {
874 continue;
875 }
876 if gives_slot && room.slot.is_none() {
877 room.slot = Some(ref_.index);
878 }
879 room.memory += runner.memory;
880 room.cpu += runner.cpu;
881 room.stopped.push(ref_.clone());
882 picked.push(at);
883 }
884 let mut after = left;
886 after.give(room.memory, room.cpu);
887 if (slot && room.slot.is_none()) || !after.fits(needs_memory, needs_cpu) {
888 return None;
889 }
890 for at in picked {
891 self.runners[at] = None;
892 }
893 Some(room)
894 }
895}
896
897struct Started<'plan> {
900 free_slots: BTreeSet<&'plan str>,
902 before: Left,
904 by_profile: BTreeMap<&'plan str, u64>,
906 first: Option<u64>,
907}
908
909impl<'plan> Started<'plan> {
910 fn new(taken: &BTreeMap<&'plan str, Vec<bool>>, before: Left) -> Started<'plan> {
911 Started {
912 free_slots: taken
913 .iter()
914 .filter(|(_, slots)| slots.contains(&false))
915 .map(|(name, _)| *name)
916 .collect(),
917 before,
918 by_profile: BTreeMap::new(),
919 first: None,
920 }
921 }
922
923 fn note(&mut self, profile: &'plan str, job: u64) {
925 self.by_profile.entry(profile).or_insert(job);
926 self.first.get_or_insert(job);
927 }
928
929 fn for_slot(&self, profile: &str, reason: Waiting) -> Waiting {
931 match self.by_profile.get(profile) {
932 Some(older) if self.free_slots.contains(profile) => {
933 Waiting::BehindStart { job: *older }
934 }
935 _ => reason,
936 }
937 }
938
939 fn for_memory(&self, needs: u64, reason: Waiting) -> Waiting {
941 Self::behind(self.first, self.before.memory, needs).unwrap_or(reason)
942 }
943
944 fn for_host(&self, needs: u64, reason: Waiting) -> Waiting {
946 Self::behind(self.first, self.before.host, needs).unwrap_or(reason)
947 }
948
949 fn for_cpu(&self, needs: u64, reason: Waiting) -> Waiting {
951 Self::behind(self.first, self.before.cpu, needs).unwrap_or(reason)
952 }
953
954 fn behind(first: Option<u64>, free: Option<i64>, needs: u64) -> Option<Waiting> {
955 first
956 .filter(|_| free.is_some_and(|free| free >= signed(needs)))
957 .map(|job| Waiting::BehindStart { job })
958 }
959}
960
961fn taken_slots(profiles: &BTreeMap<String, Profile>) -> BTreeMap<&str, Vec<bool>> {
963 profiles
964 .iter()
965 .map(|(name, profile)| {
966 let taken = profile
967 .slots
968 .iter()
969 .map(|slot| profile.preparing.is_some() || *slot != Slot::Free)
970 .collect();
971 (name.as_str(), taken)
972 })
973 .collect()
974}
975
976#[cfg(test)]
977mod tests;