1use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::{mpsc, watch};
10use tokio::task::JoinHandle;
11
12#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SchedulerError {
16 #[error("invalid cron expression '{0}': {1}")]
18 InvalidCron(String, String),
19 #[error("the scheduler requires an active Tokio runtime")]
21 RuntimeUnavailable,
22 #[error("scheduled task '{label}' exceeded its {timeout_ms}ms timeout")]
24 TaskTimedOut {
25 label: String,
27 timeout_ms: u64,
29 },
30 #[error("scheduled task '{label}' panicked")]
32 TaskPanicked {
33 label: String,
35 },
36 #[error("scheduled task '{label}' was cancelled")]
38 TaskCancelled {
39 label: String,
41 },
42 #[error("scheduled task '{label}' has no future execution")]
44 ScheduleExhausted {
45 label: String,
47 },
48 #[error("scheduler loop for '{label}' failed: {message}")]
50 LoopFailed {
51 label: String,
53 message: String,
55 },
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum SchedulerFailurePolicy {
62 #[default]
64 Continue,
65 StopTask,
67}
68
69pub type ScheduledHandler =
71 Arc<Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>>;
72
73pub struct ScheduledTask {
75 label: String,
76 schedule: cron::Schedule,
77 handler: ScheduledHandler,
78}
79
80pub struct Scheduler {
87 tasks: Vec<ScheduledTask>,
88 task_timeout: Duration,
89 failure_policy: SchedulerFailurePolicy,
90}
91
92impl Scheduler {
93 pub fn new() -> Self {
95 Self {
96 tasks: Vec::new(),
97 task_timeout: Duration::from_secs(300),
98 failure_policy: SchedulerFailurePolicy::Continue,
99 }
100 }
101
102 pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
104 self.task_timeout = timeout;
105 self
106 }
107
108 pub fn with_failure_policy(mut self, policy: SchedulerFailurePolicy) -> Self {
110 self.failure_policy = policy;
111 self
112 }
113
114 pub fn task<F, Fut>(mut self, cron_expr: &str, handler: F) -> Result<Self, SchedulerError>
120 where
121 F: Fn() -> Fut + Send + Sync + 'static,
122 Fut: Future<Output = ()> + Send + 'static,
123 {
124 let full_expr = format!("0 {cron_expr} *");
125 let schedule: cron::Schedule = full_expr.parse().map_err(|error: cron::error::Error| {
126 SchedulerError::InvalidCron(cron_expr.to_string(), error.to_string())
127 })?;
128 let boxed: ScheduledHandler = Arc::new(Box::new(move || Box::pin(handler())));
129
130 self.tasks.push(ScheduledTask {
131 label: cron_expr.to_string(),
132 schedule,
133 handler: boxed,
134 });
135 Ok(self)
136 }
137
138 #[cfg_attr(mutants, mutants::skip)]
147 pub fn start(self) -> Result<SchedulerHandle, SchedulerError> {
148 let runtime = tokio::runtime::Handle::try_current()
149 .map_err(|_| SchedulerError::RuntimeUnavailable)?;
150 let (shutdown, _) = watch::channel(false);
151 let (errors_tx, errors) = mpsc::unbounded_channel();
152 let mut loops = Vec::with_capacity(self.tasks.len());
153
154 for task in self.tasks {
155 let label = task.label.clone();
156 let shutdown_rx = shutdown.subscribe();
157 let errors_tx = errors_tx.clone();
158 let timeout = self.task_timeout;
159 let policy = self.failure_policy;
160 let task_loop =
161 runtime.spawn(run_task_loop(task, timeout, policy, shutdown_rx, errors_tx));
162 loops.push((label, task_loop));
163 }
164 drop(errors_tx);
165
166 Ok(SchedulerHandle {
167 shutdown,
168 loops,
169 errors,
170 })
171 }
172}
173
174impl Default for Scheduler {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180#[must_use = "dropping the scheduler handle immediately stops all scheduled tasks"]
182pub struct SchedulerHandle {
183 shutdown: watch::Sender<bool>,
184 loops: Vec<(String, tokio::task::JoinHandle<()>)>,
185 errors: mpsc::UnboundedReceiver<SchedulerError>,
186}
187
188impl SchedulerHandle {
189 pub async fn next_error(&mut self) -> Option<SchedulerError> {
191 self.errors.recv().await
192 }
193
194 pub fn try_next_error(&mut self) -> Option<SchedulerError> {
196 self.errors.try_recv().ok()
197 }
198
199 pub async fn shutdown(mut self) -> Result<(), SchedulerError> {
204 let _ = self.shutdown.send(true);
205 let mut first_error = self.try_next_error();
206
207 for (label, task_loop) in self.loops.drain(..) {
208 if let Err(error) = task_loop.await
209 && !error.is_cancelled()
210 && first_error.is_none()
211 {
212 first_error = Some(SchedulerError::LoopFailed {
213 label,
214 message: error.to_string(),
215 });
216 }
217 }
218
219 first_error = first_error.or_else(|| self.try_next_error());
220 match first_error {
221 Some(error) => Err(error),
222 None => Ok(()),
223 }
224 }
225
226 fn abort(&mut self) {
227 let _ = self.shutdown.send(true);
228 for (_, task_loop) in &self.loops {
229 task_loop.abort();
230 }
231 self.loops.clear();
232 }
233}
234
235impl Drop for SchedulerHandle {
236 fn drop(&mut self) {
237 self.abort();
238 }
239}
240
241#[cfg_attr(mutants, mutants::skip)]
242async fn run_task_loop(
243 task: ScheduledTask,
244 timeout: Duration,
245 policy: SchedulerFailurePolicy,
246 mut shutdown: watch::Receiver<bool>,
247 errors: mpsc::UnboundedSender<SchedulerError>,
248) {
249 loop {
250 if shutdown_requested(&shutdown) {
251 break;
252 }
253
254 let now = chrono::Utc::now();
255 let Some(next) = task.schedule.upcoming(chrono::Utc).next() else {
256 let _ = errors.send(SchedulerError::ScheduleExhausted {
257 label: task.label.clone(),
258 });
259 break;
260 };
261 let wait = (next - now).to_std().unwrap_or(Duration::ZERO);
262
263 tokio::select! {
264 _ = tokio::time::sleep(wait) => {}
265 _ = wait_for_shutdown(&mut shutdown) => break,
266 }
267
268 match execute_handler(&task, timeout, &mut shutdown).await {
269 Ok(ExecutionStatus::Completed) => {}
270 Ok(ExecutionStatus::ShutDown) => break,
271 Err(error) => {
272 let _ = errors.send(error);
273 if policy == SchedulerFailurePolicy::StopTask {
274 break;
275 }
276 }
277 }
278 }
279}
280
281enum ExecutionStatus {
282 Completed,
283 ShutDown,
284}
285
286#[cfg_attr(mutants, mutants::skip)]
287async fn execute_handler(
288 task: &ScheduledTask,
289 timeout: Duration,
290 shutdown: &mut watch::Receiver<bool>,
291) -> Result<ExecutionStatus, SchedulerError> {
292 let handler = Arc::clone(&task.handler);
293 let mut execution = AbortOnDrop(tokio::spawn(async move { handler().await }));
294 let deadline = tokio::time::sleep(timeout);
295 tokio::pin!(deadline);
296
297 tokio::select! {
298 result = &mut execution.0 => match result {
299 Ok(()) => Ok(ExecutionStatus::Completed),
300 Err(error) if error.is_panic() => Err(SchedulerError::TaskPanicked {
301 label: task.label.clone(),
302 }),
303 Err(_) => Err(SchedulerError::TaskCancelled {
304 label: task.label.clone(),
305 }),
306 },
307 _ = &mut deadline => {
308 execution.0.abort();
309 let _ = (&mut execution.0).await;
310 Err(SchedulerError::TaskTimedOut {
311 label: task.label.clone(),
312 timeout_ms: duration_millis_u64(timeout),
313 })
314 }
315 _ = wait_for_shutdown(shutdown) => {
316 execution.0.abort();
317 let _ = (&mut execution.0).await;
318 Ok(ExecutionStatus::ShutDown)
319 }
320 }
321}
322
323fn shutdown_requested(shutdown: &watch::Receiver<bool>) -> bool {
324 *shutdown.borrow()
325}
326
327async fn wait_for_shutdown(shutdown: &mut watch::Receiver<bool>) {
328 while !shutdown_requested(shutdown) {
329 if shutdown.changed().await.is_err() {
330 break;
331 }
332 }
333}
334
335fn duration_millis_u64(duration: Duration) -> u64 {
336 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
337}
338
339struct AbortOnDrop<T>(JoinHandle<T>);
340
341impl<T> Drop for AbortOnDrop<T> {
342 fn drop(&mut self) {
343 self.0.abort();
344 }
345}
346
347#[cfg(test)]
348#[allow(clippy::unwrap_used, clippy::expect_used)]
349mod tests {
350 use super::*;
351 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
352
353 fn task_for_test<F, Fut>(handler: F) -> ScheduledTask
354 where
355 F: Fn() -> Fut + Send + Sync + 'static,
356 Fut: Future<Output = ()> + Send + 'static,
357 {
358 Scheduler::new()
359 .task("* * * * *", handler)
360 .unwrap()
361 .tasks
362 .remove(0)
363 }
364
365 #[test]
366 fn scheduler_start_without_runtime_is_fallible() {
367 let result = Scheduler::new().start();
368 assert!(matches!(result, Err(SchedulerError::RuntimeUnavailable)));
369 }
370
371 #[test]
372 fn invalid_cron_is_rejected() {
373 let result = Scheduler::new().task("invalid cron", || async {});
374 assert!(matches!(result, Err(SchedulerError::InvalidCron(_, _))));
375 }
376
377 #[tokio::test]
378 async fn handler_timeout_is_typed_and_contained() {
379 let task = task_for_test(|| async {
380 tokio::time::sleep(Duration::from_secs(1)).await;
381 });
382 let (_shutdown_tx, mut shutdown) = watch::channel(false);
383 let result = execute_handler(&task, Duration::from_millis(5), &mut shutdown).await;
384
385 assert!(matches!(
386 result,
387 Err(SchedulerError::TaskTimedOut { timeout_ms: 5, .. })
388 ));
389 }
390
391 #[tokio::test]
392 async fn handler_panic_is_typed_and_does_not_escape() {
393 let task = task_for_test(|| async { panic!("test panic") });
394 let (_shutdown_tx, mut shutdown) = watch::channel(false);
395 let result = execute_handler(&task, Duration::from_secs(1), &mut shutdown).await;
396
397 assert!(matches!(result, Err(SchedulerError::TaskPanicked { .. })));
398 }
399
400 #[tokio::test]
401 async fn explicit_shutdown_terminates_sleeping_loops() {
402 let scheduler = Scheduler::new().task("* * * * *", || async {}).unwrap();
403 let handle = scheduler.start().unwrap();
404 let result = tokio::time::timeout(Duration::from_millis(100), handle.shutdown()).await;
405
406 assert!(result.is_ok());
407 assert!(result.unwrap().is_ok());
408 }
409
410 #[tokio::test]
411 async fn shutdown_aborts_current_handler() {
412 let task = task_for_test(|| async {
413 std::future::pending::<()>().await;
414 });
415 let (shutdown_tx, mut shutdown) = watch::channel(false);
416 let execution = tokio::spawn(async move {
417 execute_handler(&task, Duration::from_secs(60), &mut shutdown).await
418 });
419 tokio::task::yield_now().await;
420 shutdown_tx.send(true).unwrap();
421
422 assert!(matches!(
423 execution.await.unwrap().unwrap(),
424 ExecutionStatus::ShutDown
425 ));
426 }
427
428 #[tokio::test]
429 async fn task_loop_never_overlaps_slow_executions() {
430 let active = Arc::new(AtomicUsize::new(0));
431 let maximum = Arc::new(AtomicUsize::new(0));
432 let executions = Arc::new(AtomicUsize::new(0));
433 let active_for_handler = Arc::clone(&active);
434 let maximum_for_handler = Arc::clone(&maximum);
435 let executions_for_handler = Arc::clone(&executions);
436 let handler: ScheduledHandler = Arc::new(Box::new(move || {
437 let active = Arc::clone(&active_for_handler);
438 let maximum = Arc::clone(&maximum_for_handler);
439 let executions = Arc::clone(&executions_for_handler);
440 Box::pin(async move {
441 let running = active.fetch_add(1, Ordering::SeqCst) + 1;
442 maximum.fetch_max(running, Ordering::SeqCst);
443 executions.fetch_add(1, Ordering::SeqCst);
444 tokio::time::sleep(Duration::from_millis(1_100)).await;
445 active.fetch_sub(1, Ordering::SeqCst);
446 })
447 }));
448 let task = ScheduledTask {
449 label: "every second".to_string(),
450 schedule: "* * * * * * *".parse().unwrap(),
451 handler,
452 };
453 let (shutdown_tx, shutdown) = watch::channel(false);
454 let (errors_tx, mut errors) = mpsc::unbounded_channel();
455 let task_loop = tokio::spawn(run_task_loop(
456 task,
457 Duration::from_secs(5),
458 SchedulerFailurePolicy::Continue,
459 shutdown,
460 errors_tx,
461 ));
462
463 tokio::time::sleep(Duration::from_millis(2_300)).await;
464 shutdown_tx.send(true).unwrap();
465 task_loop.await.unwrap();
466
467 assert!(executions.load(Ordering::SeqCst) >= 1);
468 assert_eq!(maximum.load(Ordering::SeqCst), 1);
469 assert!(errors.try_recv().is_err());
470 }
471
472 #[tokio::test]
473 async fn aborting_execution_drops_the_handler_future() {
474 struct Dropped(Arc<AtomicBool>);
475 impl Drop for Dropped {
476 fn drop(&mut self) {
477 self.0.store(true, Ordering::SeqCst);
478 }
479 }
480
481 let started = Arc::new(AtomicBool::new(false));
482 let dropped = Arc::new(AtomicBool::new(false));
483 let started_for_handler = Arc::clone(&started);
484 let dropped_for_handler = Arc::clone(&dropped);
485 let task = task_for_test(move || {
486 let started = Arc::clone(&started_for_handler);
487 let dropped = Arc::clone(&dropped_for_handler);
488 async move {
489 let _drop_guard = Dropped(dropped);
490 started.store(true, Ordering::SeqCst);
491 std::future::pending::<()>().await;
492 }
493 });
494 let (_shutdown_tx, mut shutdown) = watch::channel(false);
495 let outer = tokio::spawn(async move {
496 execute_handler(&task, Duration::from_secs(60), &mut shutdown).await
497 });
498 while !started.load(Ordering::SeqCst) {
499 tokio::task::yield_now().await;
500 }
501 outer.abort();
502 let _ = outer.await;
503 tokio::time::timeout(Duration::from_millis(100), async {
504 while !dropped.load(Ordering::SeqCst) {
505 tokio::task::yield_now().await;
506 }
507 })
508 .await
509 .unwrap();
510 }
511}
512
513#[cfg(test)]
514#[path = "scheduler_contract_tests.rs"]
515mod contract_tests;