runnerd_core/
scheduler.rs1use std::collections::BTreeMap;
5use std::time::{Duration, SystemTime};
6
7use crate::github::QueuedJob;
8
9pub mod engine;
10
11pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
14
15#[derive(Debug, Clone)]
17pub struct Repository {
18 pub name: String,
20 pub profile: String,
22 pub labels: Vec<String>,
24 pub paused: bool,
26 pub held_until: Option<SystemTime>,
28 pub queued: Vec<QueuedJob>,
30}
31
32#[derive(Debug, Clone)]
34pub struct Profile {
35 pub memory: u64,
37 pub preparing: bool,
39 pub slots: Vec<Slot>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Slot {
46 Free,
48 Runner(RunnerSlot),
50 Closed,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RunnerSlot {
58 pub repository: String,
60 pub busy: bool,
62 pub started_at: SystemTime,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
68pub struct SlotRef {
69 pub profile: String,
71 pub index: usize,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Decision {
78 Start {
80 slot: SlotRef,
82 repository: String,
84 },
85 Stop {
87 slot: SlotRef,
89 },
90}
91
92pub fn matches(job: &QueuedJob, labels: &[String]) -> bool {
95 job.labels.iter().all(|wanted| {
96 labels
97 .iter()
98 .any(|label| label.eq_ignore_ascii_case(wanted))
99 })
100}
101
102pub fn plan(
109 repositories: &[Repository],
110 profiles: &BTreeMap<String, Profile>,
111 free_memory: Option<u64>,
112 now: SystemTime,
113) -> Vec<Decision> {
114 let mut decisions = Vec::new();
115 let mut wanted = BTreeMap::new();
116 let mut jobs = Vec::new();
117 for repository in repositories {
118 let held = repository.held_until.is_some_and(|until| until > now);
119 let matching: Vec<&QueuedJob> = if repository.paused {
120 Vec::new()
121 } else {
122 repository
123 .queued
124 .iter()
125 .filter(|job| matches(job, &repository.labels))
126 .collect()
127 };
128 let idle: Vec<(SlotRef, &RunnerSlot)> = profiles
129 .get(&repository.profile)
130 .into_iter()
131 .flat_map(|profile| profile.slots.iter().enumerate())
132 .filter_map(|(index, slot)| match slot {
133 Slot::Runner(runner) if runner.repository == repository.name && !runner.busy => {
134 Some((
135 SlotRef {
136 profile: repository.profile.clone(),
137 index,
138 },
139 runner,
140 ))
141 }
142 _ => None,
143 })
144 .collect();
145 let surplus = idle.len().saturating_sub(matching.len());
146 decisions.extend(
147 idle.iter()
148 .filter(|(_, runner)| {
149 now.duration_since(runner.started_at)
150 .is_ok_and(|waited| waited >= IDLE_TIMEOUT)
151 })
152 .take(surplus)
153 .map(|(slot, _)| Decision::Stop { slot: slot.clone() }),
154 );
155 if !held {
156 let missing = matching.len().saturating_sub(idle.len());
157 wanted.insert(repository.name.as_str(), missing);
158 let skip = matching.len() - missing;
160 jobs.extend(matching.iter().skip(skip).map(|job| (job.id, repository)));
161 }
162 }
163 jobs.sort_by_key(|(id, _)| *id);
164
165 let mut taken: BTreeMap<&str, Vec<bool>> = profiles
166 .iter()
167 .map(|(name, profile)| {
168 let taken = profile
169 .slots
170 .iter()
171 .map(|slot| profile.preparing || *slot != Slot::Free)
172 .collect();
173 (name.as_str(), taken)
174 })
175 .collect();
176 let mut free_memory = free_memory;
177 for (_, repository) in jobs {
178 let Some(missing) = wanted.get_mut(repository.name.as_str()) else {
179 continue;
180 };
181 if *missing == 0 {
182 continue;
183 }
184 let (Some(profile), Some(taken)) = (
185 profiles.get(&repository.profile),
186 taken.get_mut(repository.profile.as_str()),
187 ) else {
188 continue;
189 };
190 if free_memory.is_some_and(|free| free < profile.memory) {
191 continue;
192 }
193 let Some(index) = taken.iter().position(|taken| !taken) else {
194 continue;
195 };
196 taken[index] = true;
197 *missing -= 1;
198 free_memory = free_memory.map(|free| free - profile.memory);
199 decisions.push(Decision::Start {
200 slot: SlotRef {
201 profile: repository.profile.clone(),
202 index,
203 },
204 repository: repository.name.clone(),
205 });
206 }
207 decisions
208}
209
210#[cfg(test)]
211mod tests;