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