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    /// What a new runner of it takes of the budget: `None`, its profile's `memory`. Under measured admission, the
31    /// largest predicted peak of its queued jobs, since GitHub may give the runner any of them.
32    pub runner_memory: Option<u64>,
33}
34
35/// A profile's slots: one concurrent job each.
36#[derive(Debug, Clone)]
37pub struct Profile {
38    /// Memory a slot's container may use.
39    pub memory: u64,
40    /// While the profile's snapshot is rebuilt or its image pulled, none of its slots starts.
41    pub preparing: bool,
42    /// Index 0 is slot 1.
43    pub slots: Vec<Slot>,
44}
45
46/// What a slot holds.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Slot {
49    /// Nothing: a runner may start here.
50    Free,
51    /// A runner, waiting for its job or running it.
52    Runner(RunnerSlot),
53    /// Free, but over the profile's number of slots, which a reload lowered while its runners ran: nothing starts
54    /// here.
55    Closed,
56}
57
58/// A slot whose container runs a runner registered for one job.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RunnerSlot {
61    /// `owner/name` the runner is registered on.
62    pub repository: String,
63    /// Whether GitHub reports the runner running a job.
64    pub busy: bool,
65    /// When its container started.
66    pub started_at: SystemTime,
67}
68
69/// Where slots are, by profile name and slot index (0 is slot 1).
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
71pub struct SlotRef {
72    /// The profile's name.
73    pub profile: String,
74    /// 0 is slot 1.
75    pub index: usize,
76}
77
78/// What `plan` decides for one slot.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Decision {
81    /// Register a runner for the repository and start it in the slot.
82    Start {
83        /// The free slot.
84        slot: SlotRef,
85        /// `owner/name` the runner registers on.
86        repository: String,
87        /// What it takes of the budget.
88        memory: u64,
89    },
90    /// Remove the slot's runner, which waited `IDLE_TIMEOUT` for a job no longer queued.
91    Stop {
92        /// The slot whose runner stops.
93        slot: SlotRef,
94    },
95}
96
97/// Whether a job may run on a repository's runners: every label it asks for is one of theirs. GitHub compares
98/// labels without regard to case.
99pub 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
107/// Decides which slots start a runner and which idle runners stop.
108///
109/// Each repository gets a runner per matching queued job, less its runners still waiting for a job. Jobs are
110/// served oldest first across repositories, each in the lowest free slot of its repository's profile, while what
111/// the runner takes (`Repository::runner_memory`, by default the slot's memory) fits `free_memory` (`None`: no
112/// budget). An idle runner stops once no queued job is left for it and
113/// it waited `IDLE_TIMEOUT`.
114pub 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            // The oldest jobs are the ones still waiting once the idle runners take theirs.
165            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;