Skip to main content

runnerd_core/scheduler/engine/
control.rs

1//! Control from the API: the `Control` handle the daemon's API holds (pause flags, which it records in the store so
2//! a restart keeps them, shutdown, a channel of `Command`s) and the engine's side of each command: pause and resume,
3//! of one repository or all, with or without cancelling their runs, reseed, cache clean, config replacement, the
4//! removal of records and the shells opened in them.
5//! Commands run between ticks or while a tick waits for GitHub, so they see the slot state a tick does (ADRs 0019,
6//! 0026). It must not serve HTTP or write the config file: the daemon does, through `Commit`.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::sync::Mutex;
10use std::time::Duration;
11
12use tokio::sync::{Notify, mpsc, oneshot};
13use tracing::{info, warn};
14
15use super::records::RecordSelection;
16use super::{Engine, Occupant};
17use crate::api;
18use crate::cache;
19use crate::config::{ByteSize, Config};
20use crate::docker::{DockerApi, Tty, TtySize};
21use crate::github::GithubApi;
22use crate::isolation;
23use crate::scheduler::SlotRef;
24use crate::store::Store;
25use crate::sync::lock;
26
27/// How long the runner of a run a forced pause cancelled gets to report its job cancelled and exit, before it is
28/// removed: GitHub completes a cancelled run only once its runner reports it, which took 40s in a test.
29pub(super) const CANCEL_GRACE: Duration = Duration::from_secs(90);
30
31/// How the daemon is asked to stop.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum Shutdown {
34    /// Keep scheduling.
35    No,
36    /// Start nothing new, let running jobs finish.
37    Graceful,
38    /// Kill running jobs and delete their runners.
39    Force,
40}
41
42/// What a pause, a shutdown or a runner's removal does with the jobs runners are running.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RunningJobs {
45    /// Leave them running: a pause refuses, a shutdown waits for them, a busy runner stays.
46    Keep,
47    /// Kill them: a pause cancels their runs, a shutdown and a removal kill their runners.
48    Kill,
49}
50
51/// What a new config may do to slots with a runner in them.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum RunningSlots {
54    /// A slot past a lowered `slots`, or of a removed profile, stays until its runner finishes.
55    Drain,
56    /// The config is refused while a runner is in a slot it does not have: what auto sizing asks for.
57    Keep,
58}
59
60/// The repositories a pause or a resume applies to.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Target {
63    /// One repository, `owner/name`.
64    Repository(String),
65    /// Every configured repository.
66    All,
67}
68
69/// Why the engine refused or failed a command from the API.
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71pub enum ControlError {
72    /// No such repository, profile or cache.
73    #[error("{0}")]
74    NotFound(String),
75    /// Runners use what the command would change.
76    #[error("{message}")]
77    Busy {
78        /// Names the runners and their jobs.
79        message: String,
80        /// The runners in the way.
81        jobs: Vec<api::RunningJob>,
82    },
83    /// The command conflicts with one already in progress.
84    #[error("{0}")]
85    Conflict(String),
86    /// The command cannot apply to this target.
87    #[error("{0}")]
88    Invalid(String),
89    /// Docker or GitHub failed.
90    #[error("{0}")]
91    Failed(String),
92    /// A pause or resume holds in memory, but the job history could not record it: a restart undoes it.
93    #[error("{0}")]
94    NotRecorded(String),
95    /// The engine stopped.
96    #[error("runnerd is stopping")]
97    Stopped,
98}
99
100/// A command the engine carries out between ticks or while a tick waits for GitHub, with where its answer goes.
101#[derive(Debug)]
102pub enum Command {
103    /// Pause one repository or all; with `RunningJobs::Kill`, cancel their running jobs.
104    Pause {
105        /// What to pause.
106        target: Target,
107        /// Cancel running jobs, or refuse while there are any.
108        jobs: RunningJobs,
109        /// The answer.
110        reply: oneshot::Sender<Result<api::Paused, ControlError>>,
111    },
112    /// Resume one repository or all.
113    Resume {
114        /// What to resume.
115        target: Target,
116        /// The answer.
117        reply: oneshot::Sender<Result<(), ControlError>>,
118    },
119    /// Make slot 1's layers the profile's snapshot once its slots are free.
120    Reseed {
121        /// The profile.
122        profile: String,
123        /// The answer.
124        reply: oneshot::Sender<Result<(), ControlError>>,
125    },
126    /// Remove a slot's volume or a shared cache's, unless a runner uses it.
127    CleanCache {
128        /// The volume.
129        name: String,
130        /// The answer.
131        reply: oneshot::Sender<Result<(), ControlError>>,
132    },
133    /// Write a new, validated config to its file and run with it, with no tick in between.
134    ReplaceConfig {
135        /// The config.
136        config: Box<Config>,
137        /// The `MemoryMax` of the slice it names.
138        memory_max: Option<u64>,
139        /// The CPUs of the slice it names, in millicores, read when it sets `cpu_overcommit`.
140        cpus: Option<u64>,
141        /// Writes the file.
142        commit: Commit,
143        /// Whether it may leave runners in slots it does not have.
144        running: RunningSlots,
145        /// Answered once the engine runs with it: with a warning from the commit, or why it did not commit.
146        reply: oneshot::Sender<Result<Option<String>, ControlError>>,
147    },
148    /// Remove records of failed jobs.
149    RemoveRecords {
150        /// Which.
151        selection: RecordSelection,
152        /// The answer.
153        reply: oneshot::Sender<Result<api::RemovedRecords, ControlError>>,
154    },
155    /// Start a shell in a job's record, if the budget has room for it.
156    OpenShell {
157        /// The job.
158        job: u64,
159        /// Its terminal's size.
160        size: TtySize,
161        /// The shell's terminal.
162        reply: oneshot::Sender<Result<Tty, ControlError>>,
163    },
164    /// Remove a shell's container: its terminal closed.
165    CloseShell {
166        /// The container's ID.
167        container: String,
168    },
169}
170
171/// What makes a new config durable, run by the engine before it runs with the config: `Ok` once the config is in
172/// place, with a warning if it may not survive a crash; `Err` with nothing changed.
173pub struct Commit(Box<dyn FnOnce() -> Result<Option<String>, String> + Send>);
174
175impl Commit {
176    /// A commit that runs `write`, off the engine's thread.
177    pub fn new(write: impl FnOnce() -> Result<Option<String>, String> + Send + 'static) -> Commit {
178        Commit(Box::new(write))
179    }
180
181    /// Runs it.
182    pub async fn run(self) -> Result<Option<String>, String> {
183        tokio::task::spawn_blocking(self.0)
184            .await
185            .unwrap_or_else(|join_error| Err(join_error.to_string()))
186    }
187}
188
189impl std::fmt::Debug for Commit {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.write_str("Commit")
192    }
193}
194
195/// The engine's end of `Control`'s commands.
196#[derive(Debug)]
197pub struct Commands(mpsc::UnboundedReceiver<Command>);
198
199impl Commands {
200    /// The next command; `None` once `Control` is gone.
201    pub async fn recv(&mut self) -> Option<Command> {
202        self.0.recv().await
203    }
204}
205
206/// Commands from the API and signals to the loop.
207#[derive(Debug)]
208pub struct Control {
209    // Locked with `sync::lock`: the guarded data stays consistent, as every critical section is a single insert,
210    // remove or read.
211    repositories: Mutex<BTreeSet<String>>,
212    paused: Mutex<BTreeSet<String>>,
213    /// Where `paused` is recorded.
214    store: Store,
215    shutdown: Mutex<Shutdown>,
216    pub(super) wake: Notify,
217    commands: mpsc::UnboundedSender<Command>,
218}
219
220impl Control {
221    /// Controls for the configured repositories, of which those in `paused` start paused, and the commands they
222    /// send the engine. A paused repository the config no longer has is forgotten, in `store` too: one added back
223    /// starts active.
224    pub fn new(
225        repositories: impl IntoIterator<Item = String>,
226        paused: impl IntoIterator<Item = String>,
227        store: Store,
228    ) -> (Control, Commands) {
229        let repositories: BTreeSet<String> = repositories.into_iter().collect();
230        let paused: BTreeSet<String> = paused
231            .into_iter()
232            .filter(|repository| repositories.contains(repository))
233            .collect();
234        // Sent at once; a write that fails leaves rows the next start filters out the same way.
235        drop(store.paused(paused.iter().cloned().collect()));
236        let (commands, receiver) = mpsc::unbounded_channel();
237        let control = Control {
238            repositories: Mutex::new(repositories),
239            paused: Mutex::new(paused),
240            store,
241            shutdown: Mutex::new(Shutdown::No),
242            wake: Notify::new(),
243            commands,
244        };
245        (control, Commands(receiver))
246    }
247
248    /// Whether a shutdown was requested, and how.
249    pub fn mode(&self) -> Shutdown {
250        *lock(&self.shutdown)
251    }
252
253    /// Pauses or resumes one repository or all, whatever they run, and records it in the store before it answers,
254    /// so a restarted daemon starts with the same ones paused. `NotFound` for a repository the config does not have.
255    /// If the record fails, the change holds until a restart and the answer is `NotRecorded`. The status shows it
256    /// at the engine's next publish: the API pauses and resumes through `pause` and `resume`, which publish before
257    /// they answer.
258    pub async fn set_paused(&self, target: &Target, paused: bool) -> Result<(), ControlError> {
259        let recorded = {
260            let repositories = lock(&self.repositories);
261            let names: Vec<&String> = match target {
262                Target::Repository(repository) => {
263                    let Some(name) = repositories.get(repository) else {
264                        return Err(not_configured(repository, repositories.iter()));
265                    };
266                    vec![name]
267                }
268                Target::All => repositories.iter().collect(),
269            };
270            let mut set = lock(&self.paused);
271            for name in names {
272                if paused {
273                    set.insert(name.clone());
274                } else {
275                    set.remove(name);
276                }
277            }
278            // Sent under the lock, so the records change in the order the set does.
279            self.store.paused(set.iter().cloned().collect())
280        };
281        self.wake.notify_one();
282        if recorded.await {
283            return Ok(());
284        }
285        let name = match target {
286            Target::Repository(repository) => repository.as_str(),
287            Target::All => "every repository",
288        };
289        let (done, undone) = if paused {
290            ("paused", "resumes it")
291        } else {
292            ("resumed", "pauses it again")
293        };
294        let message = format!(
295            "{name} is {done} until runnerd restarts, but the job history cannot record it, so a restart {undone}"
296        );
297        warn!("{message}");
298        Err(ControlError::NotRecorded(message))
299    }
300
301    /// Replaces the configured repositories; the removed ones are no longer paused, in the store too.
302    fn set_repositories(&self, repositories: BTreeSet<String>) {
303        let mut configured = lock(&self.repositories);
304        let mut paused = lock(&self.paused);
305        let before = paused.len();
306        paused.retain(|repository| repositories.contains(repository));
307        if paused.len() != before {
308            // Not waited for: a restart forgets a repository the config does not have anyway.
309            drop(self.store.paused(paused.iter().cloned().collect()));
310        }
311        *configured = repositories;
312    }
313
314    /// Asks the loop to stop; a forced request overrides a graceful one, never the other way.
315    pub fn shutdown(&self, jobs: RunningJobs) {
316        let mode = match jobs {
317            RunningJobs::Keep => Shutdown::Graceful,
318            RunningJobs::Kill => Shutdown::Force,
319        };
320        let mut shutdown = lock(&self.shutdown);
321        *shutdown = (*shutdown).max(mode);
322        drop(shutdown);
323        self.wake.notify_one();
324    }
325
326    /// Whether the repository is paused.
327    pub fn is_paused(&self, repository: &str) -> bool {
328        lock(&self.paused).contains(repository)
329    }
330
331    async fn send<T>(
332        &self,
333        command: impl FnOnce(oneshot::Sender<T>) -> Command,
334    ) -> Result<T, ControlError> {
335        let (reply, answer) = oneshot::channel();
336        self.commands
337            .send(command(reply))
338            .map_err(|_| ControlError::Stopped)?;
339        answer.await.map_err(|_| ControlError::Stopped)
340    }
341
342    /// Pauses one repository or all. With `RunningJobs::Keep`, refuses while any of them runs a job, pausing none;
343    /// with `Kill`, cancels the runs of their running jobs on GitHub, whose runners are removed once they report it
344    /// or `CANCEL_GRACE` later, and removes their other runners at once.
345    pub async fn pause(
346        &self,
347        target: Target,
348        jobs: RunningJobs,
349    ) -> Result<api::Paused, ControlError> {
350        self.send(|reply| Command::Pause {
351            target,
352            jobs,
353            reply,
354        })
355        .await?
356    }
357
358    /// Resumes one repository or all, and publishes the status before it answers, so a client that reads the status
359    /// or the events after the answer sees them resumed. Fails as `set_paused` does.
360    pub async fn resume(&self, target: Target) -> Result<(), ControlError> {
361        self.send(|reply| Command::Resume { target, reply }).await?
362    }
363
364    /// Reseeds a profile as soon as its slots are free; none of them starts a runner until then.
365    pub async fn reseed(&self, profile: &str) -> Result<(), ControlError> {
366        self.send(|reply| Command::Reseed {
367            profile: profile.to_owned(),
368            reply,
369        })
370        .await?
371    }
372
373    /// Removes a slot's volume or a shared cache's, unless a runner uses it.
374    pub async fn clean_cache(&self, name: &str) -> Result<(), ControlError> {
375        self.send(|reply| Command::CleanCache {
376            name: name.to_owned(),
377            reply,
378        })
379        .await?
380    }
381
382    /// Removes the selected records of failed jobs and their images.
383    pub async fn remove_records(
384        &self,
385        selection: RecordSelection,
386    ) -> Result<api::RemovedRecords, ControlError> {
387        self.send(|reply| Command::RemoveRecords { selection, reply })
388            .await?
389    }
390
391    /// Starts a shell in a job's record and returns its terminal. The caller closes it with `close_shell` once the
392    /// terminal ends, from either side.
393    pub async fn open_shell(&self, job: u64, size: TtySize) -> Result<Tty, ControlError> {
394        self.send(|reply| Command::OpenShell { job, size, reply })
395            .await?
396    }
397
398    /// Removes a shell's container. Not waited for, so it can be sent while the terminal is dropped.
399    pub fn close_shell(&self, container: String) {
400        let _ = self.commands.send(Command::CloseShell { container });
401    }
402
403    /// Runs `commit` to write a validated config, and if it commits, runs with the config from then on, before the
404    /// next tick. If it does not, nothing changes and the answer is `Failed`. Running jobs keep running: under
405    /// `RunningSlots::Drain` removed profiles and slots go as their runners finish; under `Keep` a config without a
406    /// slot a runner is in is refused (`Conflict`) before `commit` runs.
407    pub async fn replace_config(
408        &self,
409        config: Config,
410        memory_max: Option<u64>,
411        cpus: Option<u64>,
412        commit: Commit,
413        running: RunningSlots,
414    ) -> Result<Option<String>, ControlError> {
415        self.send(|reply| Command::ReplaceConfig {
416            config: Box::new(config),
417            memory_max,
418            cpus,
419            commit,
420            running,
421            reply,
422        })
423        .await?
424    }
425}
426
427/// Names the repositories there are, so a mistyped name shows its fix.
428fn not_configured<'name>(
429    repository: &str,
430    configured: impl Iterator<Item = &'name String>,
431) -> ControlError {
432    let configured: Vec<&str> = configured.map(String::as_str).collect();
433    let configured = if configured.is_empty() {
434        "none is configured".to_owned()
435    } else {
436        format!("configured: {}", configured.join(", "))
437    };
438    ControlError::NotFound(format!(
439        "{repository} is not a configured repository; {configured}"
440    ))
441}
442
443/// `jobs` for people to read: `job 1 on runner-a, runner-b`.
444fn list(jobs: &[api::RunningJob]) -> String {
445    jobs.iter()
446        .map(ToString::to_string)
447        .collect::<Vec<_>>()
448        .join(", ")
449}
450
451impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
452    /// Carries out a command from the API.
453    pub(super) async fn command(&mut self, command: Command) {
454        match command {
455            Command::Pause {
456                target,
457                jobs,
458                reply,
459            } => {
460                let _ = reply.send(self.pause(&target, jobs).await);
461            }
462            Command::Resume { target, reply } => {
463                let _ = reply.send(self.control.set_paused(&target, false).await);
464            }
465            Command::Reseed { profile, reply } => {
466                let _ = reply.send(self.request_reseed(&profile));
467            }
468            Command::CleanCache { name, reply } => {
469                let _ = reply.send(self.clean_cache(&name).await);
470            }
471            Command::ReplaceConfig {
472                config,
473                memory_max,
474                cpus,
475                commit,
476                running,
477                reply,
478            } => {
479                if running == RunningSlots::Keep
480                    && let Some(occupied) = self.occupied_past(&config)
481                {
482                    let _ = reply.send(Err(ControlError::Conflict(occupied)));
483                    return;
484                }
485                let answer = match commit.run().await {
486                    Ok(warning) => {
487                        self.reload(*config, memory_max, cpus);
488                        Ok(warning)
489                    }
490                    Err(message) => Err(ControlError::Failed(message)),
491                };
492                let _ = reply.send(answer);
493            }
494            Command::RemoveRecords { selection, reply } => {
495                let _ = reply.send(self.remove_records(&selection).await);
496            }
497            Command::OpenShell { job, size, reply } => {
498                let opened = self.open_shell(job, size).await;
499                // A terminal nobody waits for any more is closed at once.
500                if let Err(Ok(tty)) = reply.send(opened) {
501                    self.close_shell(&tty.container).await;
502                }
503            }
504            Command::CloseShell { container } => self.close_shell(&container).await,
505        }
506    }
507
508    /// Pauses one repository or all. A runner of theirs that holds a job, or that GitHub reports busy, refuses it
509    /// with `RunningJobs::Keep`; with `Kill`, the runs of their jobs are cancelled, and their runners removed once
510    /// they exit or `CANCEL_GRACE` later; a runner whose run cannot be cancelled is removed at once.
511    pub(super) async fn pause(
512        &mut self,
513        target: &Target,
514        running: RunningJobs,
515    ) -> Result<api::Paused, ControlError> {
516        let repositories: BTreeSet<String> = match target {
517            Target::Repository(repository) if !self.repositories.contains_key(repository) => {
518                return Err(not_configured(repository, self.repositories.keys()));
519            }
520            Target::Repository(repository) => BTreeSet::from([repository.clone()]),
521            Target::All => self.repositories.keys().cloned().collect(),
522        };
523        self.take_found();
524        let runners: Vec<(SlotRef, Occupant)> = self
525            .occupied()
526            .filter(|(_, occupant)| repositories.contains(&occupant.repository))
527            .map(|(slot, occupant)| (slot, occupant.clone()))
528            .collect();
529        let jobs: Vec<api::RunningJob> = runners
530            .iter()
531            .filter(|(_, occupant)| occupant.busy || occupant.job.is_some())
532            .map(|(_, occupant)| occupant.running_job())
533            .collect();
534        if running == RunningJobs::Keep && !jobs.is_empty() {
535            let mut by_repository: BTreeMap<&str, Vec<api::RunningJob>> = BTreeMap::new();
536            for job in &jobs {
537                by_repository
538                    .entry(&job.repository)
539                    .or_default()
540                    .push(job.clone());
541            }
542            let runs: Vec<String> = by_repository
543                .iter()
544                .map(|(repository, jobs)| format!("{repository} runs {}", list(jobs)))
545                .collect();
546            // The jobs only: each client says how to force the pause in its own terms.
547            return Err(ControlError::Busy {
548                message: runs.join("; "),
549                jobs,
550            });
551        }
552        // Paused in memory even if not recorded: a forced pause still cancels the runs it was asked to.
553        let recorded = self.control.set_paused(target, true).await;
554        match target {
555            Target::Repository(repository) => info!("{repository}: paused"),
556            Target::All => info!("paused every repository"),
557        }
558        let mut stopped = Vec::new();
559        if running == RunningJobs::Keep {
560            return recorded.map(|()| api::Paused { stopped });
561        }
562        for (slot, occupant) in runners {
563            let repository = &occupant.repository;
564            let cancelled = if let Some(job) = occupant.job {
565                self.cancel(repository, job).await
566            } else {
567                if occupant.busy {
568                    warn!(
569                        "{repository}: {} runs a job its log has not named yet; GitHub fails it once the runner is gone",
570                        occupant.runner_name
571                    );
572                }
573                false
574            };
575            if cancelled {
576                // `collect_finished` removes it once it exits, `kill_cancelled` if it does not.
577                self.cancelling.insert(
578                    occupant.runner_name.clone(),
579                    tokio::time::Instant::now() + CANCEL_GRACE,
580                );
581            } else {
582                self.stop(&slot, RunningJobs::Kill).await;
583                if self
584                    .occupied()
585                    .any(|(_, candidate)| candidate.runner_name == occupant.runner_name)
586                {
587                    continue;
588                }
589            }
590            stopped.push(occupant.running_job());
591        }
592        match recorded {
593            Ok(()) => Ok(api::Paused { stopped }),
594            Err(ControlError::NotRecorded(message)) if !stopped.is_empty() => Err(
595                ControlError::NotRecorded(format!("{message}; stopped {}", list(&stopped))),
596            ),
597            Err(error) => Err(error),
598        }
599    }
600
601    /// Removes the runners of cancelled runs that did not exit within `CANCEL_GRACE`, every tick until they are gone.
602    pub(super) async fn kill_cancelled(&mut self) {
603        let now = tokio::time::Instant::now();
604        let running: BTreeSet<String> = self
605            .occupied()
606            .map(|(_, occupant)| occupant.runner_name.clone())
607            .collect();
608        self.cancelling.retain(|name, _| running.contains(name));
609        let overdue: Vec<(SlotRef, String)> = self
610            .occupied()
611            .filter(|(_, occupant)| {
612                self.cancelling
613                    .get(&occupant.runner_name)
614                    .is_some_and(|deadline| *deadline <= now)
615            })
616            .map(|(slot, occupant)| (slot, occupant.runner_name.clone()))
617            .collect();
618        for (slot, name) in overdue {
619            warn!(
620                "{name} did not exit {}s after its run was cancelled",
621                CANCEL_GRACE.as_secs()
622            );
623            // The deadline stays until the runner is gone, so a failed removal is tried again next tick.
624            self.stop(&slot, RunningJobs::Kill).await;
625        }
626    }
627
628    /// Cancels the run of a job; whether GitHub accepted it.
629    async fn cancel(&self, repository: &str, job: u64) -> bool {
630        let run = match self.github.job(repository, job).await {
631            Ok(details) => details.run_id,
632            Err(error) => {
633                warn!("{repository}: cannot read job {job} to cancel its run: {error}");
634                return false;
635            }
636        };
637        match self.github.cancel_run(repository, run).await {
638            Ok(()) => {
639                info!("{repository}: cancelled run {run} of job {job}");
640                true
641            }
642            Err(error) => {
643                warn!("{repository}: cannot cancel run {run}: {error}");
644                false
645            }
646        }
647    }
648
649    /// Asks for a profile's reseed: none of its slots starts a runner until they are free and it ran.
650    pub(super) fn request_reseed(&mut self, profile: &str) -> Result<(), ControlError> {
651        if !self.config.profiles.contains_key(profile) {
652            return Err(ControlError::NotFound(format!(
653                "{profile} is not a configured profile"
654            )));
655        }
656        if self.is_reseeding(profile) {
657            return Err(ControlError::Conflict(format!(
658                "{profile} is reseeding already"
659            )));
660        }
661        // A shell mounts the snapshot a reseed replaces, and may stay open for hours.
662        if self.shell_profiles().contains(profile) {
663            return Err(ControlError::Conflict(format!(
664                "a shell is open in a record of {profile}, which mounts its snapshot; reseed it once the shell exits"
665            )));
666        }
667        info!("{profile}: reseeding once its slots are free");
668        self.reseed_requested.insert(profile.to_owned());
669        Ok(())
670    }
671
672    /// Removes a slot's volume or a shared cache's, unless a runner or a reseed uses it. Snapshots are rebuilt by
673    /// reseeding instead: slot layers sit on top of them.
674    pub(super) async fn clean_cache(&mut self, name: &str) -> Result<(), ControlError> {
675        let Some(volume) = cache::volumes(&self.names, &self.config)
676            .into_iter()
677            .find(|volume| volume.name == name)
678        else {
679            return Err(ControlError::NotFound(format!(
680                "{name} is not a cache volume of this config; `runnerctl caches` lists them"
681            )));
682        };
683        let users: Vec<api::RunningJob> = match &volume.kind {
684            api::CacheKind::Snapshot { profile } => {
685                return Err(ControlError::Invalid(format!(
686                    "{name} is {profile}'s snapshot, which every slot's layers sit on; reseed {profile} to rebuild it"
687                )));
688            }
689            api::CacheKind::Slot { profile, slot } => {
690                if self.reseeds.contains_key(profile) {
691                    return Err(ControlError::Conflict(format!(
692                        "{profile} is reseeding, which writes {name}"
693                    )));
694                }
695                self.occupied()
696                    .filter(|(at, _)| at.profile == *profile && at.index + 1 == *slot)
697                    .map(|(_, occupant)| occupant.running_job())
698                    .collect()
699            }
700            api::CacheKind::Pnpm
701            | api::CacheKind::Maven
702            | api::CacheKind::Mirror
703            | api::CacheKind::Build => self
704                .occupied()
705                .map(|(_, occupant)| occupant.running_job())
706                .collect(),
707        };
708        let shared = matches!(volume.kind, api::CacheKind::Pnpm | api::CacheKind::Maven);
709        if shared && !self.records.shells.is_empty() {
710            return Err(ControlError::Conflict(format!(
711                "a shell open in a record mounts {name}; clean it once the shell exits"
712            )));
713        }
714        if !users.is_empty() {
715            return Err(ControlError::Busy {
716                message: format!(
717                    "{name} is in use by {}; clean it once they finish",
718                    list(&users)
719                ),
720                jobs: users,
721            });
722        }
723        if volume.kind == api::CacheKind::Build {
724            let Some(cache) = self.build_cache.clone() else {
725                return Err(ControlError::Failed(
726                    "the build cache did not open when runnerd started; its log says why"
727                        .to_owned(),
728                ));
729            };
730            let trimmed = tokio::task::spawn_blocking(move || cache.clear())
731                .await
732                .map_err(|error| ControlError::Failed(format!("cannot empty {name}: {error}")))?;
733            info!(
734                "emptied {name}: {} entries, {}",
735                trimmed.before.entries,
736                ByteSize(trimmed.before.bytes)
737            );
738            self.trim_due = true;
739            return Ok(());
740        }
741        if volume.kind == api::CacheKind::Mirror && self.mirror_up {
742            cache::mirror_down(&*self.docker, &self.names)
743                .await
744                .map_err(|error| {
745                    ControlError::Failed(format!("cannot stop the mirror: {error}"))
746                })?;
747            self.mirror_up = false;
748        }
749        self.docker
750            .remove_volume(name)
751            .await
752            .map_err(|error| ControlError::Failed(format!("cannot remove {name}: {error}")))?;
753        info!("removed {name}");
754        // Measured again once idle.
755        self.trim_due = true;
756        Ok(())
757    }
758
759    /// The first slot with a runner in it that `config` does not have, for people to read.
760    fn occupied_past(&self, config: &Config) -> Option<String> {
761        self.slots.iter().find_map(|(name, slots)| {
762            let kept = config
763                .profiles
764                .get(name)
765                .map_or(0, |profile| profile.slots as usize);
766            slots
767                .iter()
768                .enumerate()
769                .skip(kept)
770                .find(|(_, slot)| slot.is_some())
771                .map(|(index, _)| {
772                    format!(
773                        "{name}'s slot {} has a runner, and the new config keeps {kept} slots",
774                        index + 1
775                    )
776                })
777        })
778    }
779
780    /// Runs with a new, validated config. Running jobs keep running: a removed profile, or slots past a lowered
781    /// number, stay until their runners finish, and no runner starts in a profile while it has as many as its
782    /// slots. Runners started from now on get the new settings.
783    /// `cpus` are the slice's, read when the config sets `cpu_overcommit`.
784    pub(super) fn reload(&mut self, config: Config, memory_max: Option<u64>, cpus: Option<u64>) {
785        let mut old = std::mem::replace(&mut self.config, config);
786        if let Some(cache) = &self.build_cache {
787            cache.set_max_size(self.config.caches.build.max_size.0);
788        }
789        for (name, profile) in &self.config.profiles {
790            let slots = self.slots.entry(name.clone()).or_default();
791            if slots.len() < profile.slots as usize {
792                slots.resize(profile.slots as usize, None);
793            }
794            self.retired.remove(name);
795        }
796        for (name, profile) in std::mem::take(&mut old.profiles) {
797            if !self.config.profiles.contains_key(&name) {
798                self.retired.insert(name, profile);
799            }
800        }
801        self.drop_closed_slots();
802        self.images.stale_due = true;
803        let names: BTreeSet<String> = self
804            .config
805            .repositories
806            .iter()
807            .map(|repository| repository.name.clone())
808            .collect();
809        let removed: Vec<String> = self
810            .repositories
811            .keys()
812            .filter(|name| !names.contains(*name))
813            .cloned()
814            .collect();
815        self.draining.extend(removed);
816        self.draining.retain(|name| !names.contains(name));
817        self.repositories.retain(|name, _| names.contains(name));
818        for name in &names {
819            self.repositories.entry(name.clone()).or_default();
820        }
821        self.control.set_repositories(names);
822        let profiles = &self.config.profiles;
823        self.reseed_requested
824            .retain(|name| profiles.contains_key(name));
825        self.oversized.retain(|name| profiles.contains_key(name));
826        self.isolation = isolation::backend(self.config.host.isolation);
827        self.memory_max = memory_max;
828        self.cpus = cpus;
829        self.caches
830            .send_replace(cache::volumes(&self.names, &self.config));
831        info!("reloaded the config");
832    }
833}