1use 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
42fn 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
50fn 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
58pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
60
61pub struct ScheduleTicker {
89 store: Arc<dyn Store>,
90 engine: Option<Arc<Engine>>,
91 interval: Duration,
92}
93
94impl ScheduleTicker {
95 pub fn new(store: Arc<dyn Store>) -> Self {
97 Self {
98 store,
99 engine: None,
100 interval: DEFAULT_TICK_INTERVAL,
101 }
102 }
103
104 pub fn engine(mut self, engine: Arc<Engine>) -> Self {
109 self.engine = Some(engine);
110 self
111 }
112
113 pub fn interval(mut self, interval: Duration) -> Self {
115 self.interval = interval;
116 self
117 }
118
119 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[tokio::test]
497 async fn tick_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
498 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 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 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 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}