1use 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
70const NEVER: i64 = i64::MAX;
73
74#[derive(Debug, Clone, PartialEq)]
75enum When {
76 Every(i64),
78 Cron(Box<Cron>),
80}
81
82impl When {
83 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
93struct 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 days: u8,
111 between: Option<(u32, u32)>,
113 on_failure: Option<FailFn>,
114 on_success: Option<DoneFn>,
115 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 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 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 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 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#[derive(Clone, Default)]
242pub struct Schedule {
243 tasks: Vec<Task>,
244 error: Option<String>,
245}
246
247pub struct ScheduledTask<'a> {
250 schedule: &'a mut Schedule,
251 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 pub fn weekdays(self) -> Self {
285 self.edit(|t| {
286 t.days &= 0b0001_1111;
287 Ok(())
288 })
289 }
290
291 pub fn weekends(self) -> Self {
293 self.edit(|t| {
294 t.days &= 0b0110_0000;
295 Ok(())
296 })
297 }
298
299 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 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 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 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 pub fn ping_before(self, url: impl Into<String>) -> Self {
349 self.ping(PING_BEFORE, url.into())
350 }
351
352 pub fn then_ping(self, url: impl Into<String>) -> Self {
354 self.ping(PING_AFTER, url.into())
355 }
356
357 pub fn ping_on_success(self, url: impl Into<String>) -> Self {
359 self.ping(PING_SUCCESS, url.into())
360 }
361
362 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
534#[non_exhaustive]
535pub struct UpcomingRun {
536 pub name: String,
538 pub at: Option<crate::db::DateTime>,
540 pub zone: Zone,
542}
543
544impl Schedule {
545 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 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 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 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 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#[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
646const CLAIM_PREFIX: &str = "renox:schedule:";
648
649async 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
663async 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
684fn 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#[derive(Debug, Clone, PartialEq)]
697struct Cron {
698 minutes: u64,
699 hours: u32,
700 days: u32,
701 months: u16,
702 weekdays: u8,
703 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 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 fn next_after(&self, after: NaiveDateTime) -> Option<NaiveDateTime> {
756 let mut at = after.with_second(0)?.with_nanosecond(0)? + TimeDelta::minutes(1);
757 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
782fn 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
830fn 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 continue;
843 }
844 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 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 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 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 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 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; 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), "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 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 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 #[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 #[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 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 #[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 #[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 Schedule::default()
1134 .run(state.clone(), Zone::UTC, stopped.clone())
1135 .await;
1136
1137 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 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 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 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}