Skip to main content

rullst_core/
scheduler.rs

1//! # Rullst Task Scheduler (`rullst::scheduler`)
2//!
3//! Declarative cron jobs with bounded, observable execution lifecycles.
4
5use 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/// Strongly-typed error domain for scheduler operations.
13#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SchedulerError {
16    /// Invalid cron expression syntax.
17    #[error("invalid cron expression '{0}': {1}")]
18    InvalidCron(String, String),
19    /// The scheduler was started outside an active Tokio runtime.
20    #[error("the scheduler requires an active Tokio runtime")]
21    RuntimeUnavailable,
22    /// A task exceeded its configured execution deadline.
23    #[error("scheduled task '{label}' exceeded its {timeout_ms}ms timeout")]
24    TaskTimedOut {
25        /// Cron expression identifying the task.
26        label: String,
27        /// Configured timeout in milliseconds.
28        timeout_ms: u64,
29    },
30    /// A task panicked. The panic is contained in its isolated Tokio task.
31    #[error("scheduled task '{label}' panicked")]
32    TaskPanicked {
33        /// Cron expression identifying the task.
34        label: String,
35    },
36    /// A task execution was unexpectedly cancelled.
37    #[error("scheduled task '{label}' was cancelled")]
38    TaskCancelled {
39        /// Cron expression identifying the task.
40        label: String,
41    },
42    /// A schedule no longer has a future execution.
43    #[error("scheduled task '{label}' has no future execution")]
44    ScheduleExhausted {
45        /// Cron expression identifying the task.
46        label: String,
47    },
48    /// A scheduler loop terminated unexpectedly.
49    #[error("scheduler loop for '{label}' failed: {message}")]
50    LoopFailed {
51        /// Cron expression identifying the task.
52        label: String,
53        /// Runtime failure description.
54        message: String,
55    },
56}
57
58/// Action taken after a timeout, panic, or unexpected cancellation.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum SchedulerFailurePolicy {
62    /// Report the typed failure and continue with the next future cron tick.
63    #[default]
64    Continue,
65    /// Report the typed failure and permanently stop that task's loop.
66    StopTask,
67}
68
69/// The boxed async handler function type for scheduled tasks.
70pub type ScheduledHandler =
71    Arc<Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>>;
72
73/// A single scheduled task with a cron expression and async handler.
74pub struct ScheduledTask {
75    label: String,
76    schedule: cron::Schedule,
77    handler: ScheduledHandler,
78}
79
80/// A declarative scheduler for recurring asynchronous jobs.
81///
82/// Each registered task has exactly one serial execution loop. A slow run
83/// therefore skips already-missed cron instants instead of creating unbounded
84/// overlapping tasks. Handler futures execute in isolated Tokio tasks so their
85/// panics can be converted into [`SchedulerError`] values.
86pub struct Scheduler {
87    tasks: Vec<ScheduledTask>,
88    task_timeout: Duration,
89    failure_policy: SchedulerFailurePolicy,
90}
91
92impl Scheduler {
93    /// Creates an empty scheduler with a five-minute handler timeout.
94    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    /// Sets the maximum duration of one handler execution.
103    pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
104        self.task_timeout = timeout;
105        self
106    }
107
108    /// Selects whether a failing task continues at its next tick or stops.
109    pub fn with_failure_policy(mut self, policy: SchedulerFailurePolicy) -> Self {
110        self.failure_policy = policy;
111        self
112    }
113
114    /// Registers a recurring task using a standard five-field cron expression.
115    ///
116    /// # Errors
117    /// Returns [`SchedulerError::InvalidCron`] when the expression cannot be
118    /// parsed.
119    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    /// Starts every registered task and returns its lifecycle handle.
139    ///
140    /// Dropping the handle aborts all scheduler loops and their current handler
141    /// futures. Prefer [`SchedulerHandle::shutdown`] for graceful cancellation.
142    ///
143    /// # Errors
144    /// Returns [`SchedulerError::RuntimeUnavailable`] without spawning anything
145    /// when called outside Tokio.
146    #[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/// Owns running scheduler loops and exposes their typed failures.
181#[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    /// Waits for the next timeout, panic, or runtime failure reported by a task.
190    pub async fn next_error(&mut self) -> Option<SchedulerError> {
191        self.errors.recv().await
192    }
193
194    /// Returns a pending scheduler failure without waiting.
195    pub fn try_next_error(&mut self) -> Option<SchedulerError> {
196        self.errors.try_recv().ok()
197    }
198
199    /// Gracefully stops task loops, aborting any current handler execution.
200    ///
201    /// # Errors
202    /// Returns the first reported task failure, or a typed loop join failure.
203    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;