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}