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}