Skip to main content

rustlavel_queue/
schedule.rs

1//! The scheduler: cron, expressed as a builder.
2//!
3//! ```ignore
4//! let mut schedule = schedule(queue.clone());
5//!
6//! schedule.job(SendDailyReport).daily().at("13:00");
7//! schedule.job(PruneSessions).hourly();
8//! schedule.job(SyncInventory).every_minutes(5).without_overlapping();
9//! schedule.job(BillCustomers).weekly_on(Weekday::Monday, "09:00");
10//! schedule.job(RebuildSearchIndex).cron("*/5 * * * *");
11//!
12//! schedule.run(shutdown).await?;
13//! ```
14//!
15//! Each builder method returns `&mut ScheduledEvent`, the same shape
16//! rustlavel-db's schema builder uses, so the chain reads as one sentence.
17//! A method that is handed something invalid records the complaint on the event
18//! rather than returning a `Result` that would break the chain; the whole set is
19//! checked in one go by [`Scheduler::compile`], which `run` calls first.
20
21use 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
31/// Start building a schedule. The Rust spelling of Laravel's `schedule()`.
32pub fn schedule(queue: Arc<dyn Queue>) -> Scheduler {
33    Scheduler::new(queue)
34}
35
36/// An arbitrary piece of work a schedule can run, for tasks with no job behind
37/// them.
38type Task = Arc<dyn Fn() -> BoxFuture<'static, Result<()>> + Send + Sync>;
39
40#[derive(Clone)]
41enum Action {
42    /// Put a job on the queue. What `.job(...)` builds.
43    Dispatch(QueuedJob),
44    /// Run something in the scheduler's own process.
45    Call(Task),
46}
47
48/// The five fields, kept as text so `at` can rewrite two of them without
49/// re-parsing the rest.
50#[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    /// Every minute — the starting point every builder method narrows.
61    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
79/// One entry in a schedule.
80pub struct ScheduledEvent {
81    name: String,
82    fields: Fields,
83    action: Action,
84    without_overlapping: bool,
85    /// Set while a run of this event is in flight. Shared with the spawned
86    /// task, which clears it on the way out — including on a panic, because the
87    /// guard that clears it does so on drop.
88    running: Arc<AtomicBool>,
89    /// The first thing a builder method disagreed with, reported by `compile`.
90    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    /// What this event is called in logs and in a [`TickReport`].
106    pub fn name(&self) -> &str {
107        &self.name
108    }
109
110    /// The cron expression this event has been narrowed to so far.
111    pub fn expression(&self) -> String {
112        self.fields.expression()
113    }
114
115    /// Set the whole expression at once.
116    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    /// Every minute. The default, spelled out.
137    pub fn every_minute(&mut self) -> &mut Self {
138        self.fields = Fields::every_minute();
139        self
140    }
141
142    /// Every `n` minutes, from the top of the hour.
143    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    /// On the hour, every hour.
154    pub fn hourly(&mut self) -> &mut Self {
155        self.fields.minute = "0".into();
156        self.fields.hour = "*".into();
157        self
158    }
159
160    /// Once a day at midnight. Narrow it with [`ScheduledEvent::at`].
161    pub fn daily(&mut self) -> &mut Self {
162        self.fields.minute = "0".into();
163        self.fields.hour = "0".into();
164        self
165    }
166
167    /// Once a week, on a day and at a time. Times are UTC.
168    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    /// Once a month, on the first, at midnight.
174    pub fn monthly(&mut self) -> &mut Self {
175        self.daily();
176        self.fields.day_of_month = "1".into();
177        self
178    }
179
180    /// The time of day, as `HH:MM`.
181    ///
182    /// Sets the hour and minute fields, so `daily().at("13:00")` means one run a
183    /// day at one in the afternoon, UTC.
184    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    /// Skip a run when the previous one is still going.
202    ///
203    /// A task that takes six minutes on a five-minute schedule would otherwise
204    /// pile up until the process falls over. The lock is a flag in this process
205    /// — good enough for the scheduler, which is meant to be run once, not once
206    /// per node.
207    pub fn without_overlapping(&mut self) -> &mut Self {
208        self.without_overlapping = true;
209        self
210    }
211
212    /// True while a run of this event is in flight.
213    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
225/// An event with its expression parsed, ready to be asked about a moment.
226struct CompiledEvent {
227    name: String,
228    cron: Cron,
229    action: Action,
230    without_overlapping: bool,
231    running: Arc<AtomicBool>,
232}
233
234/// What one tick did.
235#[derive(Debug, Default, Clone, PartialEq, Eq)]
236pub struct TickReport {
237    /// Events that were due and were started.
238    pub started: Vec<String>,
239    /// Events that were due but whose previous run had not finished.
240    pub skipped: Vec<String>,
241}
242
243/// A set of scheduled events, and the loop that runs them.
244pub 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    /// Schedule a job. It is dispatched onto the queue when due, not run here:
255    /// the scheduler's tick must stay short, and a worker is what runs work.
256    ///
257    /// The payload is taken now, when the schedule is defined, so a job that
258    /// needs fresh data at run time should read it in `handle` rather than
259    /// capture it in a field.
260    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    /// Schedule an already-built envelope — a job with no Rust type behind it.
265    pub fn push(&mut self, job: QueuedJob) -> &mut ScheduledEvent {
266        self.add(ScheduledEvent::new(job.name.clone(), Action::Dispatch(job)))
267    }
268
269    /// Schedule a closure to run in this process. For work with no job behind
270    /// it: pruning a cache, touching a heartbeat file.
271    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    /// Parse every expression, reporting all the bad ones at once.
288    ///
289    /// Called by `run` before the first tick, so a typo in a schedule stops the
290    /// process at boot rather than at 3 a.m. on a Sunday.
291    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    /// Check every expression without running anything.
321    pub fn validate(&self) -> Result<()> {
322        self.compile().map(|_| ())
323    }
324
325    /// The events due at a given moment, by name. For tests and `schedule:list`.
326    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    /// When each event next runs after `at`, for `schedule:list`.
337    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    /// Run everything due at `at`, and return without waiting for it.
346    ///
347    /// Each due event is spawned rather than awaited: a scheduler that waited
348    /// would be late for everything behind a slow task, which is the very thing
349    /// `without_overlapping` exists to survive.
350    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            // `compare_exchange` rather than a read-then-write: two ticks can
360            // only overlap if they raced, and a racy check would let both
361            // through exactly when it matters.
362            let claimed = event
363                .running
364                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
365                .is_ok();
366
367            // When overlapping is allowed the flag is only advisory, so losing
368            // the claim is not a reason to skip.
369            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                // Held for the whole run and dropped afterwards, so the flag is
386                // cleared even if the task panics.
387                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    /// Run the schedule until shutdown, waking on each minute boundary.
404    ///
405    /// Aligning to the boundary rather than sleeping a fixed minute is what
406    /// keeps a schedule from drifting: every tick is asked about a whole minute,
407    /// so an expression matches exactly once however long a tick took.
408    pub async fn run(&self, shutdown: Shutdown) -> Result<()> {
409        // Fail loudly at boot rather than silently never running an event.
410        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            // Sleep to just past the next boundary, so the tick above always
428            // sees a fresh minute.
429            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
439/// Clears an event's "running" flag when the run ends, however it ends.
440struct 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        // The dispatch is spawned, so give it a moment to land.
539        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        // The task is still sleeping, so the next minute's run is skipped.
581        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        // Once it finishes, the lock is released and the next run goes ahead.
587        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}