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 occurrence with
8//! [`fire_due_schedule`]: the run creation and the next trigger time are
9//! written in one transaction, or not at all.
10//!
11//! [`list_due_schedules`]: ironflow_store::schedule_store::ScheduleStore::list_due_schedules
12//! [`fire_due_schedule`]: ironflow_store::schedule_store::ScheduleStore::fire_due_schedule
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18use croner::Cron;
19use ironflow_store::entities::ScheduleNext;
20use ironflow_store::store::Store;
21use tokio::time::interval;
22use tokio_util::sync::CancellationToken;
23use tracing::{debug, error, info, warn};
24use uuid::Uuid;
25
26#[cfg(feature = "prometheus")]
27use ironflow_core::metric_names::SCHEDULE_FIRE_ERRORS_TOTAL;
28#[cfg(feature = "prometheus")]
29use metrics::counter;
30
31/// Compute the next trigger time from a cron expression (5-field standard format).
32pub(crate) fn next_trigger(cron_str: &str) -> Result<Option<DateTime<Utc>>, String> {
33    let mut cron = Cron::new(cron_str);
34    cron.pattern.with_seconds_optional = true;
35    let cron = cron
36        .parse()
37        .map_err(|e| format!("invalid cron expression: {e}"))?;
38    let next = cron
39        .find_next_occurrence(&Utc::now(), false)
40        .map_err(|e| format!("cannot compute next trigger: {e}"))?;
41    Ok(Some(next))
42}
43
44/// What a schedule with this cron expression does after firing: fire again at
45/// its next occurrence, or be disabled when that occurrence cannot be computed.
46pub(crate) fn schedule_next(cron_str: &str) -> ScheduleNext {
47    match next_trigger(cron_str) {
48        Ok(Some(at)) => ScheduleNext::At(at),
49        Ok(None) => ScheduleNext::Disable {
50            error: "cannot compute next trigger: no next occurrence".to_string(),
51        },
52        Err(error) => ScheduleNext::Disable { error },
53    }
54}
55
56/// Count a schedule that failed to fire or was disabled by an error.
57fn record_fire_error(schedule_id: Uuid) {
58    #[cfg(feature = "prometheus")]
59    counter!(SCHEDULE_FIRE_ERRORS_TOTAL, "schedule" => schedule_id.to_string()).increment(1);
60    #[cfg(not(feature = "prometheus"))]
61    let _ = schedule_id;
62}
63
64/// How often the ticker checks for due schedules.
65pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
66
67/// Periodic task that fires DB-persisted schedules.
68///
69/// # Examples
70///
71/// ```no_run
72/// use std::sync::Arc;
73/// use std::time::Duration;
74/// use ironflow_api::schedule_ticker::ScheduleTicker;
75/// use ironflow_api::schedule_sync::sync_handler_schedules;
76/// use ironflow_engine::engine::Engine;
77/// use ironflow_core::providers::claude::ClaudeCodeProvider;
78/// use ironflow_store::memory::InMemoryStore;
79/// use ironflow_store::store::Store;
80/// use tokio_util::sync::CancellationToken;
81///
82/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
83/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
84/// let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
85/// sync_handler_schedules(&engine, store.as_ref()).await?;
86///
87/// let ticker = ScheduleTicker::new(store).interval(Duration::from_secs(10));
88/// tokio::spawn(ticker.run(CancellationToken::new()));
89/// # Ok(())
90/// # }
91/// ```
92pub struct ScheduleTicker {
93    store: Arc<dyn Store>,
94    interval: Duration,
95}
96
97impl ScheduleTicker {
98    /// Create a ticker with the default interval.
99    pub fn new(store: Arc<dyn Store>) -> Self {
100        Self {
101            store,
102            interval: DEFAULT_TICK_INTERVAL,
103        }
104    }
105
106    /// Set how often due schedules are polled.
107    pub fn interval(mut self, interval: Duration) -> Self {
108        self.interval = interval;
109        self
110    }
111
112    /// Run the tick loop until `shutdown` is cancelled.
113    ///
114    /// Store errors are logged per-schedule; the loop keeps going.
115    pub async fn run(self, shutdown: CancellationToken) {
116        let mut ticker = interval(self.interval);
117        ticker.tick().await;
118
119        info!(
120            interval_secs = self.interval.as_secs(),
121            "schedule ticker started"
122        );
123
124        loop {
125            tokio::select! {
126                _ = shutdown.cancelled() => {
127                    info!("schedule ticker stopped");
128                    return;
129                }
130                _ = ticker.tick() => {
131                    self.tick().await;
132                }
133            }
134        }
135    }
136
137    /// Fire every due schedule once.
138    ///
139    /// Each occurrence is fired atomically: a schedule whose firing fails
140    /// stays due and is retried on the next tick. A schedule whose next
141    /// occurrence cannot be computed fires its due run, then is disabled with
142    /// the reason in `last_error`.
143    ///
144    /// Exposed for tests and for callers that drive the tick themselves.
145    pub async fn tick(&self) {
146        let due = match self.store.list_due_schedules().await {
147            Ok(due) => due,
148            Err(err) => {
149                error!(error = %err, "failed to list due schedules");
150                return;
151            }
152        };
153
154        if due.is_empty() {
155            return;
156        }
157
158        info!(count = due.len(), "firing due schedules");
159
160        for schedule in due {
161            let Some(occurrence) = schedule.next_trigger_at else {
162                continue;
163            };
164            let next = schedule_next(&schedule.cron_expression);
165
166            match self
167                .store
168                .fire_due_schedule(schedule.id, occurrence, next.clone())
169                .await
170            {
171                Ok(Some(firing)) => {
172                    info!(
173                        schedule_id = %schedule.id,
174                        workflow = %schedule.workflow_name,
175                        run_id = %firing.run.run().id,
176                        replayed = !firing.run.is_created(),
177                        "schedule fired"
178                    );
179                    if let ScheduleNext::Disable { error } = next {
180                        warn!(
181                            schedule_id = %schedule.id,
182                            workflow = %schedule.workflow_name,
183                            error = %error,
184                            "cannot compute next trigger, schedule disabled"
185                        );
186                        record_fire_error(schedule.id);
187                    }
188                }
189                Ok(None) => {
190                    debug!(
191                        schedule_id = %schedule.id,
192                        "schedule occurrence already fired or no longer due"
193                    );
194                }
195                Err(err) => {
196                    error!(
197                        schedule_id = %schedule.id,
198                        workflow = %schedule.workflow_name,
199                        error = %err,
200                        "failed to fire schedule, it stays due and is retried next tick"
201                    );
202                    record_fire_error(schedule.id);
203                }
204            }
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use chrono::{Duration as ChronoDuration, Utc};
212    use ironflow_store::entities::{
213        NewSchedule, RunFilter, Schedule, ScheduleSource, ScheduleUpdate,
214    };
215    use ironflow_store::memory::InMemoryStore;
216    use ironflow_store::store::Store;
217    #[cfg(feature = "prometheus")]
218    use metrics::with_local_recorder;
219    #[cfg(feature = "prometheus")]
220    use metrics_exporter_prometheus::PrometheusBuilder;
221    use serde_json::json;
222    use std::sync::Arc;
223    #[cfg(feature = "prometheus")]
224    use tokio::runtime::Builder;
225    use uuid::Uuid;
226
227    use super::*;
228
229    async fn store_with_due_schedule(cron: &str) -> (Arc<dyn Store>, Schedule) {
230        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
231        let schedule = store
232            .create_schedule(NewSchedule {
233                workflow_name: "deploy".to_string(),
234                cron_expression: cron.to_string(),
235                inputs: json!({}),
236                source: ScheduleSource::Api,
237                created_by_user_id: Some(Uuid::now_v7()),
238                next_trigger_at: Some(Utc::now() - ChronoDuration::seconds(10)),
239            })
240            .await
241            .expect("create schedule");
242        (store, schedule)
243    }
244
245    async fn make_store_with_due_schedule() -> (Arc<dyn Store>, Uuid) {
246        let (store, schedule) = store_with_due_schedule("* * * * *").await;
247        (store, schedule.id)
248    }
249
250    async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
251        store
252            .find_schedule_by_id(id)
253            .await
254            .expect("find")
255            .expect("exists")
256    }
257
258    #[tokio::test]
259    async fn tick_creates_run_for_due_schedule() {
260        let (store, schedule_id) = make_store_with_due_schedule().await;
261        let ticker = ScheduleTicker::new(store.clone());
262
263        ticker.tick().await;
264
265        let runs = store
266            .list_runs(RunFilter::default(), 1, 10)
267            .await
268            .expect("list runs");
269        assert_eq!(runs.items.len(), 1);
270        assert_eq!(runs.items[0].workflow_name, "deploy");
271
272        let updated = reload(&store, schedule_id).await;
273        assert!(updated.last_triggered_at.is_some());
274        assert!(updated.next_trigger_at.is_some());
275        assert!(updated.next_trigger_at.unwrap() > Utc::now() - ChronoDuration::seconds(1));
276    }
277
278    #[tokio::test]
279    async fn tick_run_carries_the_occurrence_key() {
280        let (store, schedule) = store_with_due_schedule("* * * * *").await;
281        let occurrence = schedule.next_trigger_at.expect("due");
282
283        ScheduleTicker::new(store.clone()).tick().await;
284
285        let runs = store
286            .list_runs(RunFilter::default(), 1, 10)
287            .await
288            .expect("list runs");
289        assert_eq!(runs.items.len(), 1);
290        assert_eq!(
291            runs.items[0].idempotency_key.as_deref(),
292            Some(Schedule::occurrence_key(schedule.id, occurrence).as_str())
293        );
294    }
295
296    /// #173: an instance that saw the schedule due and stopped before firing
297    /// it used to leave it active with no next trigger, never fired again.
298    #[tokio::test]
299    async fn tick_fires_a_schedule_an_interrupted_tick_left_due() {
300        let (store, schedule_id) = make_store_with_due_schedule().await;
301        // A first instance lists the due schedules, then stops before firing.
302        let seen = store.list_due_schedules().await.expect("list due");
303        assert_eq!(seen.len(), 1);
304
305        ScheduleTicker::new(store.clone()).tick().await;
306
307        let s = reload(&store, schedule_id).await;
308        assert!(
309            !(s.is_active() && s.next_trigger_at.is_none()),
310            "active schedule without next trigger: {s:?}"
311        );
312        let runs = store
313            .list_runs(RunFilter::default(), 1, 10)
314            .await
315            .expect("list runs");
316        assert_eq!(runs.items.len(), 1);
317    }
318
319    /// #173: a cron expression whose next occurrence cannot be computed used
320    /// to leave the schedule active with no next trigger.
321    #[tokio::test]
322    async fn tick_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
323        // February 30th never happens.
324        let (store, schedule) = store_with_due_schedule("0 0 30 2 *").await;
325
326        ScheduleTicker::new(store.clone()).tick().await;
327
328        let s = reload(&store, schedule.id).await;
329        assert!(
330            !(s.is_active() && s.next_trigger_at.is_none()),
331            "active schedule without next trigger: {s:?}"
332        );
333        assert!(!s.is_active());
334        let error = s.last_error.expect("disable reason stored");
335        assert!(error.contains("cannot compute next trigger"), "{error}");
336
337        // The due occurrence still ran.
338        let runs = store
339            .list_runs(RunFilter::default(), 1, 10)
340            .await
341            .expect("list runs");
342        assert_eq!(runs.items.len(), 1);
343    }
344
345    #[tokio::test]
346    async fn tick_disables_a_schedule_whose_cron_no_longer_parses() {
347        let (store, schedule) = store_with_due_schedule("not-a-cron").await;
348
349        ScheduleTicker::new(store.clone()).tick().await;
350
351        let s = reload(&store, schedule.id).await;
352        assert!(!s.is_active());
353        assert!(s.next_trigger_at.is_none());
354        let error = s.last_error.expect("disable reason stored");
355        assert!(error.contains("invalid cron expression"), "{error}");
356    }
357
358    #[test]
359    fn schedule_next_for_a_valid_cron_is_in_the_future() {
360        match schedule_next("* * * * *") {
361            ScheduleNext::At(at) => assert!(at > Utc::now()),
362            ScheduleNext::Disable { error } => panic!("unexpected disable: {error}"),
363        }
364    }
365
366    #[tokio::test]
367    async fn tick_skips_disabled_schedule() {
368        let (store, schedule_id) = make_store_with_due_schedule().await;
369
370        store
371            .update_schedule(
372                schedule_id,
373                ScheduleUpdate {
374                    disabled_at: Some(Some(Utc::now())),
375                    ..Default::default()
376                },
377            )
378            .await
379            .expect("disable");
380
381        let ticker = ScheduleTicker::new(store.clone());
382        ticker.tick().await;
383
384        let runs = store
385            .list_runs(RunFilter::default(), 1, 10)
386            .await
387            .expect("list runs");
388        assert!(runs.items.is_empty());
389    }
390
391    #[tokio::test]
392    async fn tick_skips_future_schedule() {
393        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
394        store
395            .create_schedule(NewSchedule {
396                workflow_name: "deploy".to_string(),
397                cron_expression: "* * * * *".to_string(),
398                inputs: json!({}),
399                source: ScheduleSource::Api,
400                created_by_user_id: Some(Uuid::now_v7()),
401                next_trigger_at: Some(Utc::now() + ChronoDuration::hours(1)),
402            })
403            .await
404            .expect("create");
405
406        let ticker = ScheduleTicker::new(store.clone());
407        ticker.tick().await;
408
409        let runs = store
410            .list_runs(RunFilter::default(), 1, 10)
411            .await
412            .expect("list runs");
413        assert!(runs.items.is_empty());
414    }
415
416    #[tokio::test]
417    async fn tick_does_nothing_when_empty() {
418        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
419        let ticker = ScheduleTicker::new(store.clone());
420        ticker.tick().await;
421
422        let runs = store
423            .list_runs(RunFilter::default(), 1, 10)
424            .await
425            .expect("list runs");
426        assert!(runs.items.is_empty());
427    }
428
429    #[tokio::test]
430    async fn tick_does_not_double_fire() {
431        let (store, _) = make_store_with_due_schedule().await;
432        let ticker = ScheduleTicker::new(store.clone());
433
434        ticker.tick().await;
435        ticker.tick().await;
436
437        let runs = store
438            .list_runs(RunFilter::default(), 1, 10)
439            .await
440            .expect("list runs");
441        assert_eq!(
442            runs.items.len(),
443            1,
444            "second tick must not create a duplicate run"
445        );
446    }
447
448    #[cfg(feature = "prometheus")]
449    #[test]
450    fn tick_counts_fire_errors_per_schedule() {
451        let recorder = PrometheusBuilder::new().build_recorder();
452        let handle = recorder.handle();
453        let runtime = Builder::new_current_thread()
454            .enable_all()
455            .build()
456            .expect("runtime");
457
458        let (broken, healthy) = with_local_recorder(&recorder, || {
459            runtime.block_on(async {
460                let (store, broken) = store_with_due_schedule("0 0 30 2 *").await;
461                let healthy = store
462                    .create_schedule(NewSchedule {
463                        workflow_name: "deploy".to_string(),
464                        cron_expression: "* * * * *".to_string(),
465                        inputs: json!({}),
466                        source: ScheduleSource::Api,
467                        created_by_user_id: None,
468                        next_trigger_at: Some(Utc::now() - ChronoDuration::seconds(10)),
469                    })
470                    .await
471                    .expect("create healthy");
472                ScheduleTicker::new(store).tick().await;
473                (broken.id, healthy.id)
474            })
475        });
476
477        let rendered = handle.render();
478        assert!(
479            rendered.contains(&format!(
480                "ironflow_schedule_fire_errors_total{{schedule=\"{broken}\"}} 1"
481            )),
482            "{rendered}"
483        );
484        assert!(!rendered.contains(&healthy.to_string()), "{rendered}");
485    }
486}