Skip to main content

rainier_framework/
scheduling.rs

1//! Scheduling — the tasks and commands that need more than the scheduler crate
2//! depends on.
3//!
4//! `rainier-scheduler` knows about the container and the cache and nothing
5//! else, which is what keeps it usable without a queue or a console. The two
6//! things people actually schedule — a queued job and a console command — are
7//! adapters, and they live here because this is the crate that has both.
8//!
9//! ```ignore
10//! // src/routes/console.rs
11//! pub fn schedule(schedule: &mut Schedule) {
12//!     schedule.job(PruneSessions).daily_at("03:00").without_overlapping(HALF_HOUR);
13//!     schedule.command("app:seed").weekly_on(1, "04:00").on_one_server();
14//!     schedule.call("heartbeat", |_| Box::pin(async { Ok(()) })).every_five_minutes();
15//! }
16//! ```
17
18use std::sync::Arc;
19use std::time::Duration;
20
21use chrono::Utc;
22use rainier_cache::LockManager;
23use rainier_console::{exit, Arguments, Command, Console};
24use rainier_container::Application;
25use rainier_queue::{Job, QueueManager};
26use rainier_scheduler::{Schedule, ScheduledTask, Task};
27use rainier_support::{BoxFuture, Error, Result};
28
29/// A [`Task`] that dispatches a queued job.
30///
31/// The scheduler's own work is then only "put it on the queue", which is what
32/// you want: a scheduled task that runs for ten minutes is ten minutes the
33/// scheduler is not looking at anything else, and a worker is the thing built
34/// to run long work.
35pub struct JobTask<J> {
36    job: Arc<J>,
37}
38
39impl<J: Job + Clone> JobTask<J> {
40    /// Dispatch `job` when the schedule fires.
41    pub fn new(job: J) -> Self {
42        Self { job: Arc::new(job) }
43    }
44}
45
46impl<J: Job + Clone> Task for JobTask<J> {
47    fn name(&self) -> String {
48        // The job's registered name, which is stable across restarts and
49        // machines — exactly what a lock key has to be.
50        J::NAME.to_string()
51    }
52
53    fn run<'a>(&'a self, app: &'a Application) -> BoxFuture<'a, Result<()>> {
54        Box::pin(async move {
55            let queue = app.resolve::<QueueManager>()?;
56            queue.dispatch((*self.job).clone()).await?;
57            Ok(())
58        })
59    }
60}
61
62/// A [`Task`] that runs a console command.
63///
64/// Laravel's `$schedule->command('inspire')`. The command is resolved from the
65/// console at run time, so a typo is a failure of that occurrence rather than
66/// of the boot — which is the trade for being able to name a command the
67/// scheduler crate cannot see.
68pub struct CommandTask {
69    name: String,
70    argv: Vec<String>,
71    console: Arc<Console>,
72}
73
74impl CommandTask {
75    /// Run `name` from `console`, with `argv` after it.
76    pub fn new(console: Arc<Console>, name: impl Into<String>, argv: Vec<String>) -> Self {
77        Self { name: name.into(), argv, console }
78    }
79}
80
81impl Task for CommandTask {
82    fn name(&self) -> String {
83        if self.argv.is_empty() {
84            self.name.clone()
85        } else {
86            format!("{} {}", self.name, self.argv.join(" "))
87        }
88    }
89
90    fn run<'a>(&'a self, app: &'a Application) -> BoxFuture<'a, Result<()>> {
91        Box::pin(async move {
92            let mut argv = vec![self.name.clone()];
93            argv.extend(self.argv.iter().cloned());
94
95            let code = self.console.run_argv(app, argv).await;
96            if code == exit::SUCCESS {
97                Ok(())
98            } else {
99                Err(Error::internal(format!("`{}` exited with {code}", self.name)))
100            }
101        })
102    }
103}
104
105/// `job` and `command`, on top of [`Schedule`].
106///
107/// An extension trait rather than methods on `Schedule`, because the scheduler
108/// crate would otherwise have to depend on the queue and the console — and then
109/// an application with neither would compile both.
110pub trait ScheduleExt {
111    /// Dispatch a queued job on a schedule.
112    fn job<J: Job + Clone>(&mut self, job: J) -> &mut ScheduledTask;
113
114    /// Run a console command on a schedule.
115    fn command(&mut self, console: Arc<Console>, name: impl Into<String>) -> &mut ScheduledTask;
116
117    /// Run a console command with arguments.
118    fn command_with(
119        &mut self,
120        console: Arc<Console>,
121        name: impl Into<String>,
122        argv: impl IntoIterator<Item = impl Into<String>>,
123    ) -> &mut ScheduledTask;
124}
125
126impl ScheduleExt for Schedule {
127    fn job<J: Job + Clone>(&mut self, job: J) -> &mut ScheduledTask {
128        self.add(JobTask::new(job))
129    }
130
131    fn command(&mut self, console: Arc<Console>, name: impl Into<String>) -> &mut ScheduledTask {
132        self.add(CommandTask::new(console, name, Vec::new()))
133    }
134
135    fn command_with(
136        &mut self,
137        console: Arc<Console>,
138        name: impl Into<String>,
139        argv: impl IntoIterator<Item = impl Into<String>>,
140    ) -> &mut ScheduledTask {
141        self.add(CommandTask::new(console, name, argv.into_iter().map(Into::into).collect()))
142    }
143}
144
145/// `schedule:run` — run whatever is due this minute, then exit.
146///
147/// What a system cron entry calls:
148///
149/// ```cron
150/// * * * * * cd /srv/app && ./app schedule:run >> /dev/null 2>&1
151/// ```
152#[derive(Debug, Default)]
153pub struct ScheduleRunCommand;
154
155#[async_trait::async_trait]
156impl Command for ScheduleRunCommand {
157    fn name(&self) -> &str {
158        "schedule:run"
159    }
160
161    fn description(&self) -> &str {
162        "Run every scheduled task that is due"
163    }
164
165    fn help(&self) -> Option<&str> {
166        Some(
167            "Usage:\n  schedule:run\n\n\
168             Runs the tasks due this minute and exits. Call it once a minute\n\
169             from cron, a systemd timer, or a Kubernetes CronJob.\n\n\
170             Exits non-zero if any task failed, so a supervisor notices.",
171        )
172    }
173
174    async fn handle(&self, _args: &Arguments, app: &Application) -> Result<i32> {
175        let schedule = app.resolve::<Schedule>()?;
176        let locks = app.resolve::<LockManager>()?;
177
178        refuse_a_broken_schedule(&schedule)?;
179
180        let summary = schedule.run_due(app, &locks, Utc::now()).await;
181
182        if summary.considered() == 0 {
183            println!("Nothing is due.");
184            return Ok(exit::SUCCESS);
185        }
186
187        for name in &summary.ran {
188            println!("Ran: {name}");
189        }
190        for (name, why) in &summary.skipped {
191            println!("Skipped: {name} — {why}");
192        }
193        for (name, error) in &summary.failed {
194            eprintln!("Failed: {name} — {error}");
195        }
196
197        Ok(if summary.has_failures() { exit::FAILURE } else { exit::SUCCESS })
198    }
199}
200
201/// `schedule:work` — the same thing in a loop, for a container.
202///
203/// For a deployment with no cron: one process that wakes at the top of each
204/// minute. Equivalent to `schedule:run` on a timer, and easier to supervise.
205#[derive(Debug, Default)]
206pub struct ScheduleWorkCommand;
207
208#[async_trait::async_trait]
209impl Command for ScheduleWorkCommand {
210    fn name(&self) -> &str {
211        "schedule:work"
212    }
213
214    fn description(&self) -> &str {
215        "Run the scheduler in the foreground, once a minute"
216    }
217
218    fn help(&self) -> Option<&str> {
219        Some(
220            "Usage:\n  schedule:work [--once]\n\n\
221             Options:\n  --once  Run the due tasks once and exit\n\n\
222             For a deployment with no cron — a container, a systemd unit. One\n\
223             process, supervised like any other.",
224        )
225    }
226
227    async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
228        let schedule = app.resolve::<Schedule>()?;
229        let locks = app.resolve::<LockManager>()?;
230
231        refuse_a_broken_schedule(&schedule)?;
232
233        if args.flag("once") {
234            let summary = schedule.run_due(app, &locks, Utc::now()).await;
235            return Ok(if summary.has_failures() { exit::FAILURE } else { exit::SUCCESS });
236        }
237
238        println!("Scheduler running — {} task(s). Press Ctrl-C to stop.", schedule.len());
239
240        loop {
241            // Sleep to the top of the next minute rather than for sixty
242            // seconds. Sleeping for a fixed interval drifts: each pass takes a
243            // little longer than it slept, and after enough hours a task
244            // scheduled for `:00` is running at `:01` and missing its minute
245            // entirely.
246            tokio::time::sleep(until_next_minute()).await;
247
248            let summary = schedule.run_due(app, &locks, Utc::now()).await;
249            if summary.considered() > 0 {
250                tracing::info!(
251                    ran = summary.ran.len(),
252                    skipped = summary.skipped.len(),
253                    failed = summary.failed.len(),
254                    "scheduler pass"
255                );
256            }
257        }
258    }
259}
260
261/// `schedule:list` — what is scheduled, and when it next runs.
262#[derive(Debug, Default)]
263pub struct ScheduleListCommand;
264
265#[async_trait::async_trait]
266impl Command for ScheduleListCommand {
267    fn name(&self) -> &str {
268        "schedule:list"
269    }
270
271    fn description(&self) -> &str {
272        "List every scheduled task and when it next runs"
273    }
274
275    async fn handle(&self, _args: &Arguments, app: &Application) -> Result<i32> {
276        let schedule = app.resolve::<Schedule>()?;
277
278        if schedule.is_empty() {
279            println!("Nothing is scheduled.");
280            return Ok(exit::SUCCESS);
281        }
282
283        let now = Utc::now();
284        let name_width =
285            schedule.tasks().iter().map(|t| t.description().len()).max().unwrap_or(4).max(4);
286        let expression_width =
287            schedule.tasks().iter().map(|t| t.expression().source().len()).max().unwrap_or(10);
288
289        println!(
290            "{:name_width$}  {:expression_width$}  {:19}  GUARDS",
291            "TASK", "SCHEDULE", "NEXT RUN (UTC)"
292        );
293
294        for task in schedule.tasks() {
295            let next = task
296                .next_run(now)
297                .map(|at| at.format("%Y-%m-%d %H:%M:%S").to_string())
298                .unwrap_or_else(|| "never".to_string());
299
300            let mut guards = Vec::new();
301            if task.overlap_ttl().is_some() {
302                guards.push("without-overlapping");
303            }
304            if task.is_one_server() {
305                guards.push("one-server");
306            }
307
308            println!(
309                "{:name_width$}  {:expression_width$}  {next:19}  {}",
310                task.description(),
311                task.expression().source(),
312                guards.join(", "),
313            );
314        }
315
316        // Both of these make a lock silently useless, so they are worth
317        // printing even when nobody asked.
318        for (name, error) in schedule.errors() {
319            eprintln!("\nInvalid schedule for `{name}`: {error}");
320        }
321        for name in schedule.duplicate_names() {
322            eprintln!(
323                "\nTwo tasks are both called `{name}`. They share a lock, so \
324                 `without_overlapping` between them makes each block the other."
325            );
326        }
327
328        println!("\n{} task(s)", schedule.len());
329        Ok(exit::SUCCESS)
330    }
331}
332
333/// Fail rather than run a schedule with a task on the wrong expression.
334///
335/// The builders are lenient so a chain does not need a `?` in the middle of it;
336/// this is where that leniency is paid back. A task quietly on `* * * * *`
337/// because `.daily_at("3pm")` did not parse is worse than a scheduler that
338/// refuses to start.
339fn refuse_a_broken_schedule(schedule: &Schedule) -> Result<()> {
340    let errors = schedule.errors();
341    if errors.is_empty() {
342        return Ok(());
343    }
344
345    let listed: Vec<String> =
346        errors.iter().map(|(name, error)| format!("`{name}`: {error}")).collect();
347
348    Err(Error::internal(format!(
349        "the schedule has {} task(s) with an invalid expression — {}",
350        errors.len(),
351        listed.join("; ")
352    )))
353}
354
355/// How long until the top of the next minute.
356fn until_next_minute() -> Duration {
357    let now = Utc::now();
358    let seconds = 60 - (now.timestamp() % 60);
359
360    // At least a second, so a pass that finishes instantly cannot spin through
361    // the same minute twice.
362    Duration::from_secs(seconds.max(1) as u64)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use rainier_cache::MemoryCache;
369    use rainier_console::Console;
370
371    fn app_with(schedule: Schedule) -> Application {
372        let app = Application::new(".");
373        app.instance(schedule);
374        app.instance(LockManager::new(Arc::new(MemoryCache::new())));
375        app
376    }
377
378    #[tokio::test]
379    async fn schedule_run_reports_what_it_did() {
380        let mut schedule = Schedule::new();
381        schedule.call("tick", |_| Box::pin(async { Ok(()) }));
382
383        let app = app_with(schedule);
384        let console = Console::new("rainier").register(ScheduleRunCommand);
385
386        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::SUCCESS);
387    }
388
389    #[tokio::test]
390    async fn schedule_run_exits_non_zero_when_a_task_fails() {
391        // So a supervisor, or the `&&` in a deploy script, notices.
392        let mut schedule = Schedule::new();
393        schedule.call("broken", |_| Box::pin(async { Err(Error::internal("boom")) }));
394
395        let app = app_with(schedule);
396        let console = Console::new("rainier").register(ScheduleRunCommand);
397
398        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::FAILURE);
399    }
400
401    #[tokio::test]
402    async fn a_schedule_with_an_unparsable_expression_refuses_to_run() {
403        // The builders are lenient mid-chain; this is where that is paid back.
404        let mut schedule = Schedule::new();
405        schedule.call("wrong", |_| Box::pin(async { Ok(()) })).cron("not a cron expression");
406
407        let app = app_with(schedule);
408        let console = Console::new("rainier").register(ScheduleRunCommand);
409
410        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::FAILURE);
411    }
412
413    #[tokio::test]
414    async fn schedule_list_names_a_duplicate() {
415        let mut schedule = Schedule::new();
416        schedule.call("prune", |_| Box::pin(async { Ok(()) })).hourly();
417        schedule.call("prune", |_| Box::pin(async { Ok(()) })).daily();
418
419        let app = app_with(schedule);
420        let console = Console::new("rainier").register(ScheduleListCommand);
421
422        // It prints rather than fails — a duplicate is a warning, not a reason
423        // to stop the whole schedule.
424        assert_eq!(console.run_argv(&app, ["schedule:list"]).await, exit::SUCCESS);
425        assert_eq!(app.resolve::<Schedule>().unwrap().duplicate_names(), vec!["prune"]);
426    }
427
428    #[tokio::test]
429    async fn schedule_work_once_runs_a_single_pass() {
430        let mut schedule = Schedule::new();
431        schedule.call("tick", |_| Box::pin(async { Ok(()) }));
432
433        let app = app_with(schedule);
434        let console = Console::new("rainier").register(ScheduleWorkCommand);
435
436        assert_eq!(console.run_argv(&app, ["schedule:work", "--once"]).await, exit::SUCCESS);
437    }
438
439    #[test]
440    fn the_sleep_lands_on_the_next_minute_rather_than_drifting() {
441        // Sleeping a fixed sixty seconds drifts by however long each pass took,
442        // and after enough hours a task scheduled for `:00` runs at `:01`.
443        let wait = until_next_minute();
444
445        assert!(wait.as_secs() >= 1 && wait.as_secs() <= 60, "{wait:?}");
446    }
447
448    #[test]
449    fn a_broken_schedule_is_described_rather_than_just_refused() {
450        let mut schedule = Schedule::new();
451        schedule.call("wrong", |_| Box::pin(async { Ok(()) })).cron("* * *");
452
453        let err = refuse_a_broken_schedule(&schedule).unwrap_err();
454        assert!(err.message().contains("`wrong`"), "{}", err.message());
455        assert!(err.message().contains("5"), "{}", err.message());
456    }
457}