Skip to main content

cja/
tasks.rs

1use std::{collections::HashMap, future::Future, time::Duration};
2
3use color_eyre::eyre::{WrapErr as _, eyre};
4use tokio::task::{Id, JoinError, JoinSet};
5use tokio_util::sync::CancellationToken;
6
7/// A named wrapper around a tokio `JoinHandle`, used to track which
8/// long-running task completed (or failed) first.
9pub struct NamedTask {
10    name: &'static str,
11    handle: tokio::task::JoinHandle<crate::Result<()>>,
12}
13
14impl NamedTask {
15    /// Spawn a named async task on the tokio runtime.
16    pub fn spawn<F>(name: &'static str, future: F) -> Self
17    where
18        F: Future<Output = crate::Result<()>> + Send + 'static,
19    {
20        Self {
21            name,
22            handle: tokio::spawn(future),
23        }
24    }
25
26    /// Returns the name of this task.
27    pub fn name(&self) -> &'static str {
28        self.name
29    }
30}
31
32/// Wait for the first task in the set to complete and return its name alongside
33/// the join result.
34///
35/// For long-running services (server, job worker, cron) any task exiting is
36/// typically an error — use this to detect which one stopped.
37pub async fn wait_for_first_task(
38    tasks: Vec<NamedTask>,
39) -> (
40    &'static str,
41    Result<crate::Result<()>, tokio::task::JoinError>,
42) {
43    let (handles, names): (Vec<_>, Vec<_>) = tasks.into_iter().map(|t| (t.handle, t.name)).unzip();
44
45    let (result, index, _remaining) = futures::future::select_all(handles).await;
46    (names[index], result)
47}
48
49/// Wait for the first task to complete and convert the outcome into a
50/// single `Result`.
51///
52/// All three exit conditions (clean exit, error, panic) are treated as
53/// errors because long-running tasks are not expected to return.
54pub async fn wait_for_first_error(tasks: Vec<NamedTask>) -> crate::Result<()> {
55    if tasks.is_empty() {
56        return Ok(());
57    }
58
59    let (name, result) = wait_for_first_task(tasks).await;
60
61    match result {
62        Ok(Ok(())) => {
63            tracing::error!(task = name, "Task exited unexpectedly");
64            Err(eyre!("Task '{}' exited unexpectedly", name))
65        }
66        Ok(Err(e)) => {
67            tracing::error!(task = name, error = ?e, "Task failed with error");
68            Err(e)
69        }
70        Err(join_error) => {
71            tracing::error!(task = name, error = ?join_error, "Task panicked");
72            Err(eyre!("Task '{}' panicked: {}", name, join_error))
73        }
74    }
75}
76
77/// How long a process may spend shutting down, split into the two phases a
78/// [`Supervisor`] runs.
79///
80/// The sum has to fit inside the platform's kill window, which is short and
81/// mostly out of the app's hands: Cloud Run sends `SIGKILL` a fixed 10 seconds
82/// after `SIGTERM`; Fly waits `kill_timeout`, 5 seconds unless `fly.toml` says
83/// otherwise. Whatever is still running when the platform kills the process
84/// keeps its job locks until the lock timeout, so the defaults (2s + 2s) are
85/// sized for the smallest of those windows. Raise them where there is room.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct ShutdownBudget {
88    /// How long an in-flight job may keep running after shutdown starts
89    /// before it is dropped and its lock released. Hand this to
90    /// [`crate::jobs::worker::job_worker`] via [`crate::jobs::JobWorkerConfig::shutdown_drain_timeout`].
91    pub job_drain: Duration,
92    /// Extra time for every task to exit once the job drain is over: workers
93    /// release their locks, the HTTP server closes connections.
94    pub exit_grace: Duration,
95}
96
97impl Default for ShutdownBudget {
98    fn default() -> Self {
99        Self {
100            job_drain: Duration::from_secs(2),
101            exit_grace: Duration::from_secs(2),
102        }
103    }
104}
105
106impl ShutdownBudget {
107    /// Read `CJA_SHUTDOWN_JOB_DRAIN_SECS` and `CJA_SHUTDOWN_EXIT_GRACE_SECS`,
108    /// falling back to the defaults for anything unset or unparseable.
109    #[must_use]
110    pub fn from_env() -> Self {
111        let defaults = Self::default();
112        Self {
113            job_drain: env_secs("CJA_SHUTDOWN_JOB_DRAIN_SECS").unwrap_or(defaults.job_drain),
114            exit_grace: env_secs("CJA_SHUTDOWN_EXIT_GRACE_SECS").unwrap_or(defaults.exit_grace),
115        }
116    }
117
118    /// Total time from the shutdown signal until remaining tasks are aborted.
119    #[must_use]
120    pub fn deadline(&self) -> Duration {
121        self.job_drain + self.exit_grace
122    }
123
124    /// Warn when the budget cannot fit a kill window cja can detect. Only
125    /// Cloud Run qualifies: its window is fixed, while Fly's is configurable
126    /// and not visible to the process.
127    fn warn_if_over_platform_window(&self) {
128        const CLOUD_RUN_WINDOW: Duration = Duration::from_secs(10);
129
130        if std::env::var_os("K_SERVICE").is_some() && self.deadline() >= CLOUD_RUN_WINDOW {
131            tracing::warn!(
132                deadline_secs = self.deadline().as_secs(),
133                window_secs = CLOUD_RUN_WINDOW.as_secs(),
134                "Shutdown budget does not fit Cloud Run's termination window; \
135                 in-flight job locks will be stranded on every deploy"
136            );
137        }
138    }
139}
140
141fn env_secs(name: &str) -> Option<Duration> {
142    std::env::var(name)
143        .ok()?
144        .parse()
145        .ok()
146        .map(Duration::from_secs)
147}
148
149/// Runs an app's long-lived tasks and owns their shutdown.
150///
151/// Spawn the server, job workers and cron through it, handing each one
152/// [`Supervisor::shutdown_token`], then await [`Supervisor::run`]. It returns
153/// when the process should exit:
154///
155/// 1. `SIGTERM` or `SIGINT` arrives (handlers are registered in
156///    [`Supervisor::new`], so a signal during startup is not lost), or any
157///    task exits on its own.
158/// 2. The token is cancelled: workers stop claiming jobs, in-flight jobs get
159///    [`ShutdownBudget::job_drain`] to finish, and whatever is left is
160///    dropped with its lock released so another instance re-runs it at once
161///    instead of after the lock timeout.
162/// 3. Tasks still running at [`ShutdownBudget::deadline`] are aborted. With
163///    websockets or other long-lived connections that is routine, since they
164///    hold axum's graceful shutdown open indefinitely.
165///
166/// A signal is a clean exit. A task exiting by itself is an error, because
167/// every task is meant to run for the life of the process; its peers are
168/// still drained first.
169///
170/// ```ignore
171/// let mut supervisor = cja::tasks::Supervisor::new(ShutdownBudget::from_env())?;
172/// let shutdown = supervisor.shutdown_token();
173///
174/// supervisor.spawn(
175///     "server",
176///     cja::server::run_server_until(routes(app_state.clone()), shutdown.clone().cancelled_owned()),
177/// );
178/// supervisor.spawn(
179///     "jobs",
180///     cja::jobs::worker::job_worker(
181///         app_state.clone(),
182///         Jobs,
183///         Duration::from_secs(60),
184///         cja::jobs::DEFAULT_MAX_RETRIES,
185///         shutdown.clone(),
186///         Default::default(),
187///     ),
188/// );
189///
190/// supervisor.run().await
191/// ```
192pub struct Supervisor {
193    tasks: JoinSet<crate::Result<()>>,
194    names: HashMap<Id, &'static str>,
195    shutdown: CancellationToken,
196    budget: ShutdownBudget,
197    signals: ShutdownSignals,
198}
199
200impl Supervisor {
201    /// Register the signal handlers and create the shared shutdown token.
202    /// Must be called from within a tokio runtime.
203    ///
204    /// # Errors
205    ///
206    /// Fails if a signal handler cannot be registered.
207    pub fn new(budget: ShutdownBudget) -> crate::Result<Self> {
208        budget.warn_if_over_platform_window();
209        Ok(Self {
210            tasks: JoinSet::new(),
211            names: HashMap::new(),
212            shutdown: CancellationToken::new(),
213            budget,
214            signals: ShutdownSignals::register()?,
215        })
216    }
217
218    /// The token every supervised task should watch. Cancelled once, when
219    /// shutdown starts.
220    #[must_use]
221    pub fn shutdown_token(&self) -> CancellationToken {
222        self.shutdown.clone()
223    }
224
225    #[must_use]
226    pub fn budget(&self) -> ShutdownBudget {
227        self.budget
228    }
229
230    /// Spawn a named task on the tokio runtime.
231    pub fn spawn<F>(&mut self, name: &'static str, future: F)
232    where
233        F: Future<Output = crate::Result<()>> + Send + 'static,
234    {
235        let handle = self.tasks.spawn(future);
236        self.names.insert(handle.id(), name);
237    }
238
239    /// Run until a shutdown signal or a task exit, then stop everything
240    /// within the budget.
241    ///
242    /// # Errors
243    ///
244    /// Returns the failure of the task that ended the process. A
245    /// signal-initiated stop is `Ok`, even when stragglers had to be aborted.
246    pub async fn run(mut self) -> crate::Result<()> {
247        let signals = std::mem::take(&mut self.signals);
248        self.run_until(signals.recv()).await
249    }
250
251    async fn run_until(mut self, signal: impl Future<Output = &'static str>) -> crate::Result<()> {
252        if self.tasks.is_empty() {
253            return Ok(());
254        }
255
256        let result = tokio::select! {
257            signal = signal => {
258                tracing::info!(
259                    signal,
260                    deadline_secs = self.budget.deadline().as_secs(),
261                    "Shutdown signal received; draining"
262                );
263                Ok(())
264            }
265            Some(outcome) = self.tasks.join_next_with_id() => {
266                let (name, result) = self.outcome(outcome);
267                let error = match result {
268                    Ok(()) => eyre!("Task '{name}' exited unexpectedly"),
269                    Err(error) => error,
270                };
271                tracing::error!(task = name, error = %format!("{error:#}"), "Task ended the process");
272                Err(error)
273            }
274        };
275
276        self.shutdown.cancel();
277        let drained = tokio::time::timeout(self.budget.deadline(), async {
278            while let Some(outcome) = self.tasks.join_next_with_id().await {
279                match self.outcome(outcome) {
280                    (name, Ok(())) => tracing::debug!(task = name, "Task stopped"),
281                    (name, Err(error)) => {
282                        tracing::warn!(task = name, error = %format!("{error:#}"), "Task failed while stopping");
283                    }
284                }
285            }
286        })
287        .await;
288
289        if drained.is_ok() {
290            tracing::info!("All tasks stopped");
291        } else {
292            let mut stragglers: Vec<_> = self.names.values().copied().collect();
293            stragglers.sort_unstable();
294            tracing::warn!(
295                ?stragglers,
296                "Shutdown deadline reached; aborting remaining tasks"
297            );
298            self.tasks.shutdown().await;
299        }
300
301        result
302    }
303
304    /// Resolve a finished task to its name, folding a panic into the error.
305    fn outcome(
306        &mut self,
307        outcome: Result<(Id, crate::Result<()>), JoinError>,
308    ) -> (&'static str, crate::Result<()>) {
309        match outcome {
310            Ok((id, result)) => {
311                let name = self.names.remove(&id).unwrap_or("unknown");
312                (
313                    name,
314                    result.wrap_err_with(|| format!("Task '{name}' failed")),
315                )
316            }
317            Err(error) => {
318                let name = self.names.remove(&error.id()).unwrap_or("unknown");
319                (name, Err(eyre!("Task '{name}' panicked: {error}")))
320            }
321        }
322    }
323}
324
325/// `SIGTERM` (Cloud Run, Kubernetes, systemd) and `SIGINT` (Fly's default,
326/// Ctrl-C). Off unix only Ctrl-C exists.
327#[derive(Default)]
328struct ShutdownSignals {
329    #[cfg(unix)]
330    unix: Option<(tokio::signal::unix::Signal, tokio::signal::unix::Signal)>,
331}
332
333impl ShutdownSignals {
334    #[cfg(unix)]
335    fn register() -> crate::Result<Self> {
336        use tokio::signal::unix::{SignalKind, signal};
337
338        let sigterm = signal(SignalKind::terminate()).wrap_err("Failed to register SIGTERM")?;
339        let sigint = signal(SignalKind::interrupt()).wrap_err("Failed to register SIGINT")?;
340        Ok(Self {
341            unix: Some((sigterm, sigint)),
342        })
343    }
344
345    #[cfg(not(unix))]
346    #[allow(clippy::unnecessary_wraps)]
347    fn register() -> crate::Result<Self> {
348        Ok(Self {})
349    }
350
351    #[cfg(unix)]
352    async fn recv(self) -> &'static str {
353        let Some((mut sigterm, mut sigint)) = self.unix else {
354            return std::future::pending().await;
355        };
356        tokio::select! {
357            _ = sigterm.recv() => "SIGTERM",
358            _ = sigint.recv() => "SIGINT",
359        }
360    }
361
362    #[cfg(not(unix))]
363    async fn recv(self) -> &'static str {
364        match tokio::signal::ctrl_c().await {
365            Ok(()) => "Ctrl-C",
366            Err(_) => std::future::pending().await,
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use std::sync::{
374        Arc,
375        atomic::{AtomicBool, Ordering},
376    };
377
378    use super::*;
379
380    const BUDGET: ShutdownBudget = ShutdownBudget {
381        job_drain: Duration::from_secs(5),
382        exit_grace: Duration::from_secs(3),
383    };
384
385    /// A task that stops as soon as it is asked to, like the real workers.
386    fn spawn_cooperative(supervisor: &mut Supervisor, name: &'static str) -> Arc<AtomicBool> {
387        let stopped = Arc::new(AtomicBool::new(false));
388        let flag = stopped.clone();
389        let shutdown = supervisor.shutdown_token();
390        supervisor.spawn(name, async move {
391            shutdown.cancelled().await;
392            flag.store(true, Ordering::SeqCst);
393            Ok(())
394        });
395        stopped
396    }
397
398    #[tokio::test(start_paused = true)]
399    async fn signal_stops_every_task_and_exits_cleanly() {
400        let mut supervisor = Supervisor::new(BUDGET).unwrap();
401        let shutdown = supervisor.shutdown_token();
402        let jobs = spawn_cooperative(&mut supervisor, "jobs");
403        let cron = spawn_cooperative(&mut supervisor, "cron");
404
405        let result = supervisor.run_until(async { "SIGTERM" }).await;
406
407        assert!(result.is_ok(), "a signal is a clean exit: {result:?}");
408        assert!(shutdown.is_cancelled());
409        assert!(jobs.load(Ordering::SeqCst) && cron.load(Ordering::SeqCst));
410    }
411
412    #[tokio::test(start_paused = true)]
413    async fn task_ignoring_shutdown_is_aborted_at_the_deadline() {
414        let mut supervisor = Supervisor::new(BUDGET).unwrap();
415        spawn_cooperative(&mut supervisor, "jobs");
416        let finished = Arc::new(AtomicBool::new(false));
417        let flag = finished.clone();
418        supervisor.spawn("server", async move {
419            tokio::time::sleep(Duration::from_hours(1)).await;
420            flag.store(true, Ordering::SeqCst);
421            Ok(())
422        });
423
424        let started = tokio::time::Instant::now();
425        let result = supervisor.run_until(async { "SIGTERM" }).await;
426
427        assert!(
428            result.is_ok(),
429            "stragglers do not turn a signal into a failure"
430        );
431        assert_eq!(started.elapsed(), BUDGET.deadline());
432        tokio::time::sleep(Duration::from_hours(2)).await;
433        assert!(
434            !finished.load(Ordering::SeqCst),
435            "the straggler must be aborted, not left running"
436        );
437    }
438
439    #[tokio::test(start_paused = true)]
440    async fn task_ending_on_its_own_fails_the_process_but_still_drains_its_peers() {
441        for mode in ["error", "exit", "panic"] {
442            let mut supervisor = Supervisor::new(BUDGET).unwrap();
443            let peer = spawn_cooperative(&mut supervisor, "jobs");
444            supervisor.spawn("cron", async move {
445                match mode {
446                    "error" => Err(eyre!("database went away")),
447                    "panic" => panic!("cron blew up"),
448                    _ => Ok(()),
449                }
450            });
451
452            let error = supervisor
453                .run_until(std::future::pending())
454                .await
455                .expect_err("a task ending by itself is a failure");
456
457            let rendered = format!("{error:#}");
458            let expected = match mode {
459                "error" => "database went away",
460                "panic" => "cron blew up",
461                _ => "exited unexpectedly",
462            };
463            assert!(rendered.contains("'cron'"), "{mode}: {rendered}");
464            assert!(rendered.contains(expected), "{mode}: {rendered}");
465            assert!(
466                peer.load(Ordering::SeqCst),
467                "{mode}: surviving workers must get the chance to release their locks"
468            );
469        }
470    }
471
472    #[tokio::test]
473    async fn no_tasks_returns_immediately() {
474        let supervisor = Supervisor::new(BUDGET).unwrap();
475        assert!(supervisor.run_until(std::future::pending()).await.is_ok());
476    }
477
478    #[test]
479    fn default_budget_fits_the_smallest_platform_window() {
480        // Fly's default `kill_timeout` is 5 seconds.
481        assert!(ShutdownBudget::default().deadline() < Duration::from_secs(5));
482    }
483}