1use crate::cron::{Cron, Weekday};
22use crate::job::{BoxFuture, Job, QueuedJob};
23use crate::queue::Queue;
24use crate::time::{self, unix_now};
25use crate::worker::Shutdown;
26use rustlavel_core::{Error, Result};
27use std::sync::Arc;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::time::Duration;
30
31pub fn schedule(queue: Arc<dyn Queue>) -> Scheduler {
33 Scheduler::new(queue)
34}
35
36type Task = Arc<dyn Fn() -> BoxFuture<'static, Result<()>> + Send + Sync>;
39
40#[derive(Clone)]
41enum Action {
42 Dispatch(QueuedJob),
44 Call(Task),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51struct Fields {
52 minute: String,
53 hour: String,
54 day_of_month: String,
55 month: String,
56 day_of_week: String,
57}
58
59impl Fields {
60 fn every_minute() -> Self {
62 Fields {
63 minute: "*".into(),
64 hour: "*".into(),
65 day_of_month: "*".into(),
66 month: "*".into(),
67 day_of_week: "*".into(),
68 }
69 }
70
71 fn expression(&self) -> String {
72 format!(
73 "{} {} {} {} {}",
74 self.minute, self.hour, self.day_of_month, self.month, self.day_of_week
75 )
76 }
77}
78
79pub struct ScheduledEvent {
81 name: String,
82 fields: Fields,
83 action: Action,
84 without_overlapping: bool,
85 running: Arc<AtomicBool>,
89 error: Option<String>,
91}
92
93impl ScheduledEvent {
94 fn new(name: String, action: Action) -> Self {
95 ScheduledEvent {
96 name,
97 fields: Fields::every_minute(),
98 action,
99 without_overlapping: false,
100 running: Arc::new(AtomicBool::new(false)),
101 error: None,
102 }
103 }
104
105 pub fn name(&self) -> &str {
107 &self.name
108 }
109
110 pub fn expression(&self) -> String {
112 self.fields.expression()
113 }
114
115 pub fn cron(&mut self, expression: &str) -> &mut Self {
117 let parts: Vec<&str> = expression.split_whitespace().collect();
118
119 if parts.len() != 5 {
120 return self.complain(format!(
121 "`{expression}` has {} field(s); a cron expression needs exactly 5",
122 parts.len()
123 ));
124 }
125
126 self.fields = Fields {
127 minute: parts[0].into(),
128 hour: parts[1].into(),
129 day_of_month: parts[2].into(),
130 month: parts[3].into(),
131 day_of_week: parts[4].into(),
132 };
133 self
134 }
135
136 pub fn every_minute(&mut self) -> &mut Self {
138 self.fields = Fields::every_minute();
139 self
140 }
141
142 pub fn every_minutes(&mut self, n: u32) -> &mut Self {
144 if n == 0 || n > 59 {
145 return self.complain(format!(
146 "every_minutes({n}) is not a schedule: use a value between 1 and 59"
147 ));
148 }
149 self.fields.minute = format!("*/{n}");
150 self
151 }
152
153 pub fn hourly(&mut self) -> &mut Self {
155 self.fields.minute = "0".into();
156 self.fields.hour = "*".into();
157 self
158 }
159
160 pub fn daily(&mut self) -> &mut Self {
162 self.fields.minute = "0".into();
163 self.fields.hour = "0".into();
164 self
165 }
166
167 pub fn weekly_on(&mut self, day: Weekday, time: &str) -> &mut Self {
169 self.fields.day_of_week = day.number().to_string();
170 self.at(time)
171 }
172
173 pub fn monthly(&mut self) -> &mut Self {
175 self.daily();
176 self.fields.day_of_month = "1".into();
177 self
178 }
179
180 pub fn at(&mut self, time: &str) -> &mut Self {
185 let Some((hour, minute)) = time.split_once(':') else {
186 return self.complain(format!("`{time}` is not a time. Write it as `HH:MM`."));
187 };
188
189 match (hour.parse::<u32>(), minute.parse::<u32>()) {
190 (Ok(hour), Ok(minute)) if hour < 24 && minute < 60 => {
191 self.fields.hour = hour.to_string();
192 self.fields.minute = minute.to_string();
193 self
194 }
195 _ => self.complain(format!(
196 "`{time}` is not a time. Write it as `HH:MM`, with the hour between 00 and 23."
197 )),
198 }
199 }
200
201 pub fn without_overlapping(&mut self) -> &mut Self {
208 self.without_overlapping = true;
209 self
210 }
211
212 pub fn is_running(&self) -> bool {
214 self.running.load(Ordering::SeqCst)
215 }
216
217 fn complain(&mut self, message: String) -> &mut Self {
218 if self.error.is_none() {
219 self.error = Some(message);
220 }
221 self
222 }
223}
224
225struct CompiledEvent {
227 name: String,
228 cron: Cron,
229 action: Action,
230 without_overlapping: bool,
231 running: Arc<AtomicBool>,
232}
233
234#[derive(Debug, Default, Clone, PartialEq, Eq)]
236pub struct TickReport {
237 pub started: Vec<String>,
239 pub skipped: Vec<String>,
241}
242
243pub struct Scheduler {
245 queue: Arc<dyn Queue>,
246 events: Vec<ScheduledEvent>,
247}
248
249impl Scheduler {
250 pub fn new(queue: Arc<dyn Queue>) -> Self {
251 Scheduler { queue, events: Vec::new() }
252 }
253
254 pub fn job<J: Job>(&mut self, job: J) -> &mut ScheduledEvent {
261 self.add(ScheduledEvent::new(J::NAME.to_string(), Action::Dispatch(job.to_queued())))
262 }
263
264 pub fn push(&mut self, job: QueuedJob) -> &mut ScheduledEvent {
266 self.add(ScheduledEvent::new(job.name.clone(), Action::Dispatch(job)))
267 }
268
269 pub fn call<F>(&mut self, name: impl Into<String>, task: F) -> &mut ScheduledEvent
272 where
273 F: Fn() -> BoxFuture<'static, Result<()>> + Send + Sync + 'static,
274 {
275 self.add(ScheduledEvent::new(name.into(), Action::Call(Arc::new(task))))
276 }
277
278 fn add(&mut self, event: ScheduledEvent) -> &mut ScheduledEvent {
279 self.events.push(event);
280 self.events.last_mut().expect("just pushed")
281 }
282
283 pub fn events(&self) -> &[ScheduledEvent] {
284 &self.events
285 }
286
287 fn compile(&self) -> Result<Vec<CompiledEvent>> {
292 let mut compiled = Vec::with_capacity(self.events.len());
293 let mut problems = Vec::new();
294
295 for event in &self.events {
296 if let Some(error) = &event.error {
297 problems.push(format!("`{}`: {error}", event.name));
298 continue;
299 }
300
301 match Cron::parse(&event.expression()) {
302 Ok(cron) => compiled.push(CompiledEvent {
303 name: event.name.clone(),
304 cron,
305 action: event.action.clone(),
306 without_overlapping: event.without_overlapping,
307 running: Arc::clone(&event.running),
308 }),
309 Err(error) => problems.push(format!("`{}`: {error}", event.name)),
310 }
311 }
312
313 if problems.is_empty() {
314 Ok(compiled)
315 } else {
316 Err(Error::msg(format!("this schedule cannot run:\n {}", problems.join("\n "))))
317 }
318 }
319
320 pub fn validate(&self) -> Result<()> {
322 self.compile().map(|_| ())
323 }
324
325 pub fn due_at(&self, at: i64) -> Result<Vec<String>> {
327 let moment = time::from_unix(at);
328 Ok(self
329 .compile()?
330 .into_iter()
331 .filter(|event| event.cron.matches(&moment))
332 .map(|event| event.name)
333 .collect())
334 }
335
336 pub fn next_runs_after(&self, at: i64) -> Result<Vec<(String, Option<i64>)>> {
338 Ok(self
339 .compile()?
340 .into_iter()
341 .map(|event| (event.name, event.cron.next_after(at)))
342 .collect())
343 }
344
345 pub async fn tick(&self, at: i64) -> Result<TickReport> {
351 let moment = time::from_unix(at);
352 let mut report = TickReport::default();
353
354 for event in self.compile()? {
355 if !event.cron.matches(&moment) {
356 continue;
357 }
358
359 let claimed = event
363 .running
364 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
365 .is_ok();
366
367 if !claimed && event.without_overlapping {
370 rustlavel_core::warn!(
371 "scheduled task `{}` is still running; skipping this run",
372 event.name
373 );
374 report.skipped.push(event.name);
375 continue;
376 }
377
378 report.started.push(event.name.clone());
379 let queue = Arc::clone(&self.queue);
380 let guard = RunningGuard(Arc::clone(&event.running));
381 let name = event.name.clone();
382 let action = event.action.clone();
383
384 tokio::spawn(async move {
385 let _guard = guard;
388
389 let result = match action {
390 Action::Dispatch(job) => queue.push(job).await.map(|_| ()),
391 Action::Call(task) => task().await,
392 };
393
394 if let Err(error) = result {
395 rustlavel_core::error!("scheduled task `{name}` failed: {error}");
396 }
397 });
398 }
399
400 Ok(report)
401 }
402
403 pub async fn run(&self, shutdown: Shutdown) -> Result<()> {
409 self.compile()?;
411
412 let mut last_tick: Option<i64> = None;
413
414 loop {
415 if shutdown.is_signalled() {
416 return Ok(());
417 }
418
419 let now = unix_now();
420 let minute = time::start_of_minute(now);
421
422 if last_tick != Some(minute) {
423 last_tick = Some(minute);
424 self.tick(minute).await?;
425 }
426
427 let next = time::start_of_minute(unix_now()) + 60;
430 let wait = Duration::from_millis(((next - unix_now()).max(0) as u64) * 1000 + 50);
431
432 if shutdown.wait_for(wait).await {
433 return Ok(());
434 }
435 }
436 }
437}
438
439struct RunningGuard(Arc<AtomicBool>);
441
442impl Drop for RunningGuard {
443 fn drop(&mut self) {
444 self.0.store(false, Ordering::SeqCst);
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::memory::MemoryQueue;
452 use crate::tests_support::{CountingJob, counter};
453 use crate::time::to_unix;
454
455 fn scheduler() -> (Arc<MemoryQueue>, Scheduler) {
456 let queue = Arc::new(MemoryQueue::new());
457 let scheduler = Scheduler::new(queue.clone());
458 (queue, scheduler)
459 }
460
461 #[test]
462 fn the_builder_methods_produce_the_expressions_they_promise() {
463 let (_queue, mut schedule) = scheduler();
464 let tally = counter();
465
466 assert_eq!(schedule.job(CountingJob::new(tally, 1)).every_minute().expression(), "* * * * *");
467 assert_eq!(schedule.job(CountingJob::new(tally, 1)).hourly().expression(), "0 * * * *");
468 assert_eq!(schedule.job(CountingJob::new(tally, 1)).daily().expression(), "0 0 * * *");
469 assert_eq!(
470 schedule.job(CountingJob::new(tally, 1)).daily().at("13:00").expression(),
471 "0 13 * * *"
472 );
473 assert_eq!(
474 schedule.job(CountingJob::new(tally, 1)).every_minutes(5).expression(),
475 "*/5 * * * *"
476 );
477 assert_eq!(
478 schedule
479 .job(CountingJob::new(tally, 1))
480 .weekly_on(Weekday::Monday, "09:30")
481 .expression(),
482 "30 9 * * 1"
483 );
484 assert_eq!(schedule.job(CountingJob::new(tally, 1)).monthly().expression(), "0 0 1 * *");
485 assert_eq!(
486 schedule.job(CountingJob::new(tally, 1)).cron("*/5 * * * *").expression(),
487 "*/5 * * * *"
488 );
489
490 schedule.validate().unwrap();
491 }
492
493 #[test]
494 fn a_bad_time_is_reported_when_the_schedule_is_checked_not_when_it_is_written() {
495 let (_queue, mut schedule) = scheduler();
496 schedule.job(CountingJob::new(counter(), 1)).daily().at("25:00");
497
498 let error = schedule.validate().unwrap_err().to_string();
499 assert!(error.contains("`counting`"), "{error}");
500 assert!(error.contains("`25:00` is not a time"), "{error}");
501 }
502
503 #[test]
504 fn every_bad_event_is_reported_at_once() {
505 let (_queue, mut schedule) = scheduler();
506 schedule.call("first", || Box::pin(async { Ok(()) })).at("nonsense");
507 schedule.call("second", || Box::pin(async { Ok(()) })).cron("not a cron expression");
508 schedule.call("third", || Box::pin(async { Ok(()) })).every_minutes(0);
509
510 let error = schedule.validate().unwrap_err().to_string();
511 assert!(error.contains("`first`"), "{error}");
512 assert!(error.contains("`second`"), "{error}");
513 assert!(error.contains("`third`"), "{error}");
514 }
515
516 #[test]
517 fn a_schedule_reports_what_is_due_and_what_comes_next() {
518 let (_queue, mut schedule) = scheduler();
519 schedule.job(CountingJob::new(counter(), 1)).daily().at("13:00");
520
521 let one_pm = to_unix(2026, 8, 29, 13, 0, 0);
522 assert_eq!(schedule.due_at(one_pm).unwrap(), vec!["counting".to_string()]);
523 assert!(schedule.due_at(one_pm + 60).unwrap().is_empty());
524
525 let next = schedule.next_runs_after(to_unix(2026, 8, 29, 14, 0, 0)).unwrap();
526 assert_eq!(next[0].1, Some(to_unix(2026, 8, 30, 13, 0, 0)));
527 }
528
529 #[tokio::test]
530 async fn a_due_job_is_dispatched_onto_the_queue_rather_than_run() {
531 let (queue, mut schedule) = scheduler();
532 let tally = counter();
533 schedule.job(CountingJob::new(tally, 1)).daily().at("13:00");
534
535 let report = schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
536 assert_eq!(report.started, vec!["counting".to_string()]);
537
538 for _ in 0..100 {
540 if queue.size("default").await.unwrap() == 1 {
541 break;
542 }
543 tokio::time::sleep(Duration::from_millis(5)).await;
544 }
545
546 assert_eq!(queue.size("default").await.unwrap(), 1);
547 assert_eq!(tally.runs(), 0, "the scheduler queues work, it does not do it");
548 }
549
550 #[tokio::test]
551 async fn a_job_that_is_not_due_is_left_alone() {
552 let (queue, mut schedule) = scheduler();
553 schedule.job(CountingJob::new(counter(), 1)).daily().at("13:00");
554
555 let report = schedule.tick(to_unix(2026, 8, 29, 9, 0, 0)).await.unwrap();
556
557 assert!(report.started.is_empty());
558 assert_eq!(queue.size("default").await.unwrap(), 0);
559 }
560
561 #[tokio::test]
562 async fn without_overlapping_skips_a_run_while_the_last_one_is_still_going() {
563 let (_queue, mut schedule) = scheduler();
564 let tally = counter();
565
566 schedule
567 .call("slow-task", move || {
568 Box::pin(async move {
569 tokio::time::sleep(Duration::from_millis(200)).await;
570 tally.record(1);
571 Ok(())
572 })
573 })
574 .every_minute()
575 .without_overlapping();
576
577 let first = schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
578 assert_eq!(first.started, vec!["slow-task".to_string()]);
579
580 tokio::time::sleep(Duration::from_millis(20)).await;
582 let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
583 assert!(second.started.is_empty());
584 assert_eq!(second.skipped, vec!["slow-task".to_string()]);
585
586 tokio::time::sleep(Duration::from_millis(300)).await;
588 assert_eq!(tally.runs(), 1);
589
590 let third = schedule.tick(to_unix(2026, 8, 29, 13, 2, 0)).await.unwrap();
591 assert_eq!(third.started, vec!["slow-task".to_string()]);
592 }
593
594 #[tokio::test]
595 async fn without_it_a_slow_task_is_allowed_to_overlap() {
596 let (_queue, mut schedule) = scheduler();
597 let tally = counter();
598
599 schedule.call("slow-task", move || {
600 Box::pin(async move {
601 tokio::time::sleep(Duration::from_millis(150)).await;
602 tally.record(1);
603 Ok(())
604 })
605 });
606
607 schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
608 tokio::time::sleep(Duration::from_millis(20)).await;
609 let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
610
611 assert_eq!(second.started, vec!["slow-task".to_string()], "the second run started too");
612
613 tokio::time::sleep(Duration::from_millis(300)).await;
614 assert_eq!(tally.runs(), 2, "both runs completed");
615 }
616
617 #[tokio::test]
618 async fn a_panicking_task_releases_the_overlap_lock() {
619 let (_queue, mut schedule) = scheduler();
620
621 schedule
622 .call("panicking-task", || {
623 Box::pin(async {
624 tokio::task::yield_now().await;
625 panic!("scheduled work can panic too");
626 })
627 })
628 .every_minute()
629 .without_overlapping();
630
631 schedule.tick(to_unix(2026, 8, 29, 13, 0, 0)).await.unwrap();
632 tokio::time::sleep(Duration::from_millis(100)).await;
633
634 let second = schedule.tick(to_unix(2026, 8, 29, 13, 1, 0)).await.unwrap();
635 assert_eq!(
636 second.started,
637 vec!["panicking-task".to_string()],
638 "a task that blew up must not hold the lock forever"
639 );
640 }
641
642 #[tokio::test]
643 async fn run_refuses_to_start_with_a_broken_expression() {
644 let (_queue, mut schedule) = scheduler();
645 schedule.job(CountingJob::new(counter(), 1)).cron("* * *");
646
647 let shutdown = Shutdown::new();
648 shutdown.signal();
649
650 assert!(schedule.run(shutdown).await.is_err());
651 }
652
653 #[tokio::test]
654 async fn run_stops_on_shutdown() {
655 let (_queue, mut schedule) = scheduler();
656 schedule.job(CountingJob::new(counter(), 1)).daily().at("03:00");
657
658 let shutdown = Shutdown::new();
659 let signal = shutdown.clone();
660 tokio::spawn(async move {
661 tokio::time::sleep(Duration::from_millis(20)).await;
662 signal.signal();
663 });
664
665 let started = std::time::Instant::now();
666 schedule.run(shutdown).await.unwrap();
667
668 assert!(started.elapsed() < Duration::from_secs(30), "it should not wait out a full minute");
669 }
670
671 #[test]
672 fn an_event_knows_its_own_name() {
673 let (_queue, mut schedule) = scheduler();
674 schedule.job(CountingJob::new(counter(), 1)).daily();
675
676 assert_eq!(schedule.events()[0].name(), "counting");
677 assert!(!schedule.events()[0].is_running());
678 }
679}