saluki_core/runtime/
workers.rs

1//! Closure-style supervisable workers.
2use std::{future::Future, sync::Mutex, time::Duration};
3
4use async_trait::async_trait;
5use saluki_common::sync::shutdown::ShutdownHandle;
6use saluki_error::{generic_error, GenericError};
7
8use super::supervisor::{InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture};
9
10/// Fallback graceful shutdown period for a function-based worker.
11///
12/// Only consulted when nothing else bounds the worker: a child spawned through the builder defers to its supervisor's
13/// shutdown budget, and falls back to this when the supervisor has no budget at all.
14const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
15
16/// An output type that a function-based worker can produce.
17///
18/// Implemented for `()` (a worker that can't fail) and `Result<(), GenericError>` (one that can), so both forms can be
19/// passed to [`FnWorker::new`] without wrapping.
20pub trait IntoWorkerResult {
21    /// Converts this output into a worker result.
22    fn into_worker_result(self) -> Result<(), GenericError>;
23}
24
25impl IntoWorkerResult for () {
26    fn into_worker_result(self) -> Result<(), GenericError> {
27        Ok(())
28    }
29}
30
31impl IntoWorkerResult for Result<(), GenericError> {
32    fn into_worker_result(self) -> Result<(), GenericError> {
33        self
34    }
35}
36
37type WorkerBody = Box<dyn FnOnce() -> SupervisorFuture + Send>;
38
39/// A [`Supervisable`] worker built from a plain future.
40///
41/// This is the ordinary kind of supervised child: a piece of asynchronous work that runs until it reaches its own
42/// terminal condition -- an input channel closing, a loop finishing, a request completing.
43///
44/// # Shutdown
45///
46/// An `FnWorker` is never handed a shutdown signal, and reports as much through
47/// [`wants_shutdown_signal`][Supervisable::wants_shutdown_signal]. Shutdown of a subtree is a _trigger_, not an
48/// enforcement: the workers within it keep running until their terminal conditions are reached, which is what lets a
49/// set of tasks connected by channels drain in dependency order without any of them having to know that order. The
50/// supervisor's [shutdown budget][crate::runtime::Supervisor::with_shutdown_budget] is the backstop for work that
51/// takes too long, and [`ShutdownStrategy::Brutal`] is the answer for work that has no terminal condition at all.
52///
53/// A worker that genuinely needs to observe shutdown -- to run cleanup, or because it has no other way to know it
54/// should stop -- should implement [`Supervisable`] directly, which does receive the signal.
55///
56/// This worker cannot be restarted, as the future is consumed during initialization.
57pub struct FnWorker {
58    name: String,
59    body: Mutex<Option<WorkerBody>>,
60}
61
62impl FnWorker {
63    /// Creates a worker that runs `fut` to completion.
64    ///
65    /// Workers may return either `()` or `Result<(), GenericError>`.
66    ///
67    /// Prefer [`worker`][crate::runtime::worker] and its counterparts on
68    /// [`SupervisorHandle`][crate::runtime::SupervisorHandle], which wrap this up with the defaults appropriate to a
69    /// dynamically spawned child.
70    ///
71    /// # Examples
72    ///
73    /// ```no_run
74    /// # use saluki_core::runtime::FnWorker;
75    /// # async fn drain_queue() {}
76    /// let worker = FnWorker::new("queue_drainer", drain_queue());
77    /// ```
78    #[must_use]
79    pub fn new<N, Fut>(name: N, fut: Fut) -> Self
80    where
81        N: Into<String>,
82        Fut: Future + Send + 'static,
83        Fut::Output: IntoWorkerResult,
84    {
85        Self {
86            name: name.into(),
87            body: Mutex::new(Some(Box::new(move || {
88                Box::pin(async move { fut.await.into_worker_result() })
89            }))),
90        }
91    }
92}
93
94#[async_trait]
95impl Supervisable for FnWorker {
96    fn name(&self) -> &str {
97        &self.name
98    }
99
100    fn shutdown_strategy(&self) -> ShutdownStrategy {
101        ShutdownStrategy::Graceful(DEFAULT_SHUTDOWN_TIMEOUT)
102    }
103
104    fn wants_shutdown_signal(&self) -> bool {
105        false
106    }
107
108    async fn initialize(&self, _process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
109        let body = self
110            .body
111            .lock()
112            .expect("function worker mutex poisoned")
113            .take()
114            .ok_or_else(|| InitializationError::from(generic_error!("worker already initialized")))?;
115
116        Ok(body())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use std::sync::{
123        atomic::{AtomicUsize, Ordering},
124        Arc,
125    };
126
127    use tokio::time::timeout;
128
129    use super::*;
130
131    /// Bound on any worker-body await in these tests.
132    ///
133    /// A worker that never finishes would otherwise hang the test process until the harness kills it, which reads as a
134    /// stall rather than a failure.
135    const RUN_TIMEOUT: Duration = Duration::from_secs(5);
136
137    #[tokio::test]
138    async fn worker_runs_its_future_to_completion() {
139        let ran = Arc::new(AtomicUsize::new(0));
140        let worker_ran = Arc::clone(&ran);
141
142        let worker = FnWorker::new("test", async move {
143            worker_ran.fetch_add(1, Ordering::SeqCst);
144        });
145
146        let run = worker
147            .initialize(ShutdownHandle::noop())
148            .await
149            .expect("should initialize");
150
151        timeout(RUN_TIMEOUT, run)
152            .await
153            .expect("worker should run to completion")
154            .expect("should exit cleanly");
155
156        assert_eq!(ran.load(Ordering::SeqCst), 1);
157    }
158
159    #[tokio::test]
160    async fn worker_propagates_error() {
161        let worker = FnWorker::new("test", async { Err::<(), _>(generic_error!("worker failed")) });
162
163        let run = worker
164            .initialize(ShutdownHandle::noop())
165            .await
166            .expect("should initialize");
167
168        let error = run.await.expect_err("should surface the worker's error");
169        assert!(error.to_string().contains("worker failed"));
170    }
171
172    #[tokio::test]
173    async fn worker_does_not_want_the_shutdown_signal() {
174        // The supervisor uses this to skip allocating a shutdown coordinator it would never fire: an `FnWorker` runs
175        // until its own terminal condition regardless of what the supervisor signals.
176        assert!(!FnWorker::new("test", async {}).wants_shutdown_signal());
177    }
178
179    #[tokio::test]
180    async fn second_initialization_fails() {
181        // Function-based workers are one-shot: a restart would re-initialize, which must fail loudly rather than
182        // silently running nothing.
183        let worker = FnWorker::new("test", async {});
184
185        // Drop the run-future without polling it; we only care that the body was consumed.
186        drop(
187            worker
188                .initialize(ShutdownHandle::noop())
189                .await
190                .expect("first initialization should succeed"),
191        );
192
193        // `SupervisorFuture` isn't `Debug`, so match rather than using `expect_err`.
194        match worker.initialize(ShutdownHandle::noop()).await {
195            Ok(_) => panic!("second initialization should fail"),
196            Err(e) => assert!(e.to_string().contains("already initialized")),
197        }
198    }
199}