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};
7use tracing::debug;
8
9use super::supervisor::{InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture};
10
11/// Default graceful shutdown period for a function-based worker.
12///
13/// Matches the [`Supervisable`] trait default, and applies only when nothing else sets a strategy for the child.
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 [`noninterruptible_worker`] and [`interruptible_worker`] 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(ShutdownHandle) -> SupervisorFuture + Send>;
38
39/// A [`Supervisable`] worker built from a closure.
40///
41/// This worker cannot be restarted as the closure is consumed during initialization.
42pub struct FnWorker {
43    name: String,
44    shutdown_strategy: ShutdownStrategy,
45    body: Mutex<Option<WorkerBody>>,
46}
47
48impl FnWorker {
49    fn new(name: String, body: WorkerBody) -> Self {
50        Self {
51            name,
52            shutdown_strategy: ShutdownStrategy::Graceful(DEFAULT_SHUTDOWN_TIMEOUT),
53            body: Mutex::new(Some(body)),
54        }
55    }
56
57    /// Sets the shutdown timeout for this worker.
58    #[must_use]
59    pub const fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
60        self.shutdown_strategy = ShutdownStrategy::Graceful(timeout);
61        self
62    }
63}
64
65#[async_trait]
66impl Supervisable for FnWorker {
67    fn name(&self) -> &str {
68        &self.name
69    }
70
71    fn shutdown_strategy(&self) -> ShutdownStrategy {
72        self.shutdown_strategy
73    }
74
75    async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
76        let body = self
77            .body
78            .lock()
79            .expect("function worker mutex poisoned")
80            .take()
81            .ok_or_else(|| InitializationError::from(generic_error!("worker already initialized")))?;
82
83        Ok(body(process_shutdown))
84    }
85}
86
87/// Creates a one-shot [`Supervisable`] worker that is never interrupted mid-operation.
88///
89/// The future that is created is solely responsible for handling the shutdown signal exposed to it via
90/// `ShutdownHandle`. Callers should ensure that they respond to shutdown signals in a timely manner otherwise they risk
91/// failing to run to completion when the supervisor forcefully exits. Use this for workers that need to drain in
92/// response to shutdown; for workers that are safe to stop at an arbitrary await point, [`interruptible_worker`] is
93/// simpler.
94///
95/// Workers may return either `()` or `Result<(), GenericError>`.
96///
97/// The worker cannot be restarted as the given closure is consumed during initialization.
98///
99/// # Examples
100///
101/// ```no_run
102/// # use saluki_core::runtime::noninterruptible_worker;
103/// # async fn drain_queue() {}
104/// let worker = noninterruptible_worker("queue_drainer", |shutdown| async move {
105///     tokio::select! {
106///         _ = shutdown => {},
107///         _ = drain_queue() => {},
108///     }
109/// });
110/// ```
111#[must_use]
112pub fn noninterruptible_worker<N, F, Fut>(name: N, f: F) -> FnWorker
113where
114    N: Into<String>,
115    F: FnOnce(ShutdownHandle) -> Fut + Send + 'static,
116    Fut: Future + Send + 'static,
117    Fut::Output: IntoWorkerResult,
118{
119    FnWorker::new(
120        name.into(),
121        Box::new(move |shutdown| Box::pin(async move { f(shutdown).await.into_worker_result() })),
122    )
123}
124
125/// Creates a one-shot [`Supervisable`] worker that runs `fut` until it completes or shutdown is signalled, whichever
126/// happens first.
127///
128/// The future that is given is subsequently wrapped such that shutdown is always handled: the underlying worker cannot
129/// ignore or defer honoring it. The future is dropped at whatever await point it happens to be parked on when shutdown
130/// fires, so use this only for work that is safe to interrupt: a server accept loop, a background refresher, a
131/// connection handler. Anything that must finish what it started should use [`noninterruptible_worker`] and observe the
132/// shutdown signal itself.
133///
134/// Workers may return either `()` or `Result<(), GenericError>`.
135///
136/// The worker cannot be restarted as the given future is consumed during initialization.
137///
138/// # Examples
139///
140/// ```no_run
141/// # use saluki_core::runtime::interruptible_worker;
142/// # async fn run_accept_loop() {}
143/// let worker = interruptible_worker("acceptor", run_accept_loop());
144/// ```
145#[must_use]
146pub fn interruptible_worker<N, Fut>(name: N, fut: Fut) -> FnWorker
147where
148    N: Into<String>,
149    Fut: Future + Send + 'static,
150    Fut::Output: IntoWorkerResult,
151{
152    let name = name.into();
153    FnWorker::new(
154        name.clone(),
155        Box::new(move |shutdown| {
156            Box::pin(async move {
157                tokio::select! {
158                    _ = shutdown => {
159                        debug!(worker_name = %name, "Worker interrupted by shutdown signal.");
160                        Ok(())
161                    },
162                    output = fut => output.into_worker_result(),
163                }
164            })
165        }),
166    )
167}
168
169#[cfg(test)]
170mod tests {
171    use std::sync::{
172        atomic::{AtomicUsize, Ordering},
173        Arc,
174    };
175
176    use saluki_common::sync::shutdown::ShutdownCoordinator;
177    use tokio::time::timeout;
178
179    use super::*;
180
181    /// Bound on any worker-body await in these tests.
182    ///
183    /// A worker that stops observing shutdown would otherwise hang the test process until the harness kills it, which
184    /// reads as a stall rather than a failure.
185    const RUN_TIMEOUT: Duration = Duration::from_secs(5);
186
187    #[tokio::test]
188    async fn noninterruptible_worker_receives_shutdown_signal() {
189        // A non-interruptible worker is handed the shutdown signal and is expected to observe it and return.
190        let observed = Arc::new(AtomicUsize::new(0));
191        let worker_observed = Arc::clone(&observed);
192
193        let worker = noninterruptible_worker("test", move |shutdown| async move {
194            shutdown.await;
195            worker_observed.fetch_add(1, Ordering::SeqCst);
196        });
197
198        let mut coordinator = ShutdownCoordinator::default();
199        let handle = coordinator.register();
200        let run = worker.initialize(handle).await.expect("should initialize");
201
202        coordinator.shutdown();
203        timeout(RUN_TIMEOUT, run)
204            .await
205            .expect("worker should observe shutdown and exit")
206            .expect("should exit cleanly");
207
208        assert_eq!(observed.load(Ordering::SeqCst), 1);
209    }
210
211    #[tokio::test]
212    async fn noninterruptible_worker_propagates_error() {
213        let worker = noninterruptible_worker("test", |_shutdown| async move {
214            Err::<(), _>(generic_error!("worker failed"))
215        });
216
217        let run = worker
218            .initialize(ShutdownHandle::noop())
219            .await
220            .expect("should initialize");
221
222        let error = run.await.expect_err("should surface the worker's error");
223        assert!(error.to_string().contains("worker failed"));
224    }
225
226    #[tokio::test]
227    async fn interruptible_worker_is_interrupted_at_shutdown() {
228        // The future never completes on its own, so the only way out is being interrupted -- which is reported as a
229        // clean exit rather than an error.
230        let worker = interruptible_worker("test", std::future::pending::<()>());
231
232        let mut coordinator = ShutdownCoordinator::default();
233        let handle = coordinator.register();
234        let run = worker.initialize(handle).await.expect("should initialize");
235
236        coordinator.shutdown();
237        timeout(RUN_TIMEOUT, run)
238            .await
239            .expect("worker should be interrupted by shutdown and exit")
240            .expect("being interrupted should be reported as a clean exit");
241    }
242
243    #[tokio::test]
244    async fn interruptible_worker_returns_future_output_when_it_completes_first() {
245        let worker = interruptible_worker("test", async { Err::<(), _>(generic_error!("boom")) });
246
247        let run = worker
248            .initialize(ShutdownHandle::noop())
249            .await
250            .expect("should initialize");
251
252        let error = run.await.expect_err("should surface the future's error");
253        assert!(error.to_string().contains("boom"));
254    }
255
256    #[tokio::test]
257    async fn second_initialization_fails() {
258        // Function-based workers are one-shot: a restart would re-initialize, which must fail loudly rather than
259        // silently running nothing.
260        let worker = noninterruptible_worker("test", |_shutdown| async {});
261
262        // Drop the run-future without polling it; we only care that the body was consumed.
263        drop(
264            worker
265                .initialize(ShutdownHandle::noop())
266                .await
267                .expect("first initialization should succeed"),
268        );
269
270        // `SupervisorFuture` isn't `Debug`, so match rather than using `expect_err`.
271        match worker.initialize(ShutdownHandle::noop()).await {
272            Ok(_) => panic!("second initialization should fail"),
273            Err(e) => assert!(e.to_string().contains("already initialized")),
274        }
275    }
276}