Skip to main content

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::supervisable::{InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture};
9
10/// Graceful shutdown timeout for a function-based worker that isn't given an explicit shutdown strategy.
11const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
12
13/// An output type that a function-based worker can produce.
14///
15/// Implemented for `()` (a worker that can't fail) and `Result<(), GenericError>` (one that can), so both forms can be
16/// passed to [`FnWorker::new`] without wrapping.
17pub trait IntoWorkerResult {
18    /// Converts this output into a worker result.
19    fn into_worker_result(self) -> Result<(), GenericError>;
20}
21
22impl IntoWorkerResult for () {
23    fn into_worker_result(self) -> Result<(), GenericError> {
24        Ok(())
25    }
26}
27
28impl IntoWorkerResult for Result<(), GenericError> {
29    fn into_worker_result(self) -> Result<(), GenericError> {
30        self
31    }
32}
33
34type WorkerBody = Box<dyn FnOnce(ShutdownHandle) -> SupervisorFuture + Send>;
35
36/// A [`Supervisable`] worker built from a plain future.
37///
38/// This is the ordinary kind of supervised child: a piece of asynchronous work that runs until it reaches its own
39/// terminal condition -- an input channel closing, a loop finishing, a request completing.
40///
41/// # Shutdown
42///
43/// A worker created with [`new`][Self::new] never gets a shutdown signal, and it reports this through
44/// [`wants_shutdown_signal`][Supervisable::wants_shutdown_signal]. Shutdown of a subtree is a _trigger_, not an
45/// enforcement: the workers within it keep running until their terminal conditions are reached, which is what lets a
46/// set of tasks connected by channels drain in dependency order without any of them having to know that order. The
47/// worker's shutdown timeout (five seconds by default) is the backstop for work that takes too long, and
48/// [`ShutdownStrategy::Brutal`] is the answer for work that has no terminal condition at all.
49///
50/// Some workers must observe shutdown, for example a worker that:
51///
52/// - must stop a long-lived connection gracefully
53/// - must run cleanup
54/// - has no other way to know when to stop
55///
56/// Create such a worker with [`with_shutdown`][Self::with_shutdown] instead, which gives its body the signal.
57///
58/// This worker cannot be restarted, as the future is consumed during initialization. It reports this through
59/// [`can_restart`][Supervisable::can_restart], so its owner never restarts it.
60pub struct FnWorker {
61    name: String,
62    body: Mutex<Option<WorkerBody>>,
63    wants_signal: bool,
64}
65
66impl FnWorker {
67    /// Creates a worker that runs `fut` to completion.
68    ///
69    /// Workers may return either `()` or `Result<(), GenericError>`.
70    ///
71    /// Prefer [`worker`][crate::runtime::worker] and its counterparts on
72    /// [`SupervisorHandle`][crate::runtime::SupervisorHandle], which wrap this up with the defaults appropriate to a
73    /// dynamically spawned child.
74    ///
75    /// # Examples
76    ///
77    /// ```no_run
78    /// # use saluki_core::runtime::FnWorker;
79    /// # async fn drain_queue() {}
80    /// let worker = FnWorker::new("queue_drainer", drain_queue());
81    /// ```
82    #[must_use]
83    pub fn new<N, Fut>(name: N, fut: Fut) -> Self
84    where
85        N: Into<String>,
86        Fut: Future + Send + 'static,
87        Fut::Output: IntoWorkerResult,
88    {
89        Self {
90            name: name.into(),
91            body: Mutex::new(Some(Box::new(move |_| {
92                Box::pin(async move { fut.await.into_worker_result() })
93            }))),
94            wants_signal: false,
95        }
96    }
97
98    /// Creates a worker whose body gets the shutdown signal.
99    ///
100    /// `f` is called once, when the worker starts. Its argument is the [`ShutdownHandle`] that the owner of the worker
101    /// fires when the worker must stop.
102    ///
103    /// Prefer [`worker_with_shutdown`][crate::runtime::worker_with_shutdown] and its counterpart on
104    /// [`SupervisorHandle`][crate::runtime::SupervisorHandle]. These functions wrap this function with the correct
105    /// defaults for a dynamically spawned child.
106    ///
107    /// # Examples
108    ///
109    /// ```no_run
110    /// # use saluki_core::runtime::FnWorker;
111    /// # async fn serve_until(_: saluki_common::sync::shutdown::ShutdownHandle) {}
112    /// let worker = FnWorker::with_shutdown("conn_handler", |shutdown| serve_until(shutdown));
113    /// ```
114    #[must_use]
115    pub fn with_shutdown<N, F, Fut>(name: N, f: F) -> Self
116    where
117        N: Into<String>,
118        F: FnOnce(ShutdownHandle) -> Fut + Send + 'static,
119        Fut: Future + Send + 'static,
120        Fut::Output: IntoWorkerResult,
121    {
122        Self {
123            name: name.into(),
124            body: Mutex::new(Some(Box::new(move |shutdown| {
125                let fut = f(shutdown);
126                Box::pin(async move { fut.await.into_worker_result() })
127            }))),
128            wants_signal: true,
129        }
130    }
131}
132
133#[async_trait]
134impl Supervisable for FnWorker {
135    fn name(&self) -> &str {
136        &self.name
137    }
138
139    fn shutdown_strategy(&self) -> ShutdownStrategy {
140        ShutdownStrategy::Graceful(DEFAULT_SHUTDOWN_TIMEOUT)
141    }
142
143    fn wants_shutdown_signal(&self) -> bool {
144        self.wants_signal
145    }
146
147    fn can_restart(&self) -> bool {
148        // The body is consumed when the worker starts, so there is nothing left to start a second time.
149        false
150    }
151
152    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
153        let body = self
154            .body
155            .lock()
156            .expect("function worker mutex poisoned")
157            .take()
158            .ok_or_else(|| InitializationError::from(generic_error!("worker already initialized")))?;
159
160        Ok(body(process_shutdown))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use std::sync::{
167        atomic::{AtomicUsize, Ordering},
168        Arc,
169    };
170
171    use tokio::time::timeout;
172
173    use super::*;
174
175    /// Bound on any worker-body await in these tests.
176    ///
177    /// A worker that never finishes would otherwise hang the test process until the harness kills it, which reads as a
178    /// stall rather than a failure.
179    const RUN_TIMEOUT: Duration = Duration::from_secs(5);
180
181    #[tokio::test]
182    async fn worker_runs_its_future_to_completion() {
183        let ran = Arc::new(AtomicUsize::new(0));
184        let worker_ran = Arc::clone(&ran);
185
186        let worker = FnWorker::new("test", async move {
187            worker_ran.fetch_add(1, Ordering::SeqCst);
188        });
189
190        let run = worker
191            .initialize(ShutdownHandle::noop())
192            .await
193            .expect("should initialize");
194
195        timeout(RUN_TIMEOUT, run)
196            .await
197            .expect("worker should run to completion")
198            .expect("should exit cleanly");
199
200        assert_eq!(ran.load(Ordering::SeqCst), 1);
201    }
202
203    #[tokio::test]
204    async fn worker_propagates_error() {
205        let worker = FnWorker::new("test", async { Err::<(), _>(generic_error!("worker failed")) });
206
207        let run = worker
208            .initialize(ShutdownHandle::noop())
209            .await
210            .expect("should initialize");
211
212        let error = run.await.expect_err("should surface the worker's error");
213        assert!(error.to_string().contains("worker failed"));
214    }
215
216    #[tokio::test]
217    async fn worker_does_not_want_the_shutdown_signal() {
218        // The supervisor uses this to skip allocating a shutdown coordinator it would never fire: an `FnWorker` runs
219        // until its own terminal condition regardless of what the supervisor signals.
220        assert!(!FnWorker::new("test", async {}).wants_shutdown_signal());
221    }
222
223    #[test]
224    fn worker_cannot_restart() {
225        // Owners derive the restart policy from this, so that they never try to start a consumed body again.
226        assert!(!FnWorker::new("test", async {}).can_restart());
227        assert!(!FnWorker::with_shutdown("test", |shutdown| shutdown).can_restart());
228    }
229
230    #[tokio::test]
231    async fn worker_with_shutdown_wants_and_receives_the_signal() {
232        let worker = FnWorker::with_shutdown("test", |shutdown| async move {
233            shutdown.await;
234        });
235        assert!(worker.wants_shutdown_signal());
236
237        let (coordinator, handle) = ShutdownHandle::paired();
238        let run = worker.initialize(handle).await.expect("should initialize");
239        let run = tokio::spawn(run);
240
241        coordinator.shutdown();
242        timeout(RUN_TIMEOUT, run)
243            .await
244            .expect("worker should stop once signalled")
245            .expect("worker task should not panic")
246            .expect("should exit cleanly");
247    }
248
249    #[tokio::test]
250    async fn second_initialization_fails() {
251        // Function-based workers are one-shot: a restart would re-initialize, which must fail loudly rather than
252        // silently running nothing.
253        let worker = FnWorker::new("test", async {});
254
255        // Drop the run-future without polling it; we only care that the body was consumed.
256        drop(
257            worker
258                .initialize(ShutdownHandle::noop())
259                .await
260                .expect("first initialization should succeed"),
261        );
262
263        // `SupervisorFuture` isn't `Debug`, so match rather than using `expect_err`.
264        match worker.initialize(ShutdownHandle::noop()).await {
265            Ok(_) => panic!("second initialization should fail"),
266            Err(e) => assert!(e.to_string().contains("already initialized")),
267        }
268    }
269}