Skip to main content

renox_core/
schedule.rs

1//! Tasks that run on a schedule, defined in code and run by `serve` (or
2//! `my-app schedule:work`).
3//!
4//! ```
5//! # use renox::prelude::*;
6//! use renox::chrono::Weekday;
7//! # async fn sync(_: &AppState) -> Result { Ok(()) }
8//! # async fn report(_: &AppState) -> Result { Ok(()) }
9//! # let _ =
10//! App::new().schedule(|s| {
11//!     s.every_minutes(5, "sync-stock", |state| async move { sync(&state).await })
12//!         .weekdays()
13//!         .between("08:00", "17:00");
14//!     s.daily_at("02:00", "cleanup", |state| async move {
15//!         renox::db::sql("DELETE FROM carts WHERE updated_at < ?")
16//!             .bind(renox::db::now() - renox::chrono::TimeDelta::days(30))
17//!             .execute(&state.db)
18//!             .await?;
19//!         Ok(())
20//!     });
21//!     s.cron("30 9 * * 1-5", "standup", |state| async move { report(&state).await })
22//!         .timezone("Europe/Amsterdam")
23//!         .on_failure(|err, _state| async move {
24//!             tracing::error!(error = ?err, "standup report failed");
25//!         });
26//!     s.weekly_on(Weekday::Mon, "07:00", "weekly-report", |state| async move {
27//!         report(&state).await
28//!     });
29//!     s.monthly_on(1, "00:05", "invoices", |state| async move { report(&state).await });
30//! })
31//! # ;
32//! ```
33//!
34//! Times are in `APP_TIMEZONE` (an IANA name such as `Asia/Jakarta`, an
35//! offset such as `+07:00`, or UTC), or in a task's own `timezone`. With
36//! daylight saving time, a run in the hour that clocks skip happens right
37//! after the jump, and one in the hour that repeats happens once. A task
38//! whose previous run hasn't finished is skipped rather than run twice.
39//!
40//! Several processes may run the same schedule (e.g. `serve` on two servers
41//! sharing a PostgreSQL database): each run is claimed in the `cache` table
42//! first, so only one of them runs it. `my-app schedule:run NAME` runs one
43//! task now, `schedule:list` shows when each runs next.
44
45use std::future::Future;
46use std::ops::{Deref, DerefMut};
47use std::pin::Pin;
48use std::sync::Arc;
49use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
50use std::time::Duration;
51
52use anyhow::{Context, anyhow, bail};
53use chrono::{Datelike, NaiveDateTime, TimeDelta, Timelike, Weekday};
54use tokio::sync::watch;
55
56use crate::queue::unix_now;
57use crate::timezone::Zone;
58use crate::{AppState, Error, Result};
59
60type TaskFn = Arc<dyn Fn(AppState) -> Pin<Box<dyn Future<Output = Result> + Send>> + Send + Sync>;
61type FailFn =
62    Arc<dyn Fn(AppState, Error) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
63type DoneFn = Arc<dyn Fn(AppState) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
64
65const PING_BEFORE: usize = 0;
66const PING_AFTER: usize = 1;
67const PING_SUCCESS: usize = 2;
68const PING_FAILURE: usize = 3;
69
70/// When a run is "never" (an impossible schedule, or filters that exclude
71/// every run).
72const NEVER: i64 = i64::MAX;
73
74#[derive(Debug, Clone, PartialEq)]
75enum When {
76    /// Every `n` seconds, on multiples of `n` since midnight in the zone.
77    Every(i64),
78    /// When the wall clock matches a cron expression.
79    Cron(Box<Cron>),
80}
81
82impl When {
83    /// How long a run's claim is kept: past the slot, so a process whose
84    /// clock is a little behind doesn't run it again, and no longer.
85    fn claim_for(&self) -> i64 {
86        match self {
87            When::Every(seconds) => seconds.max(&60) + 60,
88            When::Cron(_) => 60 * 60,
89        }
90    }
91}
92
93/// Clears a task's `running` flag when dropped.
94struct Running(Arc<AtomicBool>);
95
96impl Drop for Running {
97    fn drop(&mut self) {
98        self.0.store(false, Ordering::SeqCst);
99    }
100}
101
102#[derive(Clone)]
103struct Task {
104    name: String,
105    when: When,
106    run: TaskFn,
107    running: Arc<AtomicBool>,
108    zone: Option<Zone>,
109    /// Weekdays it may run on, a bit per `Weekday::num_days_from_monday`.
110    days: u8,
111    /// Minutes of the day it may run between (inclusive; may wrap midnight).
112    between: Option<(u32, u32)>,
113    on_failure: Option<FailFn>,
114    on_success: Option<DoneFn>,
115    /// URLs to GET: before a run, after it, after a success, after a failure.
116    pings: [Vec<String>; 4],
117}
118
119impl Task {
120    fn allows(&self, local: NaiveDateTime) -> bool {
121        if self.days & (1 << local.weekday().num_days_from_monday()) == 0 {
122            return false;
123        }
124        match self.between {
125            None => true,
126            Some((from, to)) => {
127                let minute = local.hour() * 60 + local.minute();
128                if from <= to {
129                    (from..=to).contains(&minute)
130                } else {
131                    minute >= from || minute <= to
132                }
133            }
134        }
135    }
136
137    /// The first run after `now`, in unix seconds.
138    fn next_run(&self, now: i64, app_zone: Zone) -> i64 {
139        if self.days == 0 {
140            return NEVER;
141        }
142        let zone = self.zone.unwrap_or(app_zone);
143        let mut after = now;
144        // Filters skip runs one by one: a minute's interval over a weekend is
145        // ~2,900 steps; give up (never) past a few years of them.
146        for _ in 0..2_000_000 {
147            let at = match &self.when {
148                When::Every(n) => {
149                    let offset = zone.offset_at(after);
150                    ((after + offset).div_euclid(*n) + 1) * n - offset
151                }
152                When::Cron(cron) => match cron_next(cron, after, zone) {
153                    Some(at) => at,
154                    None => return NEVER,
155                },
156            };
157            if self.allows(zone.local(at)) {
158                return at;
159            }
160            after = at;
161        }
162        NEVER
163    }
164
165    /// GETs the `which` ping URLs (health checks); failures are logged.
166    async fn ping(&self, state: &AppState, which: usize) {
167        for url in &self.pings[which] {
168            let sent = state
169                .http
170                .get(url)
171                .timeout(Duration::from_secs(10))
172                .retry(1, Duration::from_secs(1))
173                .send()
174                .await;
175            match sent {
176                Ok(res) if res.ok() => {}
177                Ok(res) => {
178                    tracing::warn!(task = %self.name, url, status = %res.status(), "schedule ping answered with an error")
179                }
180                Err(err) => {
181                    tracing::warn!(task = %self.name, url, error = ?err, "schedule ping failed")
182                }
183            }
184        }
185    }
186
187    /// Runs the task once, with its hooks and pings, in a fresh context.
188    async fn execute(&self, state: AppState) -> Result {
189        self.ping(&state, PING_BEFORE).await;
190        let outcome = self.execute_inner(state.clone()).await;
191        self.ping(&state, PING_AFTER).await;
192        let which = if outcome.is_ok() {
193            PING_SUCCESS
194        } else {
195            PING_FAILURE
196        };
197        self.ping(&state, which).await;
198        outcome
199    }
200
201    async fn execute_inner(&self, state: AppState) -> Result {
202        let run = crate::context::scope_app(state.clone(), (self.run)(state.clone()));
203        let run = std::panic::AssertUnwindSafe(run);
204        let outcome = match futures_util::FutureExt::catch_unwind(run).await {
205            Ok(outcome) => outcome,
206            Err(_) => Err(anyhow!("scheduled task `{}` panicked", self.name).into()),
207        };
208        match outcome {
209            Ok(()) => {
210                if let Some(done) = &self.on_success {
211                    crate::context::scope_app(state.clone(), done(state)).await;
212                }
213                Ok(())
214            }
215            Err(err) => {
216                tracing::error!(task = %self.name, error = ?err, "scheduled task failed");
217                let report = crate::report::ErrorReport::new(
218                    &state,
219                    crate::report::ReportKind::ScheduledTask,
220                    format!("{err:?}")
221                        .lines()
222                        .next()
223                        .unwrap_or_default()
224                        .to_owned(),
225                    format!("{err:?}"),
226                    Some(self.name.clone()),
227                );
228                crate::report::send(&state, report);
229                if let Some(failed) = &self.on_failure {
230                    let message = format!("{err:?}");
231                    crate::context::scope_app(state.clone(), failed(state, err)).await;
232                    return Err(anyhow!(message).into());
233                }
234                Err(err)
235            }
236        }
237    }
238}
239
240/// The app's scheduled tasks.
241#[derive(Clone, Default)]
242pub struct Schedule {
243    tasks: Vec<Task>,
244    error: Option<String>,
245}
246
247/// A task just added to the [`Schedule`], to narrow when it runs or add
248/// hooks. It derefs to the schedule, so adding tasks can go on in a chain.
249pub struct ScheduledTask<'a> {
250    schedule: &'a mut Schedule,
251    /// `None` when the task couldn't be added (the error is kept).
252    index: Option<usize>,
253}
254
255impl Deref for ScheduledTask<'_> {
256    type Target = Schedule;
257
258    fn deref(&self) -> &Schedule {
259        self.schedule
260    }
261}
262
263impl DerefMut for ScheduledTask<'_> {
264    fn deref_mut(&mut self) -> &mut Schedule {
265        self.schedule
266    }
267}
268
269impl ScheduledTask<'_> {
270    fn edit(self, change: impl FnOnce(&mut Task) -> anyhow::Result<()>) -> Self {
271        if let Some(index) = self.index {
272            let task = &mut self.schedule.tasks[index];
273            if let Err(err) = change(task) {
274                let name = task.name.clone();
275                self.schedule
276                    .error
277                    .get_or_insert(format!("task `{name}`: {err}"));
278            }
279        }
280        self
281    }
282
283    /// Only Monday to Friday.
284    pub fn weekdays(self) -> Self {
285        self.edit(|t| {
286            t.days &= 0b0001_1111;
287            Ok(())
288        })
289    }
290
291    /// Only Saturday and Sunday.
292    pub fn weekends(self) -> Self {
293        self.edit(|t| {
294            t.days &= 0b0110_0000;
295            Ok(())
296        })
297    }
298
299    /// Only on these days.
300    pub fn days(self, days: &[Weekday]) -> Self {
301        let mask = days
302            .iter()
303            .fold(0u8, |mask, day| mask | 1 << day.num_days_from_monday());
304        self.edit(|t| {
305            t.days &= mask;
306            Ok(())
307        })
308    }
309
310    /// Only between two times of day (`HH:MM`, both included); `"22:00"` to
311    /// `"06:00"` spans midnight.
312    pub fn between(self, from: &str, to: &str) -> Self {
313        let window = parse_time(from).and_then(|from| Ok((from, parse_time(to)?)));
314        self.edit(|t| {
315            let (from, to) = window?;
316            t.between = Some(((from / 60) as u32, (to / 60) as u32));
317            Ok(())
318        })
319    }
320
321    /// Runs in this time zone instead of `APP_TIMEZONE` (`Asia/Jakarta`,
322    /// `+07:00`, `UTC`).
323    pub fn timezone(self, zone: &str) -> Self {
324        let zone = zone.parse::<Zone>();
325        self.edit(|t| {
326            t.zone = Some(zone?);
327            Ok(())
328        })
329    }
330
331    /// Runs after a run fails or panics, with the error (e.g. to alert
332    /// someone). The failure is logged either way.
333    pub fn on_failure<F, Fut>(self, hook: F) -> Self
334    where
335        F: Fn(Error, AppState) -> Fut + Send + Sync + 'static,
336        Fut: Future<Output = ()> + Send + 'static,
337    {
338        let hook: FailFn = Arc::new(move |state, err| Box::pin(hook(err, state)));
339        self.edit(|t| {
340            t.on_failure = Some(hook);
341            Ok(())
342        })
343    }
344
345    /// GETs `url` before each run, e.g. a health check's "start" URL
346    /// (Healthchecks.io, Cronitor, Better Stack). A failing ping is logged,
347    /// never stops the task.
348    pub fn ping_before(self, url: impl Into<String>) -> Self {
349        self.ping(PING_BEFORE, url.into())
350    }
351
352    /// GETs `url` after each run, however it went.
353    pub fn then_ping(self, url: impl Into<String>) -> Self {
354        self.ping(PING_AFTER, url.into())
355    }
356
357    /// GETs `url` after a successful run.
358    pub fn ping_on_success(self, url: impl Into<String>) -> Self {
359        self.ping(PING_SUCCESS, url.into())
360    }
361
362    /// GETs `url` after a failed run.
363    pub fn ping_on_failure(self, url: impl Into<String>) -> Self {
364        self.ping(PING_FAILURE, url.into())
365    }
366
367    fn ping(self, which: usize, url: String) -> Self {
368        self.edit(|t| {
369            t.pings[which].push(url);
370            Ok(())
371        })
372    }
373
374    /// Runs after a run succeeds.
375    pub fn on_success<F, Fut>(self, hook: F) -> Self
376    where
377        F: Fn(AppState) -> Fut + Send + Sync + 'static,
378        Fut: Future<Output = ()> + Send + 'static,
379    {
380        let hook: DoneFn = Arc::new(move |state| Box::pin(hook(state)));
381        self.edit(|t| {
382            t.on_success = Some(hook);
383            Ok(())
384        })
385    }
386}
387
388impl Schedule {
389    fn add<F, Fut>(&mut self, name: &str, when: anyhow::Result<When>, task: F) -> ScheduledTask<'_>
390    where
391        F: Fn(AppState) -> Fut + Send + Sync + 'static,
392        Fut: Future<Output = Result> + Send + 'static,
393    {
394        let when = match when {
395            Ok(when) => when,
396            Err(err) => {
397                self.error.get_or_insert(format!("task `{name}`: {err}"));
398                return ScheduledTask {
399                    schedule: self,
400                    index: None,
401                };
402            }
403        };
404        if self.tasks.iter().any(|t| t.name == name) {
405            self.error
406                .get_or_insert(format!("task `{name}` is scheduled twice"));
407        }
408        self.tasks.push(Task {
409            name: name.to_owned(),
410            when,
411            run: Arc::new(move |state| Box::pin(task(state))),
412            running: Arc::new(AtomicBool::new(false)),
413            zone: None,
414            days: 0b0111_1111,
415            between: None,
416            on_failure: None,
417            on_success: None,
418            pings: Default::default(),
419        });
420        let index = Some(self.tasks.len() - 1);
421        ScheduledTask {
422            schedule: self,
423            index,
424        }
425    }
426
427    /// Every `interval`, aligned to the clock (every 5 minutes runs at :00, :05, ...).
428    pub fn every<F, Fut>(&mut self, interval: Duration, name: &str, task: F) -> ScheduledTask<'_>
429    where
430        F: Fn(AppState) -> Fut + Send + Sync + 'static,
431        Fut: Future<Output = Result> + Send + 'static,
432    {
433        let seconds = interval.as_secs().max(1) as i64;
434        self.add(name, Ok(When::Every(seconds)), task)
435    }
436
437    /// Every minute, on the minute.
438    pub fn every_minute<F, Fut>(&mut self, name: &str, task: F) -> ScheduledTask<'_>
439    where
440        F: Fn(AppState) -> Fut + Send + Sync + 'static,
441        Fut: Future<Output = Result> + Send + 'static,
442    {
443        self.every(Duration::from_secs(60), name, task)
444    }
445
446    /// Every `minutes` minutes, aligned to the clock (every 15 runs at :00, :15, ...).
447    pub fn every_minutes<F, Fut>(&mut self, minutes: u64, name: &str, task: F) -> ScheduledTask<'_>
448    where
449        F: Fn(AppState) -> Fut + Send + Sync + 'static,
450        Fut: Future<Output = Result> + Send + 'static,
451    {
452        self.every(Duration::from_secs(minutes * 60), name, task)
453    }
454
455    /// Every hour, on the hour.
456    pub fn hourly<F, Fut>(&mut self, name: &str, task: F) -> ScheduledTask<'_>
457    where
458        F: Fn(AppState) -> Fut + Send + Sync + 'static,
459        Fut: Future<Output = Result> + Send + 'static,
460    {
461        self.every(Duration::from_secs(3600), name, task)
462    }
463
464    /// When the wall clock matches a cron expression: minute, hour, day of
465    /// month, month, day of week (`0`/`7` or `SUN`), with `*`, `a-b`, lists
466    /// and `/step`; also `@hourly`, `@daily`, `@weekly`, `@monthly`,
467    /// `@yearly`. `"30 9 * * 1-5"` is 09:30 on weekdays.
468    pub fn cron<F, Fut>(&mut self, expression: &str, name: &str, task: F) -> ScheduledTask<'_>
469    where
470        F: Fn(AppState) -> Fut + Send + Sync + 'static,
471        Fut: Future<Output = Result> + Send + 'static,
472    {
473        let when = Cron::parse(expression).map(|c| When::Cron(Box::new(c)));
474        self.add(name, when, task)
475    }
476
477    /// Every day at `HH:MM`.
478    pub fn daily_at<F, Fut>(&mut self, time: &str, name: &str, task: F) -> ScheduledTask<'_>
479    where
480        F: Fn(AppState) -> Fut + Send + Sync + 'static,
481        Fut: Future<Output = Result> + Send + 'static,
482    {
483        let when = at_time(time, "*", "*");
484        self.add(name, when, task)
485    }
486
487    /// Every week on `day` at `HH:MM`.
488    pub fn weekly_on<F, Fut>(
489        &mut self,
490        day: Weekday,
491        time: &str,
492        name: &str,
493        task: F,
494    ) -> ScheduledTask<'_>
495    where
496        F: Fn(AppState) -> Fut + Send + Sync + 'static,
497        Fut: Future<Output = Result> + Send + 'static,
498    {
499        let when = at_time(time, "*", &day.num_days_from_sunday().to_string());
500        self.add(name, when, task)
501    }
502
503    /// Every month on `day` (1–31; months without that day are skipped, so
504    /// use 28 or lower for every month) at `HH:MM`.
505    pub fn monthly_on<F, Fut>(
506        &mut self,
507        day: u32,
508        time: &str,
509        name: &str,
510        task: F,
511    ) -> ScheduledTask<'_>
512    where
513        F: Fn(AppState) -> Fut + Send + Sync + 'static,
514        Fut: Future<Output = Result> + Send + 'static,
515    {
516        let when = at_time(time, &day.to_string(), "*");
517        self.add(name, when, task)
518    }
519
520    pub(crate) fn check(&self) -> anyhow::Result<()> {
521        match &self.error {
522            Some(error) => bail!("invalid schedule: {error}"),
523            None => Ok(()),
524        }
525    }
526
527    pub(crate) fn is_empty(&self) -> bool {
528        self.tasks.is_empty()
529    }
530}
531
532/// A scheduled task's next run, from [`Schedule::upcoming`].
533#[derive(Debug, Clone)]
534#[non_exhaustive]
535pub struct UpcomingRun {
536    /// The task's name.
537    pub name: String,
538    /// When it runs next; `None` if it never will (a date that can't come).
539    pub at: Option<crate::db::DateTime>,
540    /// The zone its times are in.
541    pub zone: Zone,
542}
543
544impl Schedule {
545    /// Each task with its next run (in `zone` unless the task has its own),
546    /// as `schedule:list` shows them.
547    pub fn upcoming(&self, zone: Zone) -> Vec<UpcomingRun> {
548        let now = unix_now();
549        self.tasks
550            .iter()
551            .map(|t| {
552                let at = t.next_run(now, zone);
553                UpcomingRun {
554                    name: t.name.clone(),
555                    at: (at != i64::MAX).then(|| crate::db::from_unix(at)),
556                    zone: t.zone.unwrap_or(zone),
557                }
558            })
559            .collect()
560    }
561
562    /// Runs the task `name` now, whatever its schedule (`schedule:run`).
563    pub async fn run_now(&self, state: AppState, name: &str) -> Result {
564        let task = self
565            .tasks
566            .iter()
567            .find(|t| t.name == name)
568            .ok_or_else(|| Error::from(anyhow!("no scheduled task `{name}`")))?;
569        task.execute(state).await
570    }
571
572    /// Runs tasks as they fall due until `shutdown` flips to true.
573    pub(crate) async fn run(
574        self,
575        state: AppState,
576        zone: Zone,
577        mut shutdown: watch::Receiver<bool>,
578    ) {
579        let mut next: Vec<i64> = self
580            .tasks
581            .iter()
582            .map(|t| t.next_run(unix_now(), zone))
583            .collect();
584        loop {
585            let Some(soonest) = next.iter().min().copied() else {
586                return;
587            };
588            // A task that never runs still wakes the loop once a day, harmlessly.
589            let wait = (soonest.min(unix_now() + 86_400) - unix_now()).max(0);
590            tokio::select! {
591                _ = tokio::time::sleep(Duration::from_secs(wait as u64)) => {}
592                _ = shutdown.changed() => return,
593            }
594            let now = unix_now();
595            for (task, at) in self.tasks.iter().zip(next.iter_mut()) {
596                if *at > now {
597                    continue;
598                }
599                let slot = *at;
600                *at = task.next_run(now, zone);
601                if task.running.swap(true, Ordering::SeqCst) {
602                    tracing::warn!(task = %task.name, "skipped: the previous run is still going");
603                    continue;
604                }
605                let (task, state) = (task.clone(), state.clone());
606                tokio::spawn(crate::clock::carry(async move {
607                    // Clears `running` however the run ends, panics included.
608                    let _running = Running(task.running.clone());
609                    if !claim(&state, &task.name, slot, task.when.claim_for()).await {
610                        tracing::debug!(task = %task.name, "skipped: another process runs it");
611                        return;
612                    }
613                    tracing::info!(task = %task.name, "scheduled task started");
614                    let _ = task.execute(state).await;
615                }));
616            }
617        }
618    }
619}
620
621/// Claims the run of `task` due at `slot`, so that processes sharing the
622/// database run it once: the first to insert the claim wins. The claim is
623/// kept `keep` seconds. If the database can't be reached the task runs
624/// anyway, as a single process would.
625#[doc(hidden)]
626pub async fn claim(state: &AppState, task: &str, slot: i64, keep: i64) -> bool {
627    let now = unix_now();
628    prune_claims(state, now).await;
629    let claimed = crate::db::sql(
630        "INSERT INTO cache (key, value, expires_at) VALUES (?, 'null', ?) \
631         ON CONFLICT (key) DO NOTHING",
632    )
633    .bind(format!("{CLAIM_PREFIX}{task}:{slot}"))
634    .bind(now + keep)
635    .execute(&state.db)
636    .await;
637    match claimed {
638        Ok(rows) => rows == 1,
639        Err(err) => {
640            tracing::warn!(task, error = %err, "could not claim the run; running it anyway");
641            true
642        }
643    }
644}
645
646/// Framework rows in the `cache` table start with `renox:`; `Cache::flush` keeps them.
647const CLAIM_PREFIX: &str = "renox:schedule:";
648
649/// Deletes expired claims, at most once a minute per process.
650async fn prune_claims(state: &AppState, now: i64) {
651    static LAST: AtomicI64 = AtomicI64::new(0);
652    let last = LAST.load(Ordering::Relaxed);
653    if now - last < 60
654        || LAST
655            .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
656            .is_err()
657    {
658        return;
659    }
660    delete_expired_claims(state, now).await;
661}
662
663/// Deletes the claims expired at `now`; a failure is logged.
664async fn delete_expired_claims(state: &AppState, now: i64) {
665    let pruned = crate::db::sql("DELETE FROM cache WHERE key LIKE ? AND expires_at < ?")
666        .bind(format!("{CLAIM_PREFIX}%"))
667        .bind(now)
668        .execute(&state.db)
669        .await;
670    if let Err(err) = pruned {
671        tracing::warn!(error = %err, "could not clear old schedule claims");
672    }
673}
674
675fn parse_time(time: &str) -> anyhow::Result<i64> {
676    let (hour, minute) = time.split_once(':').context("expected HH:MM")?;
677    let (hour, minute): (i64, i64) = (hour.trim().parse()?, minute.trim().parse()?);
678    if !(0..24).contains(&hour) || !(0..60).contains(&minute) {
679        bail!("`{time}` is not a time of day");
680    }
681    Ok(hour * 3600 + minute * 60)
682}
683
684/// A cron schedule at `HH:MM` on these days of the month and week.
685fn at_time(time: &str, day_of_month: &str, day_of_week: &str) -> anyhow::Result<When> {
686    let seconds = parse_time(time)?;
687    let expression = format!(
688        "{} {} {day_of_month} * {day_of_week}",
689        seconds % 3600 / 60,
690        seconds / 3600
691    );
692    Ok(When::Cron(Box::new(Cron::parse(&expression)?)))
693}
694
695/// A parsed cron expression: a bit per allowed value of each field.
696#[derive(Debug, Clone, PartialEq)]
697struct Cron {
698    minutes: u64,
699    hours: u32,
700    days: u32,
701    months: u16,
702    weekdays: u8,
703    /// Day of month and day of week both restricted: either may match, as in
704    /// every cron.
705    either_day: bool,
706}
707
708const MONTHS: [&str; 12] = [
709    "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
710];
711const WEEKDAYS: [&str; 7] = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
712
713impl Cron {
714    fn parse(expression: &str) -> anyhow::Result<Self> {
715        let expression = match expression.trim() {
716            "@hourly" => "0 * * * *",
717            "@daily" | "@midnight" => "0 0 * * *",
718            "@weekly" => "0 0 * * 0",
719            "@monthly" => "0 0 1 * *",
720            "@yearly" | "@annually" => "0 0 1 1 *",
721            other => other,
722        };
723        let fields: Vec<&str> = expression.split_whitespace().collect();
724        let [minute, hour, day, month, weekday] = fields[..] else {
725            bail!("`{expression}` needs 5 fields: minute hour day-of-month month day-of-week");
726        };
727        let weekdays = field(weekday, 0, 7, &WEEKDAYS, 0)?;
728        let cron = Cron {
729            minutes: field(minute, 0, 59, &[], 0)?,
730            hours: field(hour, 0, 23, &[], 0)? as u32,
731            days: field(day, 1, 31, &[], 0)? as u32,
732            months: field(month, 1, 12, &MONTHS, 1)? as u16,
733            // 7 is Sunday too.
734            weekdays: ((weekdays | weekdays >> 7) & 0x7f) as u8,
735            either_day: !day.starts_with('*') && !weekday.starts_with('*'),
736        };
737        let probe = NaiveDateTime::default();
738        if cron.next_after(probe).is_none() {
739            bail!("`{expression}` never matches a date");
740        }
741        Ok(cron)
742    }
743
744    fn day_matches(&self, at: NaiveDateTime) -> bool {
745        let day = self.days & (1 << at.day()) != 0;
746        let weekday = self.weekdays & (1 << at.weekday().num_days_from_sunday()) != 0;
747        if self.either_day {
748            day || weekday
749        } else {
750            day && weekday
751        }
752    }
753
754    /// The first matching wall-clock minute after `after`.
755    fn next_after(&self, after: NaiveDateTime) -> Option<NaiveDateTime> {
756        let mut at = after.with_second(0)?.with_nanosecond(0)? + TimeDelta::minutes(1);
757        // Feb 29 on a Monday, say, can be decades away; stop after 30 years.
758        let limit = at + TimeDelta::days(366 * 30);
759        while at <= limit {
760            let midnight = at.date().and_hms_opt(0, 0, 0)?;
761            if self.months & (1 << at.month()) == 0 {
762                let (year, month) = if at.month() == 12 {
763                    (at.year() + 1, 1)
764                } else {
765                    (at.year(), at.month() + 1)
766                };
767                at = chrono::NaiveDate::from_ymd_opt(year, month, 1)?.and_hms_opt(0, 0, 0)?;
768            } else if !self.day_matches(at) {
769                at = midnight + TimeDelta::days(1);
770            } else if self.hours & (1 << at.hour()) == 0 {
771                at = at.with_minute(0)? + TimeDelta::hours(1);
772            } else if self.minutes & (1 << at.minute()) == 0 {
773                at += TimeDelta::minutes(1);
774            } else {
775                return Some(at);
776            }
777        }
778        None
779    }
780}
781
782/// One cron field as a bit set over `low..=high`. `names` are accepted for
783/// values from `first_name` on (`JAN` = 1, `SUN` = 0).
784fn field(text: &str, low: u32, high: u32, names: &[&str], first_name: u32) -> anyhow::Result<u64> {
785    let value = |part: &str| -> anyhow::Result<u32> {
786        let upper = part.to_ascii_uppercase();
787        if let Some(i) = names.iter().position(|n| *n == upper) {
788            return Ok(i as u32 + first_name);
789        }
790        let n: u32 = part
791            .parse()
792            .with_context(|| format!("`{part}` is not a number in `{text}`"))?;
793        if !(low..=high).contains(&n) {
794            bail!("`{n}` is out of range {low}-{high} in `{text}`");
795        }
796        Ok(n)
797    };
798    let mut bits = 0u64;
799    for part in text.split(',') {
800        let (range, step) = match part.split_once('/') {
801            Some((range, step)) => {
802                let step: u32 = step
803                    .parse()
804                    .with_context(|| format!("bad step in `{text}`"))?;
805                if step == 0 {
806                    bail!("a step of 0 in `{text}`");
807                }
808                (range, step)
809            }
810            None => (part, 1),
811        };
812        let (from, to) = if range == "*" {
813            (low, high)
814        } else if let Some((from, to)) = range.split_once('-') {
815            (value(from)?, value(to)?)
816        } else {
817            let from = value(range)?;
818            (from, if part.contains('/') { high } else { from })
819        };
820        if from > to {
821            bail!("`{range}` runs backwards in `{text}`");
822        }
823        for n in (from..=to).step_by(step as usize) {
824            bits |= 1 << n;
825        }
826    }
827    Ok(bits)
828}
829
830/// The first moment after `after` matching `cron` in `zone`. A wall-clock
831/// time that doesn't happen (the hour skipped in spring) runs right after
832/// the jump; one that happens twice (autumn) runs the first time.
833fn cron_next(cron: &Cron, after: i64, zone: Zone) -> Option<i64> {
834    let mut local = zone.local(after);
835    loop {
836        local = cron.next_after(local)?;
837        if let Some(at) = zone.resolve(local) {
838            if at > after {
839                return Some(at);
840            }
841            // The repeated hour's second pass: its first was already past.
842            continue;
843        }
844        // Skipped by daylight saving time: the first minute after the gap.
845        let at = (1..=180).find_map(|m| zone.resolve(local + TimeDelta::minutes(m)))?;
846        if at > after {
847            return Some(at);
848        }
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    // 2026-09-27 10:07:30 UTC, a Sunday.
857    const NOW: i64 = 1_790_503_650;
858
859    fn zone(name: &str) -> Zone {
860        name.parse().unwrap()
861    }
862
863    fn build(define: impl FnOnce(&mut Schedule)) -> Task {
864        let mut schedule = Schedule::default();
865        define(&mut schedule);
866        schedule.check().unwrap();
867        schedule.tasks.pop().unwrap()
868    }
869
870    fn local(at: i64, zone: Zone) -> String {
871        zone.local(at).format("%a %Y-%m-%d %H:%M").to_string()
872    }
873
874    #[test]
875    fn intervals_align_to_the_clock() {
876        let task = build(|s| {
877            s.every_minutes(5, "a", |_| async { Ok(()) });
878        });
879        let at = task.next_run(NOW, Zone::UTC);
880        assert_eq!((at % 300, at - NOW), (0, 150));
881        let wib = zone("+07:00");
882        assert_eq!(local(task.next_run(NOW, wib), wib), "Sun 2026-09-27 17:10");
883    }
884
885    #[test]
886    fn daily_times_use_the_zone() {
887        let task = build(|s| {
888            s.daily_at("02:00", "a", |_| async { Ok(()) });
889        });
890        let jakarta = zone("Asia/Jakarta");
891        // 10:07 UTC is 17:07 in Jakarta; the next 02:00 there is 19:00 UTC today.
892        let at = task.next_run(NOW, jakarta);
893        assert_eq!(local(at, jakarta), "Mon 2026-09-28 02:00");
894        assert_eq!(at - NOW, 8 * 3600 + 52 * 60 + 30);
895        let ny = zone("-05:00");
896        assert_eq!(local(task.next_run(NOW, ny), ny), "Mon 2026-09-28 02:00");
897        // UTC+14 is already Monday.
898        let kiribati = zone("+14:00");
899        let soon = build(|s| {
900            s.daily_at("00:30", "a", |_| async { Ok(()) });
901        })
902        .next_run(NOW, kiribati);
903        assert_eq!(soon - NOW, 22 * 60 + 30);
904        // A run due exactly now is the next day's.
905        let midnight = NOW - NOW.rem_euclid(86_400);
906        let task = build(|s| {
907            s.daily_at("00:00", "a", |_| async { Ok(()) });
908        });
909        assert_eq!(task.next_run(midnight, Zone::UTC), midnight + 86_400);
910    }
911
912    #[test]
913    fn cron_expressions() {
914        let next = |expression: &str| {
915            let task = build(|s| {
916                s.cron(expression, "a", |_| async { Ok(()) });
917            });
918            local(task.next_run(NOW, Zone::UTC), Zone::UTC)
919        };
920        assert_eq!(next("30 9 * * 1-5"), "Mon 2026-09-28 09:30");
921        assert_eq!(next("*/15 * * * *"), "Sun 2026-09-27 10:15");
922        assert_eq!(next("0 0 1 * *"), "Thu 2026-10-01 00:00");
923        assert_eq!(next("0 12 * JAN,jul SAT"), "Sat 2027-01-02 12:00");
924        assert_eq!(next("0 8 * * 7"), "Sun 2026-10-04 08:00", "7 is Sunday");
925        assert_eq!(next("0 0 29 2 *"), "Tue 2028-02-29 00:00");
926        // Day of month and day of week both set: either matches.
927        assert_eq!(next("0 0 13 * FRI"), "Fri 2026-10-02 00:00");
928        assert_eq!(next("5-10/5 22 * * *"), "Sun 2026-09-27 22:05");
929        assert_eq!(next("@monthly"), "Thu 2026-10-01 00:00");
930        assert_eq!(next("0 */6 * * *"), "Sun 2026-09-27 12:00");
931    }
932
933    #[test]
934    fn weekly_and_monthly() {
935        let task = build(|s| {
936            s.weekly_on(Weekday::Wed, "07:15", "a", |_| async { Ok(()) });
937        });
938        assert_eq!(
939            local(task.next_run(NOW, Zone::UTC), Zone::UTC),
940            "Wed 2026-09-30 07:15"
941        );
942        let task = build(|s| {
943            s.monthly_on(31, "00:00", "a", |_| async { Ok(()) });
944        });
945        let at = task.next_run(NOW, Zone::UTC);
946        assert_eq!(local(at, Zone::UTC), "Sat 2026-10-31 00:00");
947        assert_eq!(
948            local(task.next_run(at, Zone::UTC), Zone::UTC),
949            "Thu 2026-12-31 00:00"
950        );
951    }
952
953    #[test]
954    fn filters_narrow_runs() {
955        let task = build(|s| {
956            s.hourly("a", |_| async { Ok(()) })
957                .weekdays()
958                .between("09:00", "17:00");
959        });
960        let first = task.next_run(NOW, Zone::UTC);
961        assert_eq!(local(first, Zone::UTC), "Mon 2026-09-28 09:00");
962        let evening = NOW + 86_400 + 7 * 3600; // Monday 17:07
963        assert_eq!(
964            local(task.next_run(evening, Zone::UTC), Zone::UTC),
965            "Tue 2026-09-29 09:00"
966        );
967        let night = build(|s| {
968            s.every_minutes(30, "a", |_| async { Ok(()) })
969                .between("23:00", "01:00");
970        });
971        assert_eq!(
972            local(night.next_run(NOW, Zone::UTC), Zone::UTC),
973            "Sun 2026-09-27 23:00"
974        );
975        assert_eq!(
976            local(night.next_run(NOW + 14 * 3600, Zone::UTC), Zone::UTC), // Monday 00:07
977            "Mon 2026-09-28 00:30"
978        );
979        let task = build(|s| {
980            s.every_minute("a", |_| async { Ok(()) })
981                .days(&[Weekday::Sat]);
982        });
983        assert_eq!(
984            local(task.next_run(NOW, Zone::UTC), Zone::UTC),
985            "Sat 2026-10-03 00:00"
986        );
987        let never = build(|s| {
988            s.every_minute("a", |_| async { Ok(()) })
989                .weekdays()
990                .weekends();
991        });
992        assert_eq!(never.next_run(NOW, Zone::UTC), NEVER);
993    }
994
995    #[test]
996    fn a_task_zone_wins_and_follows_daylight_saving() {
997        let task = build(|s| {
998            s.daily_at("02:30", "a", |_| async { Ok(()) })
999                .timezone("Europe/Amsterdam");
1000        });
1001        let ams = zone("Europe/Amsterdam");
1002        // The night of 2027-03-28 skips 02:00–03:00: it runs at 03:00 instead.
1003        let before = ams.resolve("2027-03-27T12:00:00".parse().unwrap()).unwrap();
1004        let spring = task.next_run(before, Zone::UTC);
1005        assert_eq!(local(spring, ams), "Sun 2027-03-28 03:00");
1006        assert_eq!(
1007            local(task.next_run(spring, Zone::UTC), ams),
1008            "Mon 2027-03-29 02:30"
1009        );
1010        // 2026-10-25 has 02:30 twice: it runs once.
1011        let before = ams.resolve("2026-10-24T12:00:00".parse().unwrap()).unwrap();
1012        let autumn = task.next_run(before, Zone::UTC);
1013        assert_eq!(local(autumn, ams), "Sun 2026-10-25 02:30");
1014        let after = task.next_run(autumn, Zone::UTC);
1015        assert_eq!(local(after, ams), "Mon 2026-10-26 02:30");
1016        assert_eq!(after - autumn, 25 * 3600);
1017    }
1018
1019    #[test]
1020    fn mistakes_are_boot_errors() {
1021        for (bad, why) in [("25:00", "not a time of day"), ("9", "expected HH:MM")] {
1022            let mut s = Schedule::default();
1023            s.daily_at(bad, "report", |_| async { Ok(()) });
1024            let err = s.check().unwrap_err().to_string();
1025            assert!(err.contains("report") && err.contains(why), "{err}");
1026        }
1027        for (bad, why) in [
1028            ("* * * *", "5 fields"),
1029            ("60 * * * *", "out of range"),
1030            ("0 0 31 2 *", "never matches"),
1031            ("*/0 * * * *", "step of 0"),
1032            ("5-1 * * * *", "backwards"),
1033            ("0 0 * FOO *", "not a number"),
1034        ] {
1035            let mut s = Schedule::default();
1036            s.cron(bad, "c", |_| async { Ok(()) });
1037            let err = s.check().unwrap_err().to_string();
1038            assert!(err.contains(why), "{bad}: {err}");
1039        }
1040        let mut s = Schedule::default();
1041        s.hourly("a", |_| async { Ok(()) }).timezone("Mars/Olympus");
1042        assert!(s.check().unwrap_err().to_string().contains("Mars/Olympus"));
1043        let mut s = Schedule::default();
1044        s.hourly("a", |_| async { Ok(()) }).between("9", "17:00");
1045        assert!(s.check().is_err());
1046        let mut s = Schedule::default();
1047        s.hourly("a", |_| async { Ok(()) })
1048            .hourly("a", |_| async { Ok(()) });
1049        assert!(
1050            s.check()
1051                .unwrap_err()
1052                .to_string()
1053                .contains("scheduled twice")
1054        );
1055    }
1056
1057    #[test]
1058    fn chains_and_upcoming() {
1059        let mut schedule = Schedule::default();
1060        schedule
1061            .every_minute("a", |_| async { Ok(()) })
1062            .every_minutes(15, "b", |_| async { Ok(()) })
1063            .hourly("c", |_| async { Ok(()) })
1064            .every(Duration::ZERO, "d", |_| async { Ok(()) });
1065        let whens: Vec<When> = schedule.tasks.iter().map(|t| t.when.clone()).collect();
1066        assert_eq!(
1067            whens,
1068            [
1069                When::Every(60),
1070                When::Every(900),
1071                When::Every(3600),
1072                When::Every(1)
1073            ],
1074            "a zero interval runs every second rather than never"
1075        );
1076        schedule.check().unwrap();
1077        let upcoming = schedule.upcoming(Zone::UTC);
1078        assert_eq!(upcoming.len(), 4);
1079        let at = |i: usize| upcoming[i].at.unwrap().timestamp();
1080        assert_eq!(at(1) % 900, 0, "aligned to the quarter hour");
1081        assert_eq!(at(2) % 3600, 0, "on the hour");
1082    }
1083
1084    /// Filters no run ever passes: the task never runs (a date that can't
1085    /// come, `0 0 31 2 *`, is refused when the schedule is made).
1086    #[test]
1087    fn tasks_that_never_run_have_no_next_run() {
1088        let mut schedule = Schedule::default();
1089        schedule
1090            .hourly("never-on-the-hour", |_| async { Ok(()) })
1091            .between("10:30", "10:40");
1092        schedule.check().unwrap();
1093        let upcoming = schedule.upcoming(Zone::UTC);
1094        assert!(upcoming.iter().all(|run| run.at.is_none()), "{upcoming:?}");
1095    }
1096
1097    /// Autumn's repeated hour: a run already made in its first pass isn't
1098    /// made again in the second.
1099    #[test]
1100    fn the_repeated_hour_runs_once() {
1101        let task = build(|s| {
1102            s.cron("30 1 * * *", "a", |_| async { Ok(()) });
1103        });
1104        let ny = zone("America/New_York");
1105        // 2026-11-01 06:15 UTC is the second 01:15 in New York (EST).
1106        let second_pass = 1_793_513_700;
1107        assert_eq!(local(second_pass, ny), "Sun 2026-11-01 01:15");
1108        let at = task.next_run(second_pass, ny);
1109        assert_eq!(local(at, ny), "Mon 2026-11-02 01:30");
1110    }
1111
1112    /// A task that couldn't be added ignores the settings chained after
1113    /// it; a chain reads the schedule it builds.
1114    #[test]
1115    fn settings_after_a_task_that_failed_are_ignored() {
1116        let mut schedule = Schedule::default();
1117        let task = schedule.cron("not cron", "bad", |_| async { Ok(()) });
1118        assert!(task.upcoming(Zone::UTC).is_empty());
1119        task.weekdays();
1120        assert!(schedule.check().is_err());
1121    }
1122
1123    /// The run loop: nothing to do ends it; a due cron-style task already
1124    /// claimed by another process is skipped; a task not due yet waits.
1125    /// Claims that can't be written let the run go ahead.
1126    #[tokio::test]
1127    async fn the_run_loop_skips_runs_claimed_elsewhere() {
1128        let (logs, _logged) = crate::test_logs::capture();
1129        let app = crate::testing::TestApp::new(crate::App::new()).await;
1130        let state = app.state().clone();
1131        let (stop, stopped) = watch::channel(false);
1132        // Nothing scheduled: returns at once.
1133        Schedule::default()
1134            .run(state.clone(), Zone::UTC, stopped.clone())
1135            .await;
1136
1137        // The cache table gone: a claim can't be pruned or written, and the
1138        // run goes ahead as in a single process.
1139        crate::db::sql("ALTER TABLE cache RENAME TO cache_away")
1140            .execute(&state.db)
1141            .await
1142            .unwrap();
1143        assert!(claim(&state, "lonely", 60, 60).await);
1144        delete_expired_claims(&state, unix_now()).await;
1145        assert!(
1146            logs.has(&["could not claim the run; running it anyway"]),
1147            "{}",
1148            logs.text()
1149        );
1150        assert!(
1151            logs.has(&["could not clear old schedule claims"]),
1152            "{}",
1153            logs.text()
1154        );
1155        crate::db::sql("ALTER TABLE cache_away RENAME TO cache")
1156            .execute(&state.db)
1157            .await
1158            .unwrap();
1159
1160        let runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1161        let mut schedule = Schedule::default();
1162        let counted = runs.clone();
1163        schedule.cron("* * * * *", "minutely", move |_| {
1164            let counted = counted.clone();
1165            async move {
1166                counted.fetch_add(1, Ordering::SeqCst);
1167                Ok(())
1168            }
1169        });
1170        let nightly = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1171        let counted_nightly = nightly.clone();
1172        schedule.daily_at("00:00", "nightly", move |_| {
1173            let counted = counted_nightly.clone();
1174            async move {
1175                counted.fetch_add(1, Ordering::SeqCst);
1176                Ok(())
1177            }
1178        });
1179        // A second before the next minute, whose run another process has
1180        // claimed.
1181        let now = unix_now();
1182        let slot = (now / 60 + 1) * 60;
1183        let offset = slot - 1 - now;
1184        assert!(claim(&state, "minutely", slot, 3600).await);
1185        let looping = tokio::spawn(crate::clock::with_offset(
1186            offset,
1187            schedule.run(state.clone(), Zone::UTC, stopped),
1188        ));
1189        // Wait for the loop to reach that minute: about a second, but longer
1190        // under a slow run (coverage on PostgreSQL took over 2.5 s).
1191        for _ in 0..100 {
1192            if logs.has(&["skipped: another process runs it", "minutely"]) {
1193                break;
1194            }
1195            tokio::time::sleep(Duration::from_millis(100)).await;
1196        }
1197        stop.send(true).unwrap();
1198        looping.await.unwrap();
1199        assert_eq!(runs.load(Ordering::SeqCst), 0);
1200        // The loop reached the minutely run and found it claimed; the nightly
1201        // one wasn't due.
1202        assert!(
1203            logs.has(&["skipped: another process runs it", "minutely"]),
1204            "{}",
1205            logs.text()
1206        );
1207        assert_eq!(nightly.load(Ordering::SeqCst), 0);
1208    }
1209}