Skip to main content

ironflow_api/
schedule_ticker.rs

1//! Unified schedule executor for all workflow schedules.
2//!
3//! All schedules live in the database -- both those created via the REST API
4//! and those declared by a [`WorkflowHandler::schedule()`]. At server startup,
5//! call [`sync_handler_schedules`](crate::schedule_sync::sync_handler_schedules)
6//! to reconcile handler-declared schedules, then spawn [`ScheduleTicker::run`]
7//! which polls [`list_due_schedules`] and fires each due schedule with
8//! [`fire_due_schedule`]: the runs of the occurrences it catches up and the
9//! next trigger time are written in one transaction, or not at all.
10//!
11//! Which occurrences run follows the schedule's policy: occurrences missed
12//! while the server was down are caught up (`latest`, `all` or `skip`) within
13//! the catch-up window, and an `overlap = skip` schedule starts no run while
14//! one of its runs is still active. Every dropped occurrence is logged,
15//! counted in `ironflow_schedule_missed_total` and published as
16//! [`Event::ScheduleOccurrencesMissed`] when the ticker has an engine.
17//!
18//! [`WorkflowHandler::schedule()`]: ironflow_engine::handler::WorkflowHandler::schedule
19//! [`list_due_schedules`]: ironflow_store::schedule_store::ScheduleStore::list_due_schedules
20//! [`fire_due_schedule`]: ironflow_store::schedule_store::ScheduleStore::fire_due_schedule
21
22use std::sync::Arc;
23use std::time::Duration;
24
25use chrono::{TimeDelta, Utc};
26use ironflow_engine::engine::Engine;
27use ironflow_engine::notify::{Event, ScheduleOccurrencesMissedEvent};
28use ironflow_store::entities::{Schedule, ScheduleMissReason, ScheduleNext};
29use ironflow_store::store::Store;
30use tokio::time::interval;
31use tokio_util::sync::CancellationToken;
32use tracing::{debug, error, info, warn};
33use uuid::Uuid;
34
35#[cfg(feature = "prometheus")]
36use ironflow_core::metric_names::{SCHEDULE_FIRE_ERRORS_TOTAL, SCHEDULE_MISSED_TOTAL};
37#[cfg(feature = "prometheus")]
38use metrics::counter;
39
40use crate::schedule_clock::{MIN_ON_TIME_GRACE, MissedOccurrences, plan_firing};
41
42/// Count a schedule that failed to fire or was disabled by an error.
43fn record_fire_error(schedule_id: Uuid) {
44    #[cfg(feature = "prometheus")]
45    counter!(SCHEDULE_FIRE_ERRORS_TOTAL, "schedule" => schedule_id.to_string()).increment(1);
46    #[cfg(not(feature = "prometheus"))]
47    let _ = schedule_id;
48}
49
50/// Count occurrences a schedule did not run.
51fn record_missed_metric(schedule_id: Uuid, count: u64) {
52    #[cfg(feature = "prometheus")]
53    counter!(SCHEDULE_MISSED_TOTAL, "schedule" => schedule_id.to_string()).increment(count);
54    #[cfg(not(feature = "prometheus"))]
55    let _ = (schedule_id, count);
56}
57
58/// How often the ticker checks for due schedules.
59pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
60
61/// Periodic task that fires DB-persisted schedules.
62///
63/// # Examples
64///
65/// ```no_run
66/// use std::sync::Arc;
67/// use std::time::Duration;
68/// use ironflow_api::schedule_ticker::ScheduleTicker;
69/// use ironflow_api::schedule_sync::sync_handler_schedules;
70/// use ironflow_engine::engine::Engine;
71/// use ironflow_core::providers::claude::ClaudeCodeProvider;
72/// use ironflow_store::memory::InMemoryStore;
73/// use ironflow_store::store::Store;
74/// use tokio_util::sync::CancellationToken;
75///
76/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
77/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
78/// let engine = Arc::new(Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new())));
79/// sync_handler_schedules(&engine, store.as_ref()).await?;
80///
81/// let ticker = ScheduleTicker::new(store)
82///     .engine(engine)
83///     .interval(Duration::from_secs(10));
84/// tokio::spawn(ticker.run(CancellationToken::new()));
85/// # Ok(())
86/// # }
87/// ```
88pub struct ScheduleTicker {
89    store: Arc<dyn Store>,
90    engine: Option<Arc<Engine>>,
91    interval: Duration,
92}
93
94impl ScheduleTicker {
95    /// Create a ticker with the default interval.
96    pub fn new(store: Arc<dyn Store>) -> Self {
97        Self {
98            store,
99            engine: None,
100            interval: DEFAULT_TICK_INTERVAL,
101        }
102    }
103
104    /// Publish [`Event::ScheduleOccurrencesMissed`] through this engine's
105    /// event publisher whenever a schedule drops occurrences.
106    ///
107    /// Without an engine, missed occurrences are only logged and counted.
108    pub fn engine(mut self, engine: Arc<Engine>) -> Self {
109        self.engine = Some(engine);
110        self
111    }
112
113    /// Set how often due schedules are polled.
114    pub fn interval(mut self, interval: Duration) -> Self {
115        self.interval = interval;
116        self
117    }
118
119    /// How late an occurrence may fire and still count as on time: two tick
120    /// intervals, and never less than [`MIN_ON_TIME_GRACE`].
121    fn on_time_grace(&self) -> TimeDelta {
122        let grace = MIN_ON_TIME_GRACE.max(self.interval.saturating_mul(2));
123        TimeDelta::from_std(grace).unwrap_or(TimeDelta::MAX)
124    }
125
126    /// Run the tick loop until `shutdown` is cancelled.
127    ///
128    /// Store errors are logged per-schedule; the loop keeps going.
129    pub async fn run(self, shutdown: CancellationToken) {
130        let mut ticker = interval(self.interval);
131        ticker.tick().await;
132
133        info!(
134            interval_secs = self.interval.as_secs(),
135            "schedule ticker started"
136        );
137
138        loop {
139            tokio::select! {
140                _ = shutdown.cancelled() => {
141                    info!("schedule ticker stopped");
142                    return;
143                }
144                _ = ticker.tick() => {
145                    self.tick().await;
146                }
147            }
148        }
149    }
150
151    /// Fire every due schedule once.
152    ///
153    /// The schedule's catch-up policy decides which of its missed occurrences
154    /// run; all of them are fired atomically: a schedule whose firing fails
155    /// stays due and is retried on the next tick. A schedule whose next
156    /// occurrence cannot be computed fires its due run, then is disabled with
157    /// the reason in `last_error`.
158    ///
159    /// Exposed for tests and for callers that drive the tick themselves.
160    pub async fn tick(&self) {
161        let due = match self.store.list_due_schedules().await {
162            Ok(due) => due,
163            Err(err) => {
164                error!(error = %err, "failed to list due schedules");
165                return;
166            }
167        };
168
169        if due.is_empty() {
170            return;
171        }
172
173        info!(count = due.len(), "firing due schedules");
174
175        let grace = self.on_time_grace();
176        for schedule in due {
177            let Some(occurrence) = schedule.next_trigger_at else {
178                continue;
179            };
180            let firing = plan_firing(&schedule, occurrence, Utc::now(), grace);
181
182            match self
183                .store
184                .fire_due_schedule(schedule.id, occurrence, firing.plan.clone())
185                .await
186            {
187                Ok(Some(fired)) => {
188                    for scheduled in &fired.runs {
189                        info!(
190                            schedule_id = %schedule.id,
191                            workflow = %schedule.workflow_name,
192                            run_id = %scheduled.run.run().id,
193                            occurrence = %scheduled.occurrence,
194                            replayed = !scheduled.run.is_created(),
195                            "schedule fired"
196                        );
197                    }
198                    // Traced only once the firing is committed: a firing that
199                    // fails is retried, and one won by another instance is
200                    // traced there.
201                    let overlapped =
202                        MissedOccurrences::group(ScheduleMissReason::Overlap, &fired.overlapped);
203                    for missed in firing.missed.iter().chain(&overlapped) {
204                        self.record_missed(&schedule, missed);
205                    }
206                    if firing.truncated {
207                        warn!(
208                            schedule_id = %schedule.id,
209                            workflow = %schedule.workflow_name,
210                            "too many missed occurrences to enumerate, the counts are lower bounds"
211                        );
212                    }
213                    if let ScheduleNext::Disable { error } = &firing.plan.next {
214                        warn!(
215                            schedule_id = %schedule.id,
216                            workflow = %schedule.workflow_name,
217                            error = %error,
218                            "cannot compute next trigger, schedule disabled"
219                        );
220                        record_fire_error(schedule.id);
221                    }
222                }
223                Ok(None) => {
224                    debug!(
225                        schedule_id = %schedule.id,
226                        "schedule occurrence already fired or no longer due"
227                    );
228                }
229                Err(err) => {
230                    error!(
231                        schedule_id = %schedule.id,
232                        workflow = %schedule.workflow_name,
233                        error = %err,
234                        "failed to fire schedule, it stays due and is retried next tick"
235                    );
236                    record_fire_error(schedule.id);
237                }
238            }
239        }
240    }
241
242    /// Log, count and publish occurrences a schedule did not run.
243    fn record_missed(&self, schedule: &Schedule, missed: &MissedOccurrences) {
244        warn!(
245            schedule_id = %schedule.id,
246            workflow = %schedule.workflow_name,
247            reason = %missed.reason,
248            count = missed.count,
249            first = %missed.first,
250            last = %missed.last,
251            "schedule occurrences missed"
252        );
253        record_missed_metric(schedule.id, missed.count);
254        if let Some(engine) = &self.engine {
255            let event = ScheduleOccurrencesMissedEvent {
256                schedule_id: schedule.id,
257                workflow_name: schedule.workflow_name.clone(),
258                reason: missed.reason,
259                count: missed.count,
260                first: missed.first,
261                last: missed.last,
262                at: Utc::now(),
263            };
264            engine
265                .event_publisher()
266                .publish(Event::ScheduleOccurrencesMissed(event));
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use std::sync::{Arc, Mutex};
274    use std::time::Duration as StdDuration;
275
276    use chrono::{DateTime, Duration as ChronoDuration, DurationRound, Utc};
277    use ironflow_core::providers::claude::ClaudeCodeProvider;
278    use ironflow_engine::notify::{EventSubscriber, SubscriberFuture};
279    use ironflow_store::entities::{
280        CatchupPolicy, NewSchedule, OverlapPolicy, Run, RunFilter, Schedule, SchedulePolicy,
281        ScheduleSource, ScheduleUpdate, TriggerKind,
282    };
283    use ironflow_store::memory::InMemoryStore;
284    use ironflow_store::store::Store;
285    #[cfg(feature = "prometheus")]
286    use metrics::with_local_recorder;
287    #[cfg(feature = "prometheus")]
288    use metrics_exporter_prometheus::PrometheusBuilder;
289    use serde_json::json;
290    #[cfg(feature = "prometheus")]
291    use tokio::runtime::Builder;
292    use tokio::time::{sleep, timeout};
293    use uuid::Uuid;
294
295    use chrono_tz::Tz;
296
297    use crate::schedule_clock::schedule_next;
298
299    use super::*;
300
301    async fn store_with_schedule(
302        cron: &str,
303        due: DateTime<Utc>,
304        policy: SchedulePolicy,
305    ) -> (Arc<dyn Store>, Schedule) {
306        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
307        let schedule = store
308            .create_schedule(NewSchedule {
309                workflow_name: "deploy".to_string(),
310                cron_expression: cron.to_string(),
311                inputs: json!({}),
312                source: ScheduleSource::Api,
313                priority: 0,
314                created_by_user_id: Some(Uuid::now_v7()),
315                next_trigger_at: Some(due),
316                policy,
317            })
318            .await
319            .expect("create schedule");
320        (store, schedule)
321    }
322
323    async fn store_with_due_schedule(cron: &str) -> (Arc<dyn Store>, Schedule) {
324        let due = Utc::now() - ChronoDuration::seconds(10);
325        store_with_schedule(cron, due, SchedulePolicy::default()).await
326    }
327
328    /// The top of the current hour, and an hourly schedule due four hours
329    /// before it: five occurrences to catch up, the last one current.
330    async fn store_with_hourly_backlog(policy: SchedulePolicy) -> (Arc<dyn Store>, Schedule) {
331        let hour = Utc::now()
332            .duration_trunc(ChronoDuration::hours(1))
333            .expect("truncate to the hour");
334        store_with_schedule("0 * * * *", hour - ChronoDuration::hours(4), policy).await
335    }
336
337    fn policy(catchup: CatchupPolicy, overlap: OverlapPolicy) -> SchedulePolicy {
338        SchedulePolicy {
339            catchup,
340            overlap,
341            ..SchedulePolicy::default()
342        }
343    }
344
345    async fn all_runs(store: &Arc<dyn Store>) -> Vec<Run> {
346        store
347            .list_runs(RunFilter::default(), 1, 100)
348            .await
349            .expect("list runs")
350            .items
351    }
352
353    /// Make the schedule due again, as a later occurrence would.
354    async fn make_due_again(store: &Arc<dyn Store>, id: Uuid) {
355        store
356            .update_schedule(
357                id,
358                ScheduleUpdate {
359                    next_trigger_at: Some(Some(Utc::now() - ChronoDuration::seconds(1))),
360                    ..Default::default()
361                },
362            )
363            .await
364            .expect("make due");
365    }
366
367    /// Collects the events published during a test.
368    struct EventCollector(Arc<Mutex<Vec<Event>>>);
369
370    impl EventSubscriber for EventCollector {
371        fn name(&self) -> &str {
372            "event-collector"
373        }
374
375        fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
376            Box::pin(async move {
377                self.0.lock().expect("collector lock").push(event.clone());
378            })
379        }
380    }
381
382    /// A ticker whose engine collects every missed-occurrence event.
383    fn recording_ticker(store: Arc<dyn Store>) -> (ScheduleTicker, Arc<Mutex<Vec<Event>>>) {
384        let events = Arc::new(Mutex::new(Vec::new()));
385        let mut engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
386        engine.subscribe(
387            EventCollector(events.clone()),
388            &[Event::SCHEDULE_OCCURRENCES_MISSED],
389        );
390        (ScheduleTicker::new(store).engine(Arc::new(engine)), events)
391    }
392
393    /// Wait until `count` events were delivered: subscribers run on spawned tasks.
394    async fn collected(events: &Arc<Mutex<Vec<Event>>>, count: usize) -> Vec<Event> {
395        timeout(StdDuration::from_secs(5), async {
396            loop {
397                let seen = events.lock().expect("collector lock").clone();
398                if seen.len() >= count {
399                    return seen;
400                }
401                sleep(StdDuration::from_millis(10)).await;
402            }
403        })
404        .await
405        .expect("events delivered")
406    }
407
408    fn missed_events(events: &[Event]) -> Vec<ScheduleOccurrencesMissedEvent> {
409        events
410            .iter()
411            .filter_map(|event| match event {
412                Event::ScheduleOccurrencesMissed(payload) => Some(payload.clone()),
413                _ => None,
414            })
415            .collect()
416    }
417
418    async fn make_store_with_due_schedule() -> (Arc<dyn Store>, Uuid) {
419        let (store, schedule) = store_with_due_schedule("* * * * *").await;
420        (store, schedule.id)
421    }
422
423    async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
424        store
425            .find_schedule_by_id(id)
426            .await
427            .expect("find")
428            .expect("exists")
429    }
430
431    #[tokio::test]
432    async fn tick_creates_run_for_due_schedule() {
433        let (store, schedule_id) = make_store_with_due_schedule().await;
434        let ticker = ScheduleTicker::new(store.clone());
435
436        ticker.tick().await;
437
438        let runs = store
439            .list_runs(RunFilter::default(), 1, 10)
440            .await
441            .expect("list runs");
442        assert_eq!(runs.items.len(), 1);
443        assert_eq!(runs.items[0].workflow_name, "deploy");
444
445        let updated = reload(&store, schedule_id).await;
446        assert!(updated.last_triggered_at.is_some());
447        assert!(updated.next_trigger_at.is_some());
448        assert!(updated.next_trigger_at.unwrap() > Utc::now() - ChronoDuration::seconds(1));
449    }
450
451    #[tokio::test]
452    async fn tick_run_carries_the_occurrence_key() {
453        // A yearly cron: no later occurrence can fall between the due one and
454        // the tick and supersede it.
455        let (store, schedule) = store_with_due_schedule("0 0 1 1 *").await;
456        let occurrence = schedule.next_trigger_at.expect("due");
457
458        ScheduleTicker::new(store.clone()).tick().await;
459
460        let runs = store
461            .list_runs(RunFilter::default(), 1, 10)
462            .await
463            .expect("list runs");
464        assert_eq!(runs.items.len(), 1);
465        assert_eq!(
466            runs.items[0].idempotency_key.as_deref(),
467            Some(Schedule::occurrence_key(schedule.id, occurrence).as_str())
468        );
469    }
470
471    /// #173: an instance that saw the schedule due and stopped before firing
472    /// it used to leave it active with no next trigger, never fired again.
473    #[tokio::test]
474    async fn tick_fires_a_schedule_an_interrupted_tick_left_due() {
475        let (store, schedule_id) = make_store_with_due_schedule().await;
476        // A first instance lists the due schedules, then stops before firing.
477        let seen = store.list_due_schedules().await.expect("list due");
478        assert_eq!(seen.len(), 1);
479
480        ScheduleTicker::new(store.clone()).tick().await;
481
482        let s = reload(&store, schedule_id).await;
483        assert!(
484            !(s.is_active() && s.next_trigger_at.is_none()),
485            "active schedule without next trigger: {s:?}"
486        );
487        let runs = store
488            .list_runs(RunFilter::default(), 1, 10)
489            .await
490            .expect("list runs");
491        assert_eq!(runs.items.len(), 1);
492    }
493
494    /// #173: a cron expression whose next occurrence cannot be computed used
495    /// to leave the schedule active with no next trigger.
496    #[tokio::test]
497    async fn tick_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
498        // February 30th never happens.
499        let (store, schedule) = store_with_due_schedule("0 0 30 2 *").await;
500
501        ScheduleTicker::new(store.clone()).tick().await;
502
503        let s = reload(&store, schedule.id).await;
504        assert!(
505            !(s.is_active() && s.next_trigger_at.is_none()),
506            "active schedule without next trigger: {s:?}"
507        );
508        assert!(!s.is_active());
509        let error = s.last_error.expect("disable reason stored");
510        assert!(error.contains("cannot compute next trigger"), "{error}");
511
512        // The due occurrence still ran.
513        let runs = store
514            .list_runs(RunFilter::default(), 1, 10)
515            .await
516            .expect("list runs");
517        assert_eq!(runs.items.len(), 1);
518    }
519
520    #[tokio::test]
521    async fn tick_disables_a_schedule_whose_cron_no_longer_parses() {
522        let (store, schedule) = store_with_due_schedule("not-a-cron").await;
523
524        ScheduleTicker::new(store.clone()).tick().await;
525
526        let s = reload(&store, schedule.id).await;
527        assert!(!s.is_active());
528        assert!(s.next_trigger_at.is_none());
529        let error = s.last_error.expect("disable reason stored");
530        assert!(error.contains("invalid cron expression"), "{error}");
531    }
532
533    #[test]
534    fn schedule_next_for_a_valid_cron_is_in_the_future() {
535        match schedule_next("* * * * *", Tz::UTC) {
536            ScheduleNext::At(at) => assert!(at > Utc::now()),
537            ScheduleNext::Disable { error } => panic!("unexpected disable: {error}"),
538        }
539    }
540
541    #[tokio::test]
542    async fn tick_skips_disabled_schedule() {
543        let (store, schedule_id) = make_store_with_due_schedule().await;
544
545        store
546            .update_schedule(
547                schedule_id,
548                ScheduleUpdate {
549                    disabled_at: Some(Some(Utc::now())),
550                    ..Default::default()
551                },
552            )
553            .await
554            .expect("disable");
555
556        let ticker = ScheduleTicker::new(store.clone());
557        ticker.tick().await;
558
559        let runs = store
560            .list_runs(RunFilter::default(), 1, 10)
561            .await
562            .expect("list runs");
563        assert!(runs.items.is_empty());
564    }
565
566    #[tokio::test]
567    async fn tick_skips_future_schedule() {
568        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
569        store
570            .create_schedule(NewSchedule {
571                workflow_name: "deploy".to_string(),
572                cron_expression: "* * * * *".to_string(),
573                inputs: json!({}),
574                source: ScheduleSource::Api,
575                priority: 0,
576                created_by_user_id: Some(Uuid::now_v7()),
577                next_trigger_at: Some(Utc::now() + ChronoDuration::hours(1)),
578                policy: SchedulePolicy::default(),
579            })
580            .await
581            .expect("create");
582
583        let ticker = ScheduleTicker::new(store.clone());
584        ticker.tick().await;
585
586        let runs = store
587            .list_runs(RunFilter::default(), 1, 10)
588            .await
589            .expect("list runs");
590        assert!(runs.items.is_empty());
591    }
592
593    #[tokio::test]
594    async fn tick_does_nothing_when_empty() {
595        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
596        let ticker = ScheduleTicker::new(store.clone());
597        ticker.tick().await;
598
599        let runs = store
600            .list_runs(RunFilter::default(), 1, 10)
601            .await
602            .expect("list runs");
603        assert!(runs.items.is_empty());
604    }
605
606    #[tokio::test]
607    async fn tick_does_not_double_fire() {
608        let (store, _) = make_store_with_due_schedule().await;
609        let ticker = ScheduleTicker::new(store.clone());
610
611        ticker.tick().await;
612        ticker.tick().await;
613
614        let runs = store
615            .list_runs(RunFilter::default(), 1, 10)
616            .await
617            .expect("list runs");
618        assert_eq!(
619            runs.items.len(),
620            1,
621            "second tick must not create a duplicate run"
622        );
623    }
624
625    #[cfg(feature = "prometheus")]
626    #[test]
627    fn tick_counts_fire_errors_per_schedule() {
628        let recorder = PrometheusBuilder::new().build_recorder();
629        let handle = recorder.handle();
630        let runtime = Builder::new_current_thread()
631            .enable_all()
632            .build()
633            .expect("runtime");
634
635        let (broken, healthy) = with_local_recorder(&recorder, || {
636            runtime.block_on(async {
637                let (store, broken) = store_with_due_schedule("0 0 30 2 *").await;
638                // A yearly cron: no later occurrence can fall between the due
639                // one and the tick and be counted as superseded.
640                let healthy = store
641                    .create_schedule(NewSchedule {
642                        workflow_name: "deploy".to_string(),
643                        cron_expression: "0 0 1 1 *".to_string(),
644                        inputs: json!({}),
645                        source: ScheduleSource::Api,
646                        priority: 0,
647                        created_by_user_id: None,
648                        next_trigger_at: Some(Utc::now() - ChronoDuration::seconds(10)),
649                        policy: SchedulePolicy::default(),
650                    })
651                    .await
652                    .expect("create healthy");
653                ScheduleTicker::new(store).tick().await;
654                (broken.id, healthy.id)
655            })
656        });
657
658        let rendered = handle.render();
659        assert!(
660            rendered.contains(&format!(
661                "ironflow_schedule_fire_errors_total{{schedule=\"{broken}\"}} 1"
662            )),
663            "{rendered}"
664        );
665        assert!(!rendered.contains(&healthy.to_string()), "{rendered}");
666    }
667
668    #[tokio::test]
669    async fn tick_catchup_all_creates_one_run_per_missed_occurrence() {
670        let (store, schedule) =
671            store_with_hourly_backlog(policy(CatchupPolicy::All, OverlapPolicy::Allow)).await;
672        let due = schedule.next_trigger_at.expect("due");
673
674        ScheduleTicker::new(store.clone()).tick().await;
675
676        let runs = all_runs(&store).await;
677        assert_eq!(runs.len(), 5, "{runs:?}");
678        let mut occurrences: Vec<DateTime<Utc>> = runs
679            .iter()
680            .map(|run| match &run.trigger {
681                TriggerKind::Cron {
682                    schedule_id,
683                    scheduled_for,
684                    ..
685                } => {
686                    assert_eq!(*schedule_id, Some(schedule.id));
687                    scheduled_for.expect("a scheduled occurrence")
688                }
689                other => panic!("expected a cron trigger, got {other:?}"),
690            })
691            .collect();
692        occurrences.sort();
693        let expected: Vec<DateTime<Utc>> = (0..5)
694            .map(|hours| due + ChronoDuration::hours(hours))
695            .collect();
696        assert_eq!(occurrences, expected);
697
698        let s = reload(&store, schedule.id).await;
699        assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
700    }
701
702    #[tokio::test]
703    async fn tick_catchup_latest_creates_a_single_run() {
704        let (store, schedule) = store_with_hourly_backlog(SchedulePolicy::default()).await;
705        let latest = schedule.next_trigger_at.expect("due") + ChronoDuration::hours(4);
706
707        ScheduleTicker::new(store.clone()).tick().await;
708
709        let runs = all_runs(&store).await;
710        assert_eq!(runs.len(), 1, "{runs:?}");
711        assert_eq!(
712            runs[0].trigger,
713            TriggerKind::Cron {
714                schedule: "0 * * * *".to_string(),
715                schedule_id: Some(schedule.id),
716                scheduled_for: Some(latest),
717            }
718        );
719    }
720
721    #[tokio::test]
722    async fn tick_catchup_publishes_missed_event() {
723        let (store, schedule) = store_with_hourly_backlog(SchedulePolicy::default()).await;
724        let due = schedule.next_trigger_at.expect("due");
725        let (ticker, events) = recording_ticker(store);
726
727        ticker.tick().await;
728
729        let missed = missed_events(&collected(&events, 1).await);
730        assert_eq!(missed.len(), 1, "{missed:?}");
731        assert_eq!(missed[0].schedule_id, schedule.id);
732        assert_eq!(missed[0].workflow_name, "deploy");
733        assert_eq!(missed[0].reason, ScheduleMissReason::Superseded);
734        assert_eq!(missed[0].count, 4);
735        assert_eq!(missed[0].first, due);
736        assert_eq!(missed[0].last, due + ChronoDuration::hours(3));
737    }
738
739    #[tokio::test]
740    async fn tick_catchup_skip_drops_every_late_occurrence() {
741        let skip = policy(CatchupPolicy::Skip, OverlapPolicy::Allow);
742        let due = Utc::now() - ChronoDuration::minutes(30);
743        // A yearly cron: the due occurrence is the only one, half an hour late.
744        let (store, schedule) = store_with_schedule("0 0 1 1 *", due, skip).await;
745        let (ticker, events) = recording_ticker(store.clone());
746
747        ticker.tick().await;
748
749        assert!(all_runs(&store).await.is_empty());
750        let missed = missed_events(&collected(&events, 1).await);
751        assert_eq!(missed[0].reason, ScheduleMissReason::CatchupSkip);
752        assert_eq!(missed[0].count, 1);
753        let s = reload(&store, schedule.id).await;
754        assert!(s.last_triggered_at.is_none());
755        assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
756    }
757
758    #[tokio::test]
759    async fn tick_overlap_skip_skips_while_a_run_is_active() {
760        let skip = policy(CatchupPolicy::Latest, OverlapPolicy::Skip);
761        let due = Utc::now() - ChronoDuration::seconds(10);
762        let (store, schedule) = store_with_schedule("0 0 1 1 *", due, skip).await;
763        let (ticker, events) = recording_ticker(store.clone());
764
765        ticker.tick().await;
766        assert_eq!(all_runs(&store).await.len(), 1);
767        let first_trigger = reload(&store, schedule.id).await.last_triggered_at;
768
769        make_due_again(&store, schedule.id).await;
770        ticker.tick().await;
771
772        let runs = all_runs(&store).await;
773        assert_eq!(runs.len(), 1, "the active run blocks the next occurrence");
774        let missed = missed_events(&collected(&events, 1).await);
775        assert_eq!(missed.len(), 1, "{missed:?}");
776        assert_eq!(missed[0].reason, ScheduleMissReason::Overlap);
777        assert_eq!(missed[0].count, 1);
778        let s = reload(&store, schedule.id).await;
779        assert_eq!(s.last_triggered_at, first_trigger);
780        assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
781    }
782
783    #[tokio::test]
784    async fn tick_overlap_allow_stacks_runs() {
785        let due = Utc::now() - ChronoDuration::seconds(10);
786        let (store, schedule) =
787            store_with_schedule("0 0 1 1 *", due, SchedulePolicy::default()).await;
788        let ticker = ScheduleTicker::new(store.clone());
789
790        ticker.tick().await;
791        make_due_again(&store, schedule.id).await;
792        ticker.tick().await;
793
794        assert_eq!(all_runs(&store).await.len(), 2);
795    }
796
797    #[cfg(feature = "prometheus")]
798    #[test]
799    fn tick_catchup_counts_missed_occurrences_per_schedule() {
800        let recorder = PrometheusBuilder::new().build_recorder();
801        let handle = recorder.handle();
802        let runtime = Builder::new_current_thread()
803            .enable_all()
804            .build()
805            .expect("runtime");
806
807        let schedule_id = with_local_recorder(&recorder, || {
808            runtime.block_on(async {
809                let (store, schedule) = store_with_hourly_backlog(SchedulePolicy::default()).await;
810                ScheduleTicker::new(store).tick().await;
811                schedule.id
812            })
813        });
814
815        let rendered = handle.render();
816        assert!(
817            rendered.contains(&format!(
818                "ironflow_schedule_missed_total{{schedule=\"{schedule_id}\"}} 4"
819            )),
820            "{rendered}"
821        );
822    }
823}