1use 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
29pub struct JobTask<J> {
36 job: Arc<J>,
37}
38
39impl<J: Job + Clone> JobTask<J> {
40 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 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
62pub struct CommandTask {
69 name: String,
70 argv: Vec<String>,
71 console: Arc<Console>,
72}
73
74impl CommandTask {
75 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
105pub trait ScheduleExt {
111 fn job<J: Job + Clone>(&mut self, job: J) -> &mut ScheduledTask;
113
114 fn command(&mut self, console: Arc<Console>, name: impl Into<String>) -> &mut ScheduledTask;
116
117 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#[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#[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 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#[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 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
331fn 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
353pub 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
386pub fn warn_if_locks_are_not_shared(app: &Application) {
392 match lock_complaint(app) {
393 Ok(Some(complaint)) => {
394 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 Err(_) => {}
407 }
408}
409
410const 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
415fn 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
433fn until_next_minute() -> Duration {
435 let now = Utc::now();
436 let seconds = 60 - (now.timestamp() % 60);
437
438 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 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 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 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 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 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 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 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 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(&Application::new("."));
587 }
588
589 #[test]
590 fn an_unshared_lock_is_allowed_outside_production() {
591 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 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}