saluki_core/runtime/
workers.rs1use 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
11const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
15
16pub trait IntoWorkerResult {
21 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
39pub 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 #[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#[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#[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 const RUN_TIMEOUT: Duration = Duration::from_secs(5);
186
187 #[tokio::test]
188 async fn noninterruptible_worker_receives_shutdown_signal() {
189 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 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 let worker = noninterruptible_worker("test", |_shutdown| async {});
261
262 drop(
264 worker
265 .initialize(ShutdownHandle::noop())
266 .await
267 .expect("first initialization should succeed"),
268 );
269
270 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}