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}