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