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, io, 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        assert_locks_are_shared(app)?;
180
181        let summary = schedule.run_due(app, &locks, Utc::now()).await;
182
183        if summary.considered() == 0 {
184            println!("Nothing is due.");
185            return Ok(exit::SUCCESS);
186        }
187
188        for name in &summary.ran {
189            println!("Ran: {name}");
190        }
191        for (name, why) in &summary.skipped {
192            println!("Skipped: {name} — {why}");
193        }
194        for (name, error) in &summary.failed {
195            eprintln!("Failed: {name} — {error}");
196        }
197
198        Ok(if summary.has_failures() { exit::FAILURE } else { exit::SUCCESS })
199    }
200}
201
202/// `schedule:work` — the same thing in a loop, for a container.
203///
204/// For a deployment with no cron: one process that wakes at the top of each
205/// minute. Equivalent to `schedule:run` on a timer, and easier to supervise.
206#[derive(Debug, Default)]
207pub struct ScheduleWorkCommand;
208
209#[async_trait::async_trait]
210impl Command for ScheduleWorkCommand {
211    fn name(&self) -> &str {
212        "schedule:work"
213    }
214
215    fn description(&self) -> &str {
216        "Run the scheduler in the foreground, once a minute"
217    }
218
219    fn help(&self) -> Option<&str> {
220        Some(
221            "Usage:\n  schedule:work [--once]\n\n\
222             Options:\n  --once  Run the due tasks once and exit\n\n\
223             For a deployment with no cron — a container, a systemd unit. One\n\
224             process, supervised like any other.",
225        )
226    }
227
228    async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
229        let schedule = app.resolve::<Schedule>()?;
230        let locks = app.resolve::<LockManager>()?;
231
232        refuse_a_broken_schedule(&schedule)?;
233        assert_locks_are_shared(app)?;
234
235        if args.flag("once") {
236            let summary = schedule.run_due(app, &locks, Utc::now()).await;
237            return Ok(if summary.has_failures() { exit::FAILURE } else { exit::SUCCESS });
238        }
239
240        println!("Scheduler running — {} task(s). Press Ctrl-C to stop.", schedule.len());
241
242        loop {
243            // Sleep to the top of the next minute rather than for sixty
244            // seconds. Sleeping for a fixed interval drifts: each pass takes a
245            // little longer than it slept, and after enough hours a task
246            // scheduled for `:00` is running at `:01` and missing its minute
247            // entirely.
248            tokio::time::sleep(until_next_minute()).await;
249
250            let summary = schedule.run_due(app, &locks, Utc::now()).await;
251            if summary.considered() > 0 {
252                tracing::info!(
253                    ran = summary.ran.len(),
254                    skipped = summary.skipped.len(),
255                    failed = summary.failed.len(),
256                    "scheduler pass"
257                );
258            }
259        }
260    }
261}
262
263/// `schedule:list` — what is scheduled, and when it next runs.
264#[derive(Debug, Default)]
265pub struct ScheduleListCommand;
266
267#[async_trait::async_trait]
268impl Command for ScheduleListCommand {
269    fn name(&self) -> &str {
270        "schedule:list"
271    }
272
273    fn description(&self) -> &str {
274        "List every scheduled task and when it next runs"
275    }
276
277    async fn handle(&self, _args: &Arguments, app: &Application) -> Result<i32> {
278        let schedule = app.resolve::<Schedule>()?;
279
280        if schedule.is_empty() {
281            println!("Nothing is scheduled.");
282            return Ok(exit::SUCCESS);
283        }
284
285        let now = Utc::now();
286        let rows: Vec<Vec<String>> = schedule
287            .tasks()
288            .iter()
289            .map(|task| {
290                let next = task
291                    .next_run(now)
292                    .map(|at| at.format("%Y-%m-%d %H:%M:%S").to_string())
293                    .unwrap_or_else(|| "never".to_string());
294
295                let mut guards = Vec::new();
296                if task.overlap_ttl().is_some() {
297                    guards.push("without-overlapping");
298                }
299                if task.is_one_server() {
300                    guards.push("one-server");
301                }
302
303                vec![
304                    task.description(),
305                    task.expression().source().to_string(),
306                    next,
307                    guards.join(", "),
308                ]
309            })
310            .collect();
311
312        io::table(&["TASK", "SCHEDULE", "NEXT RUN (UTC)", "GUARDS"], &rows);
313
314        // Both of these make a lock silently useless, so they are worth
315        // printing even when nobody asked.
316        for (name, error) in schedule.errors() {
317            eprintln!("\nInvalid schedule for `{name}`: {error}");
318        }
319        for name in schedule.duplicate_names() {
320            eprintln!(
321                "\nTwo tasks are both called `{name}`. They share a lock, so \
322                 `without_overlapping` between them makes each block the other."
323            );
324        }
325
326        println!("\n{} task(s)", schedule.len());
327        Ok(exit::SUCCESS)
328    }
329}
330
331/// Fail rather than run a schedule with a task on the wrong expression.
332///
333/// The builders are lenient so a chain does not need a `?` in the middle of it;
334/// this is where that leniency is paid back. A task quietly on `* * * * *`
335/// because `.daily_at("3pm")` did not parse is worse than a scheduler that
336/// refuses to start.
337fn refuse_a_broken_schedule(schedule: &Schedule) -> Result<()> {
338    let errors = schedule.errors();
339    if errors.is_empty() {
340        return Ok(());
341    }
342
343    let listed: Vec<String> =
344        errors.iter().map(|(name, error)| format!("`{name}`: {error}")).collect();
345
346    Err(Error::internal(format!(
347        "the schedule has {} task(s) with an invalid expression — {}",
348        errors.len(),
349        listed.join("; ")
350    )))
351}
352
353/// Refuse to run a schedule whose locking is decoration.
354///
355/// `without_overlapping` and `on_one_server` are only as shared as the cache
356/// behind them. Over the in-process default, three machines each take their
357/// own lock, each get it, and each run: the report goes out three times, the
358/// backup runs three times, and nothing anywhere says why. The flags read as
359/// if they are doing something, which is worse than not having them.
360///
361/// So `schedule:run` and `schedule:work` refuse in production, and warn
362/// everywhere else. A developer running one process is not wrong to use the
363/// memory cache; a production scheduler believing a guarantee it does not have
364/// is a bug already shipped.
365///
366/// This is checked **here**, in the process that actually runs the schedule,
367/// rather than at boot — a web container refusing to serve HTTP over a
368/// scheduling concern would be a much larger outage than the one being
369/// prevented. Boot still says so out loud: see
370/// [`warn_if_locks_are_not_shared`].
371pub fn assert_locks_are_shared(app: &Application) -> Result<()> {
372    let Some(complaint) = lock_complaint(app)? else {
373        return Ok(());
374    };
375
376    if app.is_production() {
377        return Err(Error::internal(format!(
378            "{complaint} In production this is refused rather than warned about: {ADVICE}."
379        )));
380    }
381
382    tracing::warn!("{complaint} In production this refuses to run. To fix: {ADVICE}.");
383    Ok(())
384}
385
386/// Say so at boot, in any environment, and carry on.
387///
388/// The counterpart to [`assert_locks_are_shared`]: every process gets told,
389/// and only the one whose guarantees are at stake refuses. Silence is not an
390/// option in either — a guard that fails open with no message is not a guard.
391pub fn warn_if_locks_are_not_shared(app: &Application) {
392    match lock_complaint(app) {
393        Ok(Some(complaint)) => {
394            // At `error` in production, because there it is a real one: the
395            // scheduler in this deployment will refuse to run at all.
396            if app.is_production() {
397                tracing::error!(
398                    "{complaint} `schedule:run` will refuse to start. To fix: {ADVICE}."
399                );
400            } else {
401                tracing::warn!("{complaint} To fix: {ADVICE}.");
402            }
403        }
404        Ok(None) => {}
405        // Nothing is bound, so there is no schedule to be wrong about.
406        Err(_) => {}
407    }
408}
409
410/// What to do about it. One sentence, in one place, so the two messages above
411/// cannot drift.
412const ADVICE: &str = "set CACHE_DRIVER to a shared store (redis, redis-cluster, memcached, \
413                      dynamodb) and hand the built store to `Rainier::with_cache`";
414
415/// The complaint, or `None` when there is nothing to complain about.
416fn lock_complaint(app: &Application) -> Result<Option<String>> {
417    let schedule = app.resolve::<Schedule>()?;
418    let locks = app.resolve::<LockManager>()?;
419
420    let needs = schedule.tasks_needing_shared_locks();
421    if needs.is_empty() || locks.is_shared() {
422        return Ok(None);
423    }
424
425    Ok(Some(format!(
426        "{} scheduled task(s) declare `without_overlapping` or `on_one_server`, but the lock is \
427         in-process — every machine will take its own, get it, and run. Tasks: {}.",
428        needs.len(),
429        needs.join(", ")
430    )))
431}
432
433/// How long until the top of the next minute.
434fn until_next_minute() -> Duration {
435    let now = Utc::now();
436    let seconds = 60 - (now.timestamp() % 60);
437
438    // At least a second, so a pass that finishes instantly cannot spin through
439    // the same minute twice.
440    Duration::from_secs(seconds.max(1) as u64)
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use rainier_cache::MemoryCache;
447    use rainier_console::Console;
448
449    fn app_with(schedule: Schedule) -> Application {
450        let app = Application::new(".");
451        app.instance(schedule);
452        app.instance(LockManager::new(Arc::new(MemoryCache::new())));
453        app
454    }
455
456    #[tokio::test]
457    async fn schedule_run_reports_what_it_did() {
458        let mut schedule = Schedule::new();
459        schedule.call("tick", |_| Box::pin(async { Ok(()) }));
460
461        let app = app_with(schedule);
462        let console = Console::new("rainier").register(ScheduleRunCommand);
463
464        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::SUCCESS);
465    }
466
467    #[tokio::test]
468    async fn schedule_run_exits_non_zero_when_a_task_fails() {
469        // So a supervisor, or the `&&` in a deploy script, notices.
470        let mut schedule = Schedule::new();
471        schedule.call("broken", |_| Box::pin(async { Err(Error::internal("boom")) }));
472
473        let app = app_with(schedule);
474        let console = Console::new("rainier").register(ScheduleRunCommand);
475
476        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::FAILURE);
477    }
478
479    #[tokio::test]
480    async fn a_schedule_with_an_unparsable_expression_refuses_to_run() {
481        // The builders are lenient mid-chain; this is where that is paid back.
482        let mut schedule = Schedule::new();
483        schedule.call("wrong", |_| Box::pin(async { Ok(()) })).cron("not a cron expression");
484
485        let app = app_with(schedule);
486        let console = Console::new("rainier").register(ScheduleRunCommand);
487
488        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::FAILURE);
489    }
490
491    #[tokio::test]
492    async fn schedule_list_names_a_duplicate() {
493        let mut schedule = Schedule::new();
494        schedule.call("prune", |_| Box::pin(async { Ok(()) })).hourly();
495        schedule.call("prune", |_| Box::pin(async { Ok(()) })).daily();
496
497        let app = app_with(schedule);
498        let console = Console::new("rainier").register(ScheduleListCommand);
499
500        // It prints rather than fails — a duplicate is a warning, not a reason
501        // to stop the whole schedule.
502        assert_eq!(console.run_argv(&app, ["schedule:list"]).await, exit::SUCCESS);
503        assert_eq!(app.resolve::<Schedule>().unwrap().duplicate_names(), vec!["prune"]);
504    }
505
506    #[tokio::test]
507    async fn schedule_work_once_runs_a_single_pass() {
508        let mut schedule = Schedule::new();
509        schedule.call("tick", |_| Box::pin(async { Ok(()) }));
510
511        let app = app_with(schedule);
512        let console = Console::new("rainier").register(ScheduleWorkCommand);
513
514        assert_eq!(console.run_argv(&app, ["schedule:work", "--once"]).await, exit::SUCCESS);
515    }
516
517    #[test]
518    fn the_sleep_lands_on_the_next_minute_rather_than_drifting() {
519        // Sleeping a fixed sixty seconds drifts by however long each pass took,
520        // and after enough hours a task scheduled for `:00` runs at `:01`.
521        let wait = until_next_minute();
522
523        assert!(wait.as_secs() >= 1 && wait.as_secs() <= 60, "{wait:?}");
524    }
525
526    #[test]
527    fn a_broken_schedule_is_described_rather_than_just_refused() {
528        let mut schedule = Schedule::new();
529        schedule.call("wrong", |_| Box::pin(async { Ok(()) })).cron("* * *");
530
531        let err = refuse_a_broken_schedule(&schedule).unwrap_err();
532        assert!(err.message().contains("`wrong`"), "{}", err.message());
533        assert!(err.message().contains("5"), "{}", err.message());
534    }
535
536    /// A schedule with one task that needs the lock to mean something.
537    fn schedule_needing_shared_locks() -> Schedule {
538        let mut schedule = Schedule::new();
539        schedule.call("digest", |_| Box::pin(async { Ok(()) })).daily().on_one_server();
540        schedule
541    }
542
543    #[test]
544    fn an_unshared_lock_is_refused_in_production() {
545        let app = app_with(schedule_needing_shared_locks());
546        app.set_environment("production");
547
548        let err = assert_locks_are_shared(&app).unwrap_err();
549
550        // Which task, and what to do about it — a message naming neither is a
551        // message nobody can act on.
552        assert!(err.message().contains("digest"), "{}", err.message());
553        assert!(err.message().contains("CACHE_DRIVER"), "{}", err.message());
554    }
555
556    #[tokio::test]
557    async fn schedule_run_refuses_in_production_rather_than_running_everywhere_at_once() {
558        // The command, not the boot: this is the process whose guarantees are
559        // at stake, and the only one that should refuse over them.
560        let mut schedule = Schedule::new();
561        schedule.call("digest", |_| Box::pin(async { Ok(()) })).every_minute().on_one_server();
562
563        let app = app_with(schedule);
564        app.set_environment("production");
565        let console = Console::new("rainier").register(ScheduleRunCommand);
566
567        assert_eq!(console.run_argv(&app, ["schedule:run"]).await, exit::FAILURE);
568    }
569
570    #[test]
571    fn the_boot_warning_never_stops_anything() {
572        // A web container must not refuse to serve HTTP over a scheduling
573        // concern — that would be a larger outage than the one prevented.
574        let app = app_with(schedule_needing_shared_locks());
575
576        for environment in ["production", "staging", "local"] {
577            app.set_environment(environment);
578            warn_if_locks_are_not_shared(&app);
579        }
580    }
581
582    #[test]
583    fn the_boot_warning_survives_an_application_with_no_schedule_bound() {
584        // `warn_if_locks_are_not_shared` runs during every boot, including
585        // ones that never bound a scheduler.
586        warn_if_locks_are_not_shared(&Application::new("."));
587    }
588
589    #[test]
590    fn an_unshared_lock_is_allowed_outside_production() {
591        // Warned about — a developer running one process is not wrong — but
592        // not refused, or no local schedule would ever boot.
593        let app = app_with(schedule_needing_shared_locks());
594        app.set_environment("local");
595
596        assert!(assert_locks_are_shared(&app).is_ok());
597    }
598
599    #[test]
600    fn a_shared_lock_passes_in_production() {
601        let app = Application::new(".");
602        app.instance(schedule_needing_shared_locks());
603        app.instance(LockManager::new(Arc::new(MemoryCache::new())).declared_shared());
604        app.set_environment("production");
605
606        assert!(assert_locks_are_shared(&app).is_ok());
607    }
608
609    #[test]
610    fn a_schedule_that_takes_no_locks_does_not_care() {
611        // No `without_overlapping`, no `on_one_server`, nothing to guarantee.
612        let mut schedule = Schedule::new();
613        schedule.call("heartbeat", |_| Box::pin(async { Ok(()) })).every_minute();
614
615        let app = app_with(schedule);
616        app.set_environment("production");
617
618        assert!(assert_locks_are_shared(&app).is_ok());
619    }
620}