Skip to main content

runnerd_core/
scheduler.rs

1//! Which runners to start and stop, as a pure function of queued jobs, slots and the memory budget (`plan`), and
2//! the loop that applies those decisions with GitHub and Docker (`engine`). `plan` does no I/O and reads no clock.
3
4use std::collections::BTreeMap;
5use std::time::{Duration, SystemTime};
6
7use crate::github::QueuedJob;
8
9pub mod engine;
10
11/// How long a started runner may wait for a job before it counts as surplus: covers the container's start and
12/// GitHub's assignment.
13pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
14
15/// A repository as the planner sees it.
16#[derive(Debug, Clone)]
17pub struct Repository {
18    /// `owner/name` on GitHub.
19    pub name: String,
20    /// The profile its jobs run in.
21    pub profile: String,
22    /// The labels its runners register with.
23    pub labels: Vec<String>,
24    /// Paused repositories get no new runners; their running jobs finish.
25    pub paused: bool,
26    /// No runner starts for this repository before then, after runners failed to take a job.
27    pub held_until: Option<SystemTime>,
28    /// Its queued jobs at the last poll.
29    pub queued: Vec<QueuedJob>,
30}
31
32/// A profile's slots: one concurrent job each.
33#[derive(Debug, Clone)]
34pub struct Profile {
35    /// Memory a slot's container may use.
36    pub memory: u64,
37    /// While the profile's snapshot is rebuilt or its image pulled, none of its slots starts.
38    pub preparing: bool,
39    /// Index 0 is slot 1.
40    pub slots: Vec<Slot>,
41}
42
43/// What a slot holds.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Slot {
46    /// Nothing: a runner may start here.
47    Free,
48    /// A runner, waiting for its job or running it.
49    Runner(RunnerSlot),
50    /// Free, but over the profile's number of slots, which a reload lowered while its runners ran: nothing starts
51    /// here.
52    Closed,
53}
54
55/// A slot whose container runs a runner registered for one job.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RunnerSlot {
58    /// `owner/name` the runner is registered on.
59    pub repository: String,
60    /// Whether GitHub reports the runner running a job.
61    pub busy: bool,
62    /// When its container started.
63    pub started_at: SystemTime,
64}
65
66/// Where slots are, by profile name and slot index (0 is slot 1).
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
68pub struct SlotRef {
69    /// The profile's name.
70    pub profile: String,
71    /// 0 is slot 1.
72    pub index: usize,
73}
74
75/// What `plan` decides for one slot.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Decision {
78    /// Register a runner for the repository and start it in the slot.
79    Start {
80        /// The free slot.
81        slot: SlotRef,
82        /// `owner/name` the runner registers on.
83        repository: String,
84    },
85    /// Remove the slot's runner, which waited `IDLE_TIMEOUT` for a job no longer queued.
86    Stop {
87        /// The slot whose runner stops.
88        slot: SlotRef,
89    },
90}
91
92/// Whether a job may run on a repository's runners: every label it asks for is one of theirs. GitHub compares
93/// labels without regard to case.
94pub 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
102/// Decides which slots start a runner and which idle runners stop.
103///
104/// Each repository gets a runner per matching queued job, less its runners still waiting for a job. Jobs are
105/// served oldest first across repositories, each in the lowest free slot of its repository's profile, while the
106/// slot's memory fits `free_memory` (`None`: no budget). An idle runner stops once no queued job is left for it and
107/// it waited `IDLE_TIMEOUT`.
108pub 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            // The oldest jobs are the ones still waiting once the idle runners take theirs.
159            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;