Skip to main content

rws_server/scheduler/
mod.rs

1//! Background task scheduler — a `@Scheduled`-style runner for fixed-rate,
2//! fixed-delay, and cron-expression tasks.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! # extern crate rws_server as rust_web_server;
8//! use std::time::Duration;
9//! use rust_web_server::scheduler::Scheduler;
10//!
11//! Scheduler::new()
12//!     // Runs every 60 s (interval measured from task start).
13//!     .every(Duration::from_secs(60), || println!("tick"))
14//!     // Waits 30 s after each run completes before starting the next.
15//!     .after(Duration::from_secs(30), || println!("heartbeat"))
16//!     // Fires at second 0 of every minute.
17//!     .cron("0 * * * * *", || println!("every minute")).unwrap()
18//!     // 10 s pause before the first run of the most recently added task.
19//!     .initial_delay(Duration::from_secs(10))
20//!     .start();
21//! ```
22
23// `cron` is pure date/time math (no threads) and stays available on every
24// target — `app::controller::static_resource` uses it unconditionally for
25// Last-Modified/cache-header formatting. `Scheduler` itself spawns one OS
26// thread per task and is native-only; see spec/WASM_SHIM.md.
27pub mod cron;
28pub use cron::CronSchedule;
29
30#[cfg(all(test, not(target_arch = "wasm32")))]
31mod tests;
32
33#[cfg(not(target_arch = "wasm32"))]
34use std::sync::Arc;
35#[cfg(not(target_arch = "wasm32"))]
36use std::time::{Duration, Instant};
37
38/// A `@Scheduled`-style background task runner.
39///
40/// Register tasks with `.every()`, `.after()`, or `.cron()`, then call
41/// `.start()` to spawn one dedicated background thread per task.
42#[cfg(not(target_arch = "wasm32"))]
43pub struct Scheduler {
44    tasks: Vec<Task>,
45}
46
47#[cfg(not(target_arch = "wasm32"))]
48struct Task {
49    kind: TaskKind,
50    initial_delay: Duration,
51    f: Arc<dyn Fn() + Send + Sync + 'static>,
52}
53
54#[cfg(not(target_arch = "wasm32"))]
55enum TaskKind {
56    /// Fixed rate — interval measured from the *start* of the previous run.
57    FixedRate(Duration),
58    /// Fixed delay — interval measured from the *end* of the previous run.
59    FixedDelay(Duration),
60    /// Cron — fires whenever the wall clock matches the parsed expression.
61    Cron(CronSchedule),
62}
63
64#[cfg(not(target_arch = "wasm32"))]
65impl Scheduler {
66    pub fn new() -> Self {
67        Scheduler { tasks: Vec::new() }
68    }
69
70    /// Run `task` every `interval`, measured from the start of the previous run.
71    /// If the task takes longer than `interval`, the next run starts immediately.
72    pub fn every(mut self, interval: Duration, task: impl Fn() + Send + Sync + 'static) -> Self {
73        self.tasks.push(Task {
74            kind: TaskKind::FixedRate(interval),
75            initial_delay: Duration::ZERO,
76            f: Arc::new(task),
77        });
78        self
79    }
80
81    /// Run `task` with `delay` between the end of one run and the start of the next.
82    pub fn after(mut self, delay: Duration, task: impl Fn() + Send + Sync + 'static) -> Self {
83        self.tasks.push(Task {
84            kind: TaskKind::FixedDelay(delay),
85            initial_delay: Duration::ZERO,
86            f: Arc::new(task),
87        });
88        self
89    }
90
91    /// Run `task` according to a 6-field cron expression.
92    ///
93    /// Format: `"second minute hour day-of-month month day-of-week"` (UTC).
94    ///
95    /// Each field supports `*`, an exact value, `*/step`, an `N-M` range, and
96    /// comma-separated combinations, e.g. `"0,30 * * * * *"` fires at seconds 0 and 30.
97    ///
98    /// Day-of-week: 0 = Sunday, 6 = Saturday.
99    pub fn cron(
100        mut self,
101        expr: &str,
102        task: impl Fn() + Send + Sync + 'static,
103    ) -> Result<Self, String> {
104        let schedule = CronSchedule::parse(expr)?;
105        self.tasks.push(Task {
106            kind: TaskKind::Cron(schedule),
107            initial_delay: Duration::ZERO,
108            f: Arc::new(task),
109        });
110        Ok(self)
111    }
112
113    /// Add an initial delay before the first run of the most recently registered task.
114    pub fn initial_delay(mut self, delay: Duration) -> Self {
115        if let Some(t) = self.tasks.last_mut() {
116            t.initial_delay = delay;
117        }
118        self
119    }
120
121    /// Spawn one background thread per registered task and return immediately.
122    /// Threads run for the lifetime of the process.
123    pub fn start(self) {
124        for task in self.tasks {
125            let f = task.f.clone();
126            let initial_delay = task.initial_delay;
127            std::thread::spawn(move || {
128                if !initial_delay.is_zero() {
129                    std::thread::sleep(initial_delay);
130                }
131                match task.kind {
132                    TaskKind::FixedRate(interval) => loop {
133                        let start = Instant::now();
134                        f();
135                        let elapsed = start.elapsed();
136                        if elapsed < interval {
137                            std::thread::sleep(interval - elapsed);
138                        }
139                    },
140                    TaskKind::FixedDelay(delay) => loop {
141                        f();
142                        std::thread::sleep(delay);
143                    },
144                    TaskKind::Cron(ref schedule) => {
145                        // Poll at 200 ms resolution; track last-fired second to avoid
146                        // double-firing when the task finishes within the same second.
147                        let mut last_fired_secs: u64 = 0;
148                        loop {
149                            let now_secs = std::time::SystemTime::now()
150                                .duration_since(std::time::UNIX_EPOCH)
151                                .unwrap_or_default()
152                                .as_secs();
153                            if now_secs != last_fired_secs
154                                && schedule.matches_epoch(now_secs)
155                            {
156                                last_fired_secs = now_secs;
157                                f();
158                            }
159                            std::thread::sleep(Duration::from_millis(200));
160                        }
161                    }
162                }
163            });
164        }
165    }
166}
167
168#[cfg(not(target_arch = "wasm32"))]
169impl Default for Scheduler {
170    fn default() -> Self {
171        Scheduler::new()
172    }
173}