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 pub runner_memory: Option<u64>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Profile {
38 pub memory: u64,
40 pub preparing: bool,
42 pub slots: Vec<Slot>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Slot {
49 Free,
51 Runner(RunnerSlot),
53 Closed,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RunnerSlot {
61 pub repository: String,
63 pub busy: bool,
65 pub started_at: SystemTime,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
71pub struct SlotRef {
72 pub profile: String,
74 pub index: usize,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Decision {
81 Start {
83 slot: SlotRef,
85 repository: String,
87 memory: u64,
89 },
90 Stop {
92 slot: SlotRef,
94 },
95}
96
97pub fn matches(job: &QueuedJob, labels: &[String]) -> bool {
100 job.labels.iter().all(|wanted| {
101 labels
102 .iter()
103 .any(|label| label.eq_ignore_ascii_case(wanted))
104 })
105}
106
107pub fn plan(
115 repositories: &[Repository],
116 profiles: &BTreeMap<String, Profile>,
117 free_memory: Option<u64>,
118 now: SystemTime,
119) -> Vec<Decision> {
120 let mut decisions = Vec::new();
121 let mut wanted = BTreeMap::new();
122 let mut jobs = Vec::new();
123 for repository in repositories {
124 let held = repository.held_until.is_some_and(|until| until > now);
125 let matching: Vec<&QueuedJob> = if repository.paused {
126 Vec::new()
127 } else {
128 repository
129 .queued
130 .iter()
131 .filter(|job| matches(job, &repository.labels))
132 .collect()
133 };
134 let idle: Vec<(SlotRef, &RunnerSlot)> = profiles
135 .get(&repository.profile)
136 .into_iter()
137 .flat_map(|profile| profile.slots.iter().enumerate())
138 .filter_map(|(index, slot)| match slot {
139 Slot::Runner(runner) if runner.repository == repository.name && !runner.busy => {
140 Some((
141 SlotRef {
142 profile: repository.profile.clone(),
143 index,
144 },
145 runner,
146 ))
147 }
148 _ => None,
149 })
150 .collect();
151 let surplus = idle.len().saturating_sub(matching.len());
152 decisions.extend(
153 idle.iter()
154 .filter(|(_, runner)| {
155 now.duration_since(runner.started_at)
156 .is_ok_and(|waited| waited >= IDLE_TIMEOUT)
157 })
158 .take(surplus)
159 .map(|(slot, _)| Decision::Stop { slot: slot.clone() }),
160 );
161 if !held {
162 let missing = matching.len().saturating_sub(idle.len());
163 wanted.insert(repository.name.as_str(), missing);
164 let skip = matching.len() - missing;
166 jobs.extend(matching.iter().skip(skip).map(|job| (job.id, repository)));
167 }
168 }
169 jobs.sort_by_key(|(id, _)| *id);
170
171 let mut taken: BTreeMap<&str, Vec<bool>> = profiles
172 .iter()
173 .map(|(name, profile)| {
174 let taken = profile
175 .slots
176 .iter()
177 .map(|slot| profile.preparing || *slot != Slot::Free)
178 .collect();
179 (name.as_str(), taken)
180 })
181 .collect();
182 let mut free_memory = free_memory;
183 for (_, repository) in jobs {
184 let Some(missing) = wanted.get_mut(repository.name.as_str()) else {
185 continue;
186 };
187 if *missing == 0 {
188 continue;
189 }
190 let (Some(profile), Some(taken)) = (
191 profiles.get(&repository.profile),
192 taken.get_mut(repository.profile.as_str()),
193 ) else {
194 continue;
195 };
196 let memory = repository.runner_memory.unwrap_or(profile.memory);
197 if free_memory.is_some_and(|free| free < memory) {
198 continue;
199 }
200 let Some(index) = taken.iter().position(|taken| !taken) else {
201 continue;
202 };
203 taken[index] = true;
204 *missing -= 1;
205 free_memory = free_memory.map(|free| free - memory);
206 decisions.push(Decision::Start {
207 slot: SlotRef {
208 profile: repository.profile.clone(),
209 index,
210 },
211 repository: repository.name.clone(),
212 memory,
213 });
214 }
215 decisions
216}
217
218#[cfg(test)]
219mod tests;