Skip to main content

runnerd_core/
scheduler.rs

1//! Which runners to start and stop, as a pure function of queued jobs, slots, warm runners and the memory and CPU
2//! budgets (`plan`), how many warm runners each repository wants (`warm`), and the loop that applies those decisions
3//! with GitHub and Docker (`engine`). `plan` and `warm` do no I/O and read no clock.
4
5use 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
14/// How long a started runner may wait for a job before it counts as surplus: covers the container's start and
15/// GitHub's assignment.
16pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
17
18/// Once a job has waited this long for CPU, no younger job takes CPU until it fits: smaller jobs arriving all the
19/// time would otherwise take every CPU that frees up before it.
20pub const CPU_PRIORITY_AFTER: Duration = Duration::from_secs(120);
21
22/// A repository's runners held back after they failed.
23#[derive(Debug, Clone, Copy)]
24pub struct Hold {
25    /// No runner starts for it before then.
26    pub until: SystemTime,
27    /// What failed.
28    pub cause: crate::api::HoldCause,
29}
30
31/// Names one of a repository's pools: its runners in one profile (ADR 0058). No two pools of a repository share a
32/// profile.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34pub struct Pool {
35    /// `owner/name` on GitHub.
36    pub repository: String,
37    /// The profile its runners start in.
38    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/// One of a repository's pools as the planner sees it: its runners in one profile, registered with one set of labels
51/// (ADR 0058). A repository with one `[[repositories]]` entry has one.
52#[derive(Debug, Clone)]
53pub struct Repository {
54    /// `owner/name` on GitHub.
55    pub name: String,
56    /// The profile the jobs its labels match run in.
57    pub profile: String,
58    /// The labels its runners register with.
59    pub labels: Vec<String>,
60    /// Paused repositories get no new runners; their running jobs finish.
61    pub paused: bool,
62    /// No runner starts for this repository before then, after its runners failed.
63    pub held: Option<Hold>,
64    /// The repository's queued jobs at the last poll, those of its other pools too.
65    pub queued: Vec<QueuedJob>,
66    /// What a new runner of it takes of the budget: `None`, its profile's `memory`. Under measured admission, the
67    /// largest predicted peak of the queued jobs its labels match, since GitHub may give the runner any of them.
68    pub runner_memory: Option<u64>,
69    /// What a new runner of it takes of the CPU budget, in millicores: `None`, its profile's `cpus`. Under measured
70    /// admission, the largest predicted average CPU of its queued jobs.
71    pub runner_cpu: Option<u64>,
72    /// Since when its next job waits for CPU that a slot and memory have room for.
73    pub cpu_waiting_since: Option<SystemTime>,
74    /// How many idle runners it keeps beyond its queued jobs, for jobs not queued yet (`warm::target`).
75    pub warm: usize,
76}
77
78impl Repository {
79    /// Its name as a pool.
80    pub fn pool(&self) -> Pool {
81        Pool {
82            repository: self.name.clone(),
83            profile: self.profile.clone(),
84        }
85    }
86
87    /// Whether its next job has waited `CPU_PRIORITY_AFTER` or more for CPU at `now`.
88    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/// A profile's slots: one concurrent job each.
96#[derive(Debug, Clone)]
97pub struct Profile {
98    /// Memory a slot's container may use.
99    pub memory: u64,
100    /// CPU a slot's container may use, in millicores; under a CPU budget, at most the budget, as a runner at its caps
101    /// reserves it.
102    pub cpus: u64,
103    /// While the profile's snapshot is rebuilt or its image pulled, none of its slots starts.
104    pub preparing: Option<Preparing>,
105    /// Upkeep waits for its runners, all idle: its reseed (slot 1 ran a job since the last), or the shared caches'
106    /// check, which waits for every profile's. Its warm runners stop, and none starts, so the upkeep can run.
107    pub upkeep_due: bool,
108    /// Its configured `slots`. A reload that lowered them leaves runners past them, whose slots close once they end.
109    pub count: usize,
110    /// Index 0 is slot 1.
111    pub slots: Vec<Slot>,
112}
113
114/// Why none of a profile's slots starts for now.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Preparing {
117    /// Slot 1's layers become the profile's snapshot.
118    Reseeding,
119    /// Its image is being pulled.
120    PullingImage,
121}
122
123/// What a slot holds.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Slot {
126    /// Nothing: a runner may start here.
127    Free,
128    /// A runner, waiting for its job or running it.
129    Runner(RunnerSlot),
130    /// Free, but over the profile's number of slots, which a reload lowered while its runners ran: nothing starts
131    /// here.
132    Closed,
133}
134
135/// A slot whose container runs a runner registered for one job.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RunnerSlot {
138    /// `owner/name` the runner is registered on.
139    pub repository: String,
140    /// Whether GitHub reports the runner running a job.
141    pub busy: bool,
142    /// When its container started.
143    pub started_at: SystemTime,
144    /// What it takes of the memory budget, which it gives back if it stops.
145    pub memory: u64,
146    /// What it takes of the CPU budget, in millicores.
147    pub cpu: u64,
148}
149
150/// Where slots are, by profile name and slot index (0 is slot 1).
151#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
152pub struct SlotRef {
153    /// The profile's name.
154    pub profile: String,
155    /// 0 is slot 1.
156    pub index: usize,
157}
158
159/// What `plan` decides for one slot.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Decision {
162    /// Register a runner for the repository and start it in the slot.
163    Start {
164        /// The free slot.
165        slot: SlotRef,
166        /// `owner/name` the runner registers on.
167        repository: String,
168        /// What it takes of the budget.
169        memory: u64,
170        /// What it takes of the CPU budget, in millicores.
171        cpu: u64,
172        /// Whether it starts for no queued job, as a warm runner.
173        warm: bool,
174        /// The warm runners this plan stops to make room for it, just before: if one is still there, it took a job
175        /// meanwhile and the start waits for the next plan.
176        replacing: Vec<SlotRef>,
177    },
178    /// Hold the next job of the repository's pool in `profile`: a slot and memory had room for its runner, the CPU
179    /// budget did not.
180    HeldForCpu {
181        /// `owner/name`.
182        repository: String,
183        /// The pool's profile.
184        profile: String,
185    },
186    /// Remove the slot's runner: it waited `IDLE_TIMEOUT` for a job no longer queued and is not kept warm, or it is a
187    /// warm runner whose room a queued job takes.
188    Stop {
189        /// The slot whose runner stops.
190        slot: SlotRef,
191    },
192}
193
194/// What `plan` decides, and why each queued job it starts no runner for waits.
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct Plan {
197    /// What to start, hold and stop.
198    pub decisions: Vec<Decision>,
199    /// Why each queued job of a repository's matching jobs waits, by job ID. A job an idle runner will take is
200    /// `Waiting::RunnerStarting`; a job this plan starts a runner for has no reason, as that runner does not exist
201    /// until the start succeeds.
202    pub waiting: BTreeMap<u64, Waiting>,
203    /// The idle runners kept warm: no queued job of their repository waits for them.
204    pub warm: BTreeSet<SlotRef>,
205}
206
207/// The budgets a plan starts runners within.
208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
209pub struct Budget {
210    /// The memory left, negative by what the running containers take past `MemoryMax` (`left`); `None` for no
211    /// budget.
212    pub memory: Option<i64>,
213    /// The CPU left, in millicores, negative by what the runners reserve past the budget (`left`); `None` for no
214    /// budget.
215    pub cpu: Option<i64>,
216    /// The memory the host has to spare for new runners beside other programs (ADR 0055), negative by what the
217    /// running ones reserve past the host's limit; `None` when not known.
218    pub host: Option<i64>,
219    /// Nothing starts, so no warm runner stops to make room: measured admission holds the slice at its high-water
220    /// mark, or the host's tasks stall for memory.
221    pub held: bool,
222}
223
224/// The workflow labels of GitHub-hosted runners, from
225/// <https://github.com/github/docs/blob/main/data/reusables/actions/supported-github-runners.md> and its single-CPU
226/// row (`single-cpu-table-row.md`), and the larger macOS runners of
227/// <https://github.com/github/docs/blob/main/data/reusables/actions/larger-runners-table.md>, as of 2026-10-16. Update it when GitHub adds or retires an image: a label
228/// missing here makes its jobs wait with `Waiting::NoPool`, whose wording stays true for them.
229const 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
262/// Whether GitHub runs a job on its own runners: it asks for exactly one label, one of `GITHUB_HOSTED`. No pool of
263/// this daemon is meant for it. A typo, a custom label and an image name beside `self-hosted` are the owner's to
264/// fix, so they wait for a pool instead.
265fn 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
269/// Whether a job may run on a repository's runners: every label it asks for is one of theirs. GitHub compares
270/// labels without regard to case.
271pub fn matches(job: &QueuedJob, labels: &[String]) -> bool {
272    crate::config::labels_match(&job.labels, labels)
273}
274
275/// Decides which slots start a runner and which idle runners stop, and why each job left queued waits.
276///
277/// `repositories` holds each repository's pools; a job is served by the pool whose labels match it, and one no pool
278/// of its repository matches waits for a pool (`Waiting::NoPool`) unless it is for GitHub's own runners. Each pool
279/// gets a runner per matching queued job, less its runners still waiting for a job. Jobs are
280/// served oldest first across repositories, each in the lowest free slot of its repository's profile, while what
281/// the runner takes (`Repository::runner_memory`, by default the slot's memory) fits `budget.memory` and
282/// `budget.host`, and its CPU
283/// (`Repository::runner_cpu`, by default the slot's `cpus`) fits `budget.cpu` (`None`: no budget). A job that does
284/// not fit takes the room of warm runners of other repositories, which stop. A repository whose job had a slot and
285/// memory but not CPU is held once (`Decision::HeldForCpu`). An idle runner stops once no queued job is left for it,
286/// it is not one of its repository's `warm` runners, and it waited `IDLE_TIMEOUT`. Once a held repository waited
287/// `CPU_PRIORITY_AFTER`, no younger job starts until its own fits. Last, while no job waits for room, each
288/// repository short of its `warm` runners starts them at its profile's caps, one per repository at a time.
289pub 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    // The job that waited long enough for CPU that no younger one takes CPU before it.
318    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                    // It waited long enough: no younger job takes CPU until it fits.
367                    if repository.starved_of_cpu(now) {
368                        first_for_cpu = Some(job);
369                    }
370                }
371                continue;
372            }
373        };
374        // The warm runners whose room it takes stop first.
375        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
399/// The hold of `repository`'s next job for CPU.
400fn held_for_cpu(repository: &Repository) -> Decision {
401    Decision::HeldForCpu {
402        repository: repository.name.clone(),
403        profile: repository.profile.clone(),
404    }
405}
406
407/// A pool's repository and profile, which tell it apart in a plan.
408fn pool_key(repository: &Repository) -> (&str, &str) {
409    (&repository.name, &repository.profile)
410}
411
412/// Says why each queued job no pool of its repository matches waits, unless GitHub runs it on its own runners.
413fn 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/// What a job's runner lacks.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444enum Short {
445    Slot,
446    Memory,
447    Cpu,
448}
449
450/// Why a job whose runner of `memory` and `cpu` does not fit in `profile` (named `name`) waits, given whether it has
451/// a `free_slot` and the budgets `left`, and what it lacks.
452fn 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        // `fits` said otherwise.
511        (_, _, None) => (
512            Waiting::NoSlot {
513                profile: profile_name(),
514            },
515            Short::Slot,
516        ),
517    }
518}
519
520/// A runner of `repository` in slot `index` of its profile for a queued job, after the warm runners it `replaces`
521/// stop.
522fn 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
542/// Starts the warm runners each repository is short of (`wanted`), at its profile's caps, in the slots and budget
543/// the jobs `left`: one per repository at a time, so a few free slots go to several repositories rather than to the
544/// first. None while a job waits for room they would take.
545fn 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/// The memory, CPU and host budgets a plan has left; `None`, no budget. Each is negative while what runs takes past
607/// it: a stopped runner's reservation pays that back first, so a start never takes room that is owed.
608#[derive(Debug, Clone, Copy)]
609struct Left {
610    memory: Option<i64>,
611    cpu: Option<i64>,
612    /// The host's memory to spare, which a runner's memory takes from as it does from `memory`.
613    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    /// What a runner of `memory` and `cpu` lacks, the host's memory counted with the slice's: none where there is
623    /// no budget.
624    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
654/// What `budget` leaves beside what runs `in_use`: negative by what runs past it, so a deficit stays owed rather
655/// than hidden at 0.
656fn left(budget: u64, in_use: u64) -> i64 {
657    signed(budget).saturating_sub(signed(in_use))
658}
659
660/// A budget's bytes or millicores, signed as `Left` counts them.
661fn signed(amount: u64) -> i64 {
662    i64::try_from(amount).unwrap_or(i64::MAX)
663}
664
665/// A signed budget as a job's wait shows it: none once it is spent.
666fn unsigned(free: i64) -> u64 {
667    u64::try_from(free).unwrap_or(0)
668}
669
670/// What `sort_out` leaves a repository to start: runners for the IDs of its jobs, and warm runners.
671struct Sorted {
672    serve: Vec<u64>,
673    warm: usize,
674}
675
676/// Stops a repository's idle runners no job is left for, beyond its warm ones, and says why its jobs wait when it is
677/// paused or held, or which an idle runner will take. Returns the jobs it wants a runner for and how many warm
678/// runners it is short of, unless it is held.
679fn 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        // None is left for its idle runners, which stop at once.
697        matching.clear();
698        warm = 0;
699    }
700    // Upkeep waits for its profile's runners: none is kept, and none waits out `IDLE_TIMEOUT` first. Nor does a
701    // paused repository's: GitHub would give it a job queued before the pause.
702    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    // Idle runners in slots a lowered `slots` closed stop now: neither warm nor room for a job, their slots close.
722    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    // The idle runners left past those its jobs take are warm.
738    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    // The oldest jobs are the ones still waiting once the idle runners take theirs.
752    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
781/// The warm runners a queued job may stop to make room for itself.
782struct Warm<'plan> {
783    /// Each warm runner with what it takes, in slot order; `None` once stopped for a job.
784    runners: Vec<Option<(SlotRef, &'plan RunnerSlot)>>,
785}
786
787/// The room some warm runners give back.
788struct Room {
789    /// The warm runners to stop.
790    stopped: Vec<SlotRef>,
791    /// A slot of the job's profile among them, when it needed one.
792    slot: Option<usize>,
793    memory: u64,
794    cpu: u64,
795}
796
797impl<'plan> Warm<'plan> {
798    /// The warm runners of `warm`; none while the slice is `held`, when no start may use their room.
799    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    /// Picks warm runners of pools other than `repository` whose room lets its next runner, of `needs_memory` and
818    /// `needs_cpu`, start in the budgets `left`: a slot of its profile when it has none (`slot`), and the memory
819    /// and CPU it is short of. Those in its profile go first. `None`, and nothing picked, when all of them would
820    /// not do.
821    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        // Its own profile's first: their slots serve it too.
848        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            // A runner that gives nothing the job still lacks stays.
870            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        // What they give back must let it start: a start never takes what the stops do not free.
885        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
897/// What a plan's own starts took, so a job held only by them waits behind them rather than for a slot, memory or CPU
898/// in use. The status explains with a plan it does not run, whose starts may never happen.
899struct Started<'plan> {
900    /// The profiles with a free slot before the plan's starts.
901    free_slots: BTreeSet<&'plan str>,
902    /// The budgets left before them.
903    before: Left,
904    /// The first job each profile, and any profile, starts a runner for.
905    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    /// The plan starts a runner for `job` in `profile`.
924    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    /// `reason` for a job `profile` has no slot for, unless only this plan's starts took its free slots.
930    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    /// `reason` for a job whose `needs` of memory do not fit, unless they did before this plan's starts.
940    fn for_memory(&self, needs: u64, reason: Waiting) -> Waiting {
941        Self::behind(self.first, self.before.memory, needs).unwrap_or(reason)
942    }
943
944    /// `reason` for a job whose `needs` of memory the host cannot spare, unless it could before this plan's starts.
945    fn for_host(&self, needs: u64, reason: Waiting) -> Waiting {
946        Self::behind(self.first, self.before.host, needs).unwrap_or(reason)
947    }
948
949    /// `reason` for a job whose `needs` of CPU do not fit, unless they did before this plan's starts.
950    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
961/// Whether each slot of each profile is taken, by profile name: by a runner, closed, or its profile preparing.
962fn 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;