1use 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
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
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#[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 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#[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 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
333fn 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
355fn until_next_minute() -> Duration {
357 let now = Utc::now();
358 let seconds = 60 - (now.timestamp() % 60);
359
360 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 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 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 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 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}