runnerd_core/scheduler/engine.rs
1//! The scheduling loop: polls GitHub, pulls the profiles' images, applies `plan`'s decisions with GitHub and
2//! Docker, removes each runner's container when its job ends, keeps the mirror up while any runner runs, reseeds
3//! idle profiles and recovers runners and containers a previous daemon left. It also feeds the job history: queued
4//! jobs, the job each runner took (from `observe::capture`), steps from GitHub, mirror bytes, cache sizes and slot
5//! and budget changes. It owns slot state; it never serves the API (it publishes `api::Status` and takes commands
6//! through `Control`) and never logs the token or a JIT config.
7//!
8//! This module holds `Engine`'s state, its construction and its loop; the submodules each add one concern's
9//! methods: `recover` (startup), `tick` (polling and planning), `runners` (a slot's runner from start to removal),
10//! `images` (pulling the profiles' images), `caches` (reseeds, the mirror and the shared caches), `build_cache` (each
11//! runner's side of the shared build cache and whether its job may write), `history` (what
12//! the store records), `warm` (how many warm runners each repository wants), `control` (the API's commands) and
13//! `status` (what it publishes).
14
15mod admission;
16mod build_cache;
17mod caches;
18mod control;
19mod history;
20mod images;
21mod records;
22mod recover;
23mod runners;
24mod status;
25mod tick;
26mod warm;
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, SystemTime};
31
32use tokio::sync::{mpsc, watch};
33use tokio::task::JoinHandle;
34use tracing::{info, warn};
35
36pub use self::control::{
37 Command, Commands, Commit, Control, ControlError, RunningJobs, RunningSlots, Shutdown, Target,
38};
39use self::history::{Closing, Found};
40use self::images::Images;
41pub use self::records::RecordSelection;
42use super::{Hold, SlotRef};
43use crate::admission::Key;
44use crate::api;
45use crate::buildcache::BuildCache;
46use crate::cache::{self, Names, Trimmed};
47use crate::config::Config;
48use crate::docker::{self, Docker, DockerApi};
49use crate::github::{Github, GithubApi, QueuedJob};
50use crate::isolation::{self, Isolation};
51use crate::observe::metrics::{self, Paths, Sampler, Tracked};
52use crate::observe::mirror::Leases;
53use crate::store::{Announced, MirrorPosition, Store};
54
55/// This machine as its runner names carry it: `runnerd-<id>-<host>-<profile>-<slot>-<secs>`. Only `runnerd-<id>-`
56/// identifies the machine, so a changed host name still matches the runners started before the change, and two
57/// machines with the same or overlapping host names never take each other's runners for their own.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HostIdentity {
60 /// 8 hex digits: a hash of the systemd machine ID, never the ID itself, as machine-id(5) asks.
61 id: String,
62 /// The host name in lower case, letters, digits and dashes, at most 20 characters: for people reading names.
63 host: String,
64}
65
66impl HostIdentity {
67 /// This machine's identity, from `/etc/machine-id` and its host name.
68 pub fn read() -> std::io::Result<HostIdentity> {
69 let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default();
70 let machine_id = std::fs::read_to_string("/etc/machine-id")
71 .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
72 .map_err(|error| {
73 std::io::Error::new(
74 error.kind(),
75 format!("cannot read /etc/machine-id: {error}"),
76 )
77 })?;
78 HostIdentity::new(&hostname, &machine_id).ok_or_else(|| {
79 std::io::Error::new(
80 std::io::ErrorKind::InvalidData,
81 "/etc/machine-id does not hold a machine ID",
82 )
83 })
84 }
85
86 /// The identity for a host name and a machine ID; `None` unless the ID is 32 hex digits.
87 pub fn new(hostname: &str, machine_id: &str) -> Option<HostIdentity> {
88 let machine_id = machine_id.trim();
89 if machine_id.len() != 32 || !machine_id.chars().all(|c| c.is_ascii_hexdigit()) {
90 return None;
91 }
92 // FNV-1a: stable across Rust releases, unlike std's hasher, so a new build recognizes the runners of the old.
93 let hash = format!("runnerd:{}", machine_id.to_ascii_lowercase())
94 .bytes()
95 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
96 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
97 });
98 let host: String = hostname
99 .trim()
100 .chars()
101 .map(|c| {
102 if c.is_ascii_alphanumeric() {
103 c.to_ascii_lowercase()
104 } else {
105 '-'
106 }
107 })
108 .take(20)
109 .collect();
110 let host = host.trim_matches('-');
111 Some(HostIdentity {
112 id: format!("{:08x}", hash >> 32),
113 host: if host.is_empty() { "host" } else { host }.to_owned(),
114 })
115 }
116
117 /// What every runner name of this machine starts with. The ID's fixed length keeps it from starting another
118 /// machine's names.
119 pub fn prefix(&self) -> String {
120 format!("runnerd-{}-", self.id)
121 }
122
123 /// The name of a runner, and of its container, started in `slot` (1-based) of `profile` at `secs`.
124 pub fn runner_name(&self, profile: &str, slot: usize, secs: u64) -> String {
125 format!("{}{}-{profile}-{slot}-{secs}", self.prefix(), self.host)
126 }
127}
128
129/// What the API needs from a running engine.
130#[derive(Debug, Clone)]
131pub struct Handle {
132 /// The latest status, replaced after every tick.
133 pub status: watch::Receiver<api::Status>,
134 /// Pause, resume and shutdown requests.
135 pub control: Arc<Control>,
136 /// The job history.
137 pub store: Store,
138 /// Every cache volume of the config, with size 0: the API fills in the sizes it measured. Replaced when the
139 /// config is.
140 pub caches: watch::Receiver<Vec<api::CacheVolume>>,
141 /// The records of failed jobs, replaced whenever one is made or removed.
142 pub records: watch::Receiver<api::Records>,
143 /// The shared build cache, for its size now.
144 pub build_cache: Option<BuildCache>,
145}
146
147/// Where the engine runs, besides its config and its GitHub and Docker clients.
148#[derive(Debug)]
149pub struct Setup {
150 /// The names of what runnerd creates in Docker.
151 pub names: Names,
152 /// This machine, which names its runners, so a restarted daemon recognizes them.
153 pub identity: HostIdentity,
154 /// The slice's `MemoryMax`.
155 pub memory_max: Option<u64>,
156 /// The CPUs the slice may use, in millicores (`config::slice_cpus`); `None` admits no runner by CPU.
157 pub cpus: Option<u64>,
158 /// The job history.
159 pub store: Store,
160 /// The profiles and repositories as the last run announced them (`Store::announced`), so the first publish
161 /// announces only the difference, including what the config no longer has.
162 pub announced: Announced,
163 /// The repositories recorded paused (`Store::paused_repositories`): they stay paused from the first tick.
164 pub paused: Vec<String>,
165 /// Where the cgroup and proc file systems are.
166 pub paths: Paths,
167 /// The shared build cache, unless the daemon could not open it.
168 pub build_cache: Option<BuildCache>,
169}
170
171/// Why a runner starts.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum Purpose {
174 /// For a queued job.
175 Job,
176 /// As a warm runner, for jobs not queued yet.
177 Warm,
178}
179
180/// A slot's runner and its container.
181#[derive(Debug, Clone)]
182struct Occupant {
183 repository: String,
184 runner_name: String,
185 runner_id: u64,
186 container: String,
187 busy: bool,
188 started_at: SystemTime,
189 /// Its container's memory cap, which a reload does not change.
190 memory: u64,
191 /// Its container's CPU limit in millicores, which a reload does not change.
192 cpus: u64,
193 /// How it was admitted, with what it reserved of the budget, kept through a restart by its record; `None` for
194 /// one whose record says nothing of it, which reserves its cap.
195 admission: Option<api::Admission>,
196 /// The job it took, once its log capture found it.
197 job: Option<u64>,
198 /// That job as admission tells jobs apart, once the history had its record.
199 key: Option<Key>,
200 /// The labels it registered with; `None` for one adopted from a container that does not say.
201 labels: Option<Vec<String>>,
202 /// The image it runs; `None` for one adopted from a container that does not say.
203 image: Option<String>,
204 /// Whether `observe` watches it: recorded, sampled and its job captured. Not while Docker fails to inspect its
205 /// container, which each tick tries again.
206 observed: bool,
207}
208
209impl Occupant {
210 /// Whether it no longer is what `repository`'s runners in `profile` start as: other labels, or other limits than
211 /// the profile's. An unknown label set counts as the same.
212 fn outdated(
213 &self,
214 repository: &crate::config::Repository,
215 profile: &crate::config::Profile,
216 ) -> bool {
217 let differ = self.labels.as_ref().is_some_and(|labels| {
218 let set = |labels: &[String]| -> BTreeSet<String> {
219 labels
220 .iter()
221 .map(|label| label.to_ascii_lowercase())
222 .collect()
223 };
224 set(labels) != set(&repository.labels)
225 });
226 differ || self.memory != profile.memory.0 || self.cpus != u64::from(profile.cpus) * 1000
227 }
228
229 /// Whether it runs another image than `profile` names. An unknown image counts as the same.
230 fn other_image(&self, profile: &crate::config::Profile) -> bool {
231 self.image
232 .as_ref()
233 .is_some_and(|image| *image != profile.image.reference())
234 }
235
236 /// What it reserves of the budget under measured admission: its cap if it has no admission.
237 fn reserved(&self) -> u64 {
238 self.admission
239 .map_or(self.memory, |admission| admission.reserved)
240 }
241
242 /// The CPU it reserves, in millicores: its CPU limit unless it was admitted by CPU.
243 fn reserved_cpu(&self) -> u64 {
244 self.admission
245 .and_then(|admission| admission.cpu)
246 .map_or(self.cpus, |cpu| cpu.reserved)
247 }
248
249 fn running_job(&self) -> api::RunningJob {
250 api::RunningJob {
251 repository: self.repository.clone(),
252 runner: self.runner_name.clone(),
253 job: self.job,
254 }
255 }
256}
257
258/// A repository's state, shared by its pools but for their holds and what they wait for CPU.
259#[derive(Debug, Default)]
260struct RepositoryState {
261 queued: Vec<QueuedJob>,
262 /// Whether the last poll of its queued jobs succeeded.
263 listed: bool,
264 /// By the profile of each of its pools, that pool's runners held back after they failed: a broken pool does
265 /// not hold back the others (ADR 0058).
266 holds: BTreeMap<String, PoolHold>,
267 /// By the profile of each of its pools, that pool's oldest job held for CPU that memory and a slot had room
268 /// for, and since when, kept while that job stays queued: a tick held by memory or slots in between does not
269 /// make it younger.
270 cpu_wait: BTreeMap<String, CpuWait>,
271 /// The profiles of its pools whose next job the last plan held for CPU.
272 cpu_held: BTreeSet<String>,
273}
274
275/// A pool's runners held back after they failed, as `RepositoryState::holds` keeps them.
276#[derive(Debug, Default)]
277struct PoolHold {
278 held: Option<Hold>,
279 /// Runners in a row that ended without taking a job.
280 failures: u32,
281 /// Runners in a row that ended without a job after waiting `IDLE_TIMEOUT` for one.
282 idle_exits: u32,
283}
284
285/// A job waiting for CPU, as `RepositoryState::cpu_wait` keeps it.
286#[derive(Debug, Clone, Copy)]
287struct CpuWait {
288 /// The job's ID.
289 job: u64,
290 /// When a plan first held it for CPU.
291 since: SystemTime,
292}
293
294/// The scheduler with its side effects, on the real GitHub and Docker unless a test passes fakes.
295#[derive(Debug)]
296pub struct Engine<G = Github, D = Docker> {
297 config: Config,
298 /// Shared with the listings a tick reads while it answers commands.
299 github: Arc<G>,
300 docker: Arc<D>,
301 names: Names,
302 isolation: &'static dyn Isolation,
303 identity: HostIdentity,
304 runner_prefix: String,
305 memory_max: Option<u64>,
306 /// The CPUs the slice may use, in millicores, read at start.
307 cpus: Option<u64>,
308 slots: BTreeMap<String, Vec<Option<Occupant>>>,
309 repositories: BTreeMap<String, RepositoryState>,
310 reseeds: BTreeMap<String, JoinHandle<Result<String, docker::Error>>>,
311 /// The pulled images of the config's profiles, and the removal of those no profile runs.
312 images: Images,
313 /// The memory cap each running reseed's container started with, which a reload does not change.
314 reseed_memory: BTreeMap<String, u64>,
315 /// Profiles the API asked to reseed: they start nothing until their slots are free and the reseed ran.
316 reseed_requested: BTreeSet<String>,
317 /// Repositories a reload removed, polled until none of this machine's runners is registered on them.
318 draining: BTreeSet<String>,
319 /// The runner and shell containers an earlier run left that recovery failed to remove, by ID with their names:
320 /// each tick tries again.
321 leftover: BTreeMap<String, String>,
322 /// Profiles a reload removed, kept while their runners or reseed still run.
323 retired: BTreeMap<String, crate::config::Profile>,
324 /// Profiles whose slot 1 ran a job since their last reseed check.
325 reseed_due: BTreeSet<String>,
326 /// Profiles whose snapshot the last check of the caches measured over `snapshot_max`: due, and reseeded however
327 /// little slot 1 changed.
328 oversized: BTreeSet<String>,
329 /// When each profile's last reseed finished: a check of the caches that started before then measured the old
330 /// snapshot.
331 reseeded_at: BTreeMap<String, SystemTime>,
332 /// What the slots' Docker Engines may keep together, from the last check of the caches (`cache::docker_disk`);
333 /// `None` until one measured Docker's disk, when each image keeps its own default.
334 docker_disk: Option<u64>,
335 /// The measure of Docker's disk recovery started, in the background: Docker measures every volume.
336 disk: Option<JoinHandle<Result<caches::Disk, docker::Error>>>,
337 /// Whether a job ended since the shared caches were last checked.
338 trim_due: bool,
339 /// The running check of the shared caches: Docker takes many seconds to measure volumes.
340 trim: Option<JoinHandle<Result<Trimmed, docker::Error>>>,
341 /// When the last check of the shared caches started; the daemon's start counts as one, as the first runs then
342 /// unless runners were adopted.
343 checked_at: SystemTime,
344 mirror_up: bool,
345 /// The memory cap the running mirror started with.
346 mirror_memory: u64,
347 rate_limit_logged: bool,
348 /// Runners without a container that GitHub still holds busy, already logged.
349 busy_orphans: BTreeSet<u64>,
350 status: watch::Sender<api::Status>,
351 /// The profiles as the last run announced them, until the first publish compares the profiles with them. Their
352 /// slots are older than the `slot.changed` events clients have, so that publish announces every slot instead.
353 announced_profiles: Option<Vec<api::ProfileStatus>>,
354 control: Arc<Control>,
355 commands: Commands,
356 caches: watch::Sender<Vec<api::CacheVolume>>,
357 store: Store,
358 paths: Paths,
359 /// The containers the sampler reads.
360 tracked: watch::Sender<Vec<Tracked>>,
361 found: (mpsc::UnboundedSender<Found>, mpsc::UnboundedReceiver<Found>),
362 captures: BTreeMap<String, JoinHandle<()>>,
363 /// Who held which address, for the mirror's log.
364 leases: Leases,
365 /// How far the mirror's log was counted.
366 mirror_until: MirrorPosition,
367 /// Jobs recorded queued and not yet taken or closed, with their repository.
368 queued_jobs: BTreeMap<u64, String>,
369 /// Runners of runs a forced pause cancelled, with when they are removed if they still run.
370 cancelling: BTreeMap<String, tokio::time::Instant>,
371 /// Jobs whose runner is gone, waiting for GitHub to report them completed.
372 closing: BTreeMap<u64, Closing>,
373 last_prune: Option<SystemTime>,
374 /// The records of failed jobs and the shells open in them.
375 records: records::RecordState,
376 /// The predictor and the high-water mark of measured admission, and what admission saw of the host.
377 measured: admission::Measured,
378 /// What warm runners learned from the history, and the workflow runs seen lately.
379 warm: warm::Warm,
380 /// The shared build cache.
381 build_cache: Option<BuildCache>,
382 /// Each repository's default branch, once a job of it needed it: the branch whose builds write to the build
383 /// cache when `[caches.build] branches` names none.
384 default_branches: BTreeMap<String, build_cache::DefaultBranch>,
385 /// Removed runners' build cache uploads held until `follow_jobs` asks GitHub of their jobs, by runner.
386 ended_uploads: BTreeMap<String, build_cache::EndedUploads>,
387 /// When each repository's visibility is next asked of GitHub, which the asking task updates; the store keeps
388 /// the answers.
389 visibility_due: Arc<Mutex<BTreeMap<String, SystemTime>>>,
390 /// The task asking GitHub repositories' visibility, off the tick (`follow_visibility`).
391 visibility_asking: Option<JoinHandle<()>>,
392}
393
394fn unix_secs(time: SystemTime) -> u64 {
395 time.duration_since(SystemTime::UNIX_EPOCH)
396 .unwrap_or_default()
397 .as_secs()
398}
399
400impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
401 /// An engine for a validated config. `identity` names this machine's runners, so a restarted daemon recognizes
402 /// them; `memory_max` is the slice's.
403 #[allow(
404 clippy::too_many_lines,
405 reason = "one line per field of the engine's state, which split up would only be listed twice"
406 )]
407 pub fn new(config: Config, github: G, docker: D, setup: Setup) -> (Engine<G, D>, Handle) {
408 let Setup {
409 names,
410 identity,
411 memory_max,
412 cpus,
413 store,
414 announced,
415 paused,
416 paths,
417 build_cache,
418 } = setup;
419 let slots = config
420 .profiles
421 .iter()
422 .map(|(name, profile)| (name.clone(), vec![None; profile.slots as usize]))
423 .collect();
424 let repositories = config
425 .repositories
426 .iter()
427 .map(|repository| (repository.name.clone(), RepositoryState::default()))
428 .collect();
429 let (control, commands) = Control::new(
430 config
431 .repositories
432 .iter()
433 .map(|repository| repository.name.clone()),
434 paused,
435 store.clone(),
436 );
437 let control = Arc::new(control);
438 let (status, receiver) = watch::channel(api::Status {
439 version: env!("CARGO_PKG_VERSION").to_owned(),
440 shutting_down: false,
441 budget: api::Budget {
442 slice: config.host.slice.clone(),
443 memory_max,
444 ..api::Budget::default()
445 },
446 profiles: Vec::new(),
447 // What clients resuming after the last run's events have, so the first publish announces the difference.
448 repositories: announced.repositories,
449 queued: Vec::new(),
450 });
451 let (caches, caches_receiver) = watch::channel(cache::volumes(&names, &config));
452 let (records, records_receiver) = records::RecordState::new();
453 let mut engine = Engine {
454 isolation: isolation::backend(config.host.isolation),
455 // Unknown: an earlier run may have left the mirror up, with the configured cap at best.
456 mirror_memory: config.mirror_memory(),
457 config,
458 github: Arc::new(github),
459 docker: Arc::new(docker),
460 names,
461 runner_prefix: identity.prefix(),
462 identity,
463 memory_max,
464 cpus,
465 slots,
466 repositories,
467 reseeds: BTreeMap::new(),
468 images: Images::new(),
469 reseed_memory: BTreeMap::new(),
470 reseed_requested: BTreeSet::new(),
471 retired: BTreeMap::new(),
472 draining: BTreeSet::new(),
473 leftover: BTreeMap::new(),
474 reseed_due: BTreeSet::new(),
475 oversized: BTreeSet::new(),
476 reseeded_at: BTreeMap::new(),
477 docker_disk: None,
478 disk: None,
479 // So the caches are measured once at start.
480 trim_due: true,
481 trim: None,
482 checked_at: SystemTime::now(),
483 // Unknown: an earlier run may have left it up, so the first idle tick stops it.
484 mirror_up: true,
485 rate_limit_logged: false,
486 busy_orphans: BTreeSet::new(),
487 status,
488 announced_profiles: Some(announced.profiles),
489 control: control.clone(),
490 commands,
491 caches,
492 store: store.clone(),
493 paths,
494 tracked: watch::channel(Vec::new()).0,
495 found: mpsc::unbounded_channel(),
496 captures: BTreeMap::new(),
497 leases: Leases::default(),
498 mirror_until: MirrorPosition::default(),
499 queued_jobs: BTreeMap::new(),
500 cancelling: BTreeMap::new(),
501 closing: BTreeMap::new(),
502 last_prune: None,
503 measured: admission::Measured::default(),
504 warm: warm::Warm::default(),
505 records,
506 build_cache: build_cache.clone(),
507 default_branches: BTreeMap::new(),
508 ended_uploads: BTreeMap::new(),
509 visibility_due: Arc::default(),
510 visibility_asking: None,
511 };
512 engine.publish();
513 (
514 engine,
515 Handle {
516 status: receiver,
517 control,
518 store,
519 caches: caches_receiver,
520 records: records_receiver,
521 build_cache,
522 },
523 )
524 }
525
526 /// Runs until a shutdown request is carried out.
527 pub async fn run(mut self) {
528 tokio::spawn(metrics::run(
529 Sampler::new(self.paths.clone(), &self.config.host.slice),
530 self.tracked.subscribe(),
531 self.store.clone(),
532 ));
533 // Until Docker answers: scheduling without knowing the containers already running would start a second
534 // runner on a slot's volume and delete the running one's registration.
535 while let Err(error) = self.recover().await {
536 warn!("cannot list the containers an earlier run left; trying again in 5s: {error}");
537 if self.control.mode() != Shutdown::No {
538 return;
539 }
540 tokio::select! {
541 () = tokio::time::sleep(Duration::from_secs(5)) => {}
542 () = self.control.wake.notified() => {}
543 }
544 }
545 loop {
546 let mode = self.control.mode();
547 self.tick(mode).await;
548 self.publish();
549 let idle = self.slots.values().flatten().all(Option::is_none);
550 if mode != Shutdown::No
551 && idle
552 && self.reseeds.is_empty()
553 && self.trim.is_none()
554 && self.records.recordings.is_empty()
555 {
556 break;
557 }
558 let interval = if mode == Shutdown::No {
559 self.config.host.poll_interval
560 } else {
561 Duration::from_secs(5)
562 };
563 tokio::select! {
564 () = tokio::time::sleep(interval) => {}
565 () = self.control.wake.notified() => {}
566 Some(command) = self.commands.recv() => {
567 self.command(command).await;
568 self.publish();
569 }
570 Some(()) = self.records.finished.recv() => {
571 self.collect_recordings().await;
572 self.publish();
573 }
574 }
575 }
576 self.close_shells().await;
577 if let Err(error) = cache::mirror_down(&*self.docker, &self.names).await {
578 warn!("cannot stop the mirror: {error}");
579 }
580 info!("stopped");
581 }
582
583 /// Runs `work` as a task of its own, answering commands and publishing after each until it is done, so a command
584 /// waits for a slow GitHub no longer than for the command before it. A tick follows each such command, as one
585 /// follows a command answered between ticks. The task goes on while a command runs: a request's timeout runs on
586 /// the clock, not on polls.
587 async fn serving<T: Send + 'static>(
588 &mut self,
589 work: impl Future<Output = T> + Send + 'static,
590 ) -> T {
591 let mut task = tokio::spawn(work);
592 loop {
593 tokio::select! {
594 biased;
595 done = &mut task => match done.map_err(tokio::task::JoinError::try_into_panic) {
596 Ok(output) => return output,
597 // As if it had panicked here.
598 Err(Ok(panic)) => std::panic::resume_unwind(panic),
599 // Only while the runtime shuts down, which drops this task too.
600 Err(Err(_)) => std::future::pending::<()>().await,
601 },
602 Some(command) = self.commands.recv() => {
603 self.command(command).await;
604 self.publish();
605 // A tick follows it, as one follows a command answered between ticks: a config it replaced
606 // polls its repositories then, not a poll interval later.
607 self.control.wake.notify_one();
608 }
609 }
610 }
611 }
612
613 fn occupied(&self) -> impl Iterator<Item = (SlotRef, &Occupant)> {
614 self.slots.iter().flat_map(|(profile, slots)| {
615 slots.iter().enumerate().filter_map(|(index, slot)| {
616 slot.as_ref().map(|occupant| {
617 (
618 SlotRef {
619 profile: profile.clone(),
620 index,
621 },
622 occupant,
623 )
624 })
625 })
626 })
627 }
628
629 /// The runner in `slot`, if one is.
630 fn occupant(&self, slot: &SlotRef) -> Option<&Occupant> {
631 self.slots
632 .get(&slot.profile)
633 .and_then(|slots| slots.get(slot.index))
634 .and_then(Option::as_ref)
635 }
636
637 /// Drops the free slots past each profile's configured number, and the profiles a reload removed once nothing
638 /// runs in them.
639 fn drop_closed_slots(&mut self) {
640 for (name, slots) in &mut self.slots {
641 let count = self
642 .config
643 .profiles
644 .get(name)
645 .map_or(0, |profile| profile.slots as usize);
646 while slots.len() > count && slots.last().is_some_and(Option::is_none) {
647 slots.pop();
648 }
649 }
650 let profiles = &self.config.profiles;
651 self.slots
652 .retain(|name, slots| !slots.is_empty() || profiles.contains_key(name));
653 let (slots, reseeds) = (&self.slots, &self.reseeds);
654 self.retired
655 .retain(|name, _| slots.contains_key(name) || reseeds.contains_key(name));
656 }
657}
658
659#[cfg(test)]
660mod tests;