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                priority: 0,
238                created_by_user_id: Some(Uuid::now_v7()),
239                next_trigger_at: Some(Utc::now() - ChronoDuration::seconds(10)),
240            })
241            .await
242            .expect("create schedule");
243        (store, schedule)
244    }
245
246    async fn make_store_with_due_schedule() -> (Arc<dyn Store>, Uuid) {
247        let (store, schedule) = store_with_due_schedule("* * * * *").await;
248        (store, schedule.id)
249    }
250
251    async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
252        store
253            .find_schedule_by_id(id)
254            .await
255            .expect("find")
256            .expect("exists")
257    }
258
259    #[tokio::test]
260    async fn tick_creates_run_for_due_schedule() {
261        let (store, schedule_id) = make_store_with_due_schedule().await;
262        let ticker = ScheduleTicker::new(store.clone());
263
264        ticker.tick().await;
265
266        let runs = store
267            .list_runs(RunFilter::default(), 1, 10)
268            .await
269            .expect("list runs");
270        assert_eq!(runs.items.len(), 1);
271        assert_eq!(runs.items[0].workflow_name, "deploy");
272
273        let updated = reload(&store, schedule_id).await;
274        assert!(updated.last_triggered_at.is_some());
275        assert!(updated.next_trigger_at.is_some());
276        assert!(updated.next_trigger_at.unwrap() > Utc::now() - ChronoDuration::seconds(1));
277    }
278
279    #[tokio::test]
280    async fn tick_run_carries_the_occurrence_key() {
281        let (store, schedule) = store_with_due_schedule("* * * * *").await;
282        let occurrence = schedule.next_trigger_at.expect("due");
283
284        ScheduleTicker::new(store.clone()).tick().await;
285
286        let runs = store
287            .list_runs(RunFilter::default(), 1, 10)
288            .await
289            .expect("list runs");
290        assert_eq!(runs.items.len(), 1);
291        assert_eq!(
292            runs.items[0].idempotency_key.as_deref(),
293            Some(Schedule::occurrence_key(schedule.id, occurrence).as_str())
294        );
295    }
296
297    /// #173: an instance that saw the schedule due and stopped before firing
298    /// it used to leave it active with no next trigger, never fired again.
299    #[tokio::test]
300    async fn tick_fires_a_schedule_an_interrupted_tick_left_due() {
301        let (store, schedule_id) = make_store_with_due_schedule().await;
302        // A first instance lists the due schedules, then stops before firing.
303        let seen = store.list_due_schedules().await.expect("list due");
304        assert_eq!(seen.len(), 1);
305
306        ScheduleTicker::new(store.clone()).tick().await;
307
308        let s = reload(&store, schedule_id).await;
309        assert!(
310            !(s.is_active() && s.next_trigger_at.is_none()),
311            "active schedule without next trigger: {s:?}"
312        );
313        let runs = store
314            .list_runs(RunFilter::default(), 1, 10)
315            .await
316            .expect("list runs");
317        assert_eq!(runs.items.len(), 1);
318    }
319
320    /// #173: a cron expression whose next occurrence cannot be computed used
321    /// to leave the schedule active with no next trigger.
322    #[tokio::test]
323    async fn tick_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
324        // February 30th never happens.
325        let (store, schedule) = store_with_due_schedule("0 0 30 2 *").await;
326
327        ScheduleTicker::new(store.clone()).tick().await;
328
329        let s = reload(&store, schedule.id).await;
330        assert!(
331            !(s.is_active() && s.next_trigger_at.is_none()),
332            "active schedule without next trigger: {s:?}"
333        );
334        assert!(!s.is_active());
335        let error = s.last_error.expect("disable reason stored");
336        assert!(error.contains("cannot compute next trigger"), "{error}");
337
338        // The due occurrence still ran.
339        let runs = store
340            .list_runs(RunFilter::default(), 1, 10)
341            .await
342            .expect("list runs");
343        assert_eq!(runs.items.len(), 1);
344    }
345
346    #[tokio::test]
347    async fn tick_disables_a_schedule_whose_cron_no_longer_parses() {
348        let (store, schedule) = store_with_due_schedule("not-a-cron").await;
349
350        ScheduleTicker::new(store.clone()).tick().await;
351
352        let s = reload(&store, schedule.id).await;
353        assert!(!s.is_active());
354        assert!(s.next_trigger_at.is_none());
355        let error = s.last_error.expect("disable reason stored");
356        assert!(error.contains("invalid cron expression"), "{error}");
357    }
358
359    #[test]
360    fn schedule_next_for_a_valid_cron_is_in_the_future() {
361        match schedule_next("* * * * *") {
362            ScheduleNext::At(at) => assert!(at > Utc::now()),
363            ScheduleNext::Disable { error } => panic!("unexpected disable: {error}"),
364        }
365    }
366
367    #[tokio::test]
368    async fn tick_skips_disabled_schedule() {
369        let (store, schedule_id) = make_store_with_due_schedule().await;
370
371        store
372            .update_schedule(
373                schedule_id,
374                ScheduleUpdate {
375                    disabled_at: Some(Some(Utc::now())),
376                    ..Default::default()
377                },
378            )
379            .await
380            .expect("disable");
381
382        let ticker = ScheduleTicker::new(store.clone());
383        ticker.tick().await;
384
385        let runs = store
386            .list_runs(RunFilter::default(), 1, 10)
387            .await
388            .expect("list runs");
389        assert!(runs.items.is_empty());
390    }
391
392    #[tokio::test]
393    async fn tick_skips_future_schedule() {
394        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
395        store
396            .create_schedule(NewSchedule {
397                workflow_name: "deploy".to_string(),
398                cron_expression: "* * * * *".to_string(),
399                inputs: json!({}),
400                source: ScheduleSource::Api,
401                priority: 0,
402                created_by_user_id: Some(Uuid::now_v7()),
403                next_trigger_at: Some(Utc::now() + ChronoDuration::hours(1)),
404            })
405            .await
406            .expect("create");
407
408        let ticker = ScheduleTicker::new(store.clone());
409        ticker.tick().await;
410
411        let runs = store
412            .list_runs(RunFilter::default(), 1, 10)
413            .await
414            .expect("list runs");
415        assert!(runs.items.is_empty());
416    }
417
418    #[tokio::test]
419    async fn tick_does_nothing_when_empty() {
420        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
421        let ticker = ScheduleTicker::new(store.clone());
422        ticker.tick().await;
423
424        let runs = store
425            .list_runs(RunFilter::default(), 1, 10)
426            .await
427            .expect("list runs");
428        assert!(runs.items.is_empty());
429    }
430
431    #[tokio::test]
432    async fn tick_does_not_double_fire() {
433        let (store, _) = make_store_with_due_schedule().await;
434        let ticker = ScheduleTicker::new(store.clone());
435
436        ticker.tick().await;
437        ticker.tick().await;
438
439        let runs = store
440            .list_runs(RunFilter::default(), 1, 10)
441            .await
442            .expect("list runs");
443        assert_eq!(
444            runs.items.len(),
445            1,
446            "second tick must not create a duplicate run"
447        );
448    }
449
450    #[cfg(feature = "prometheus")]
451    #[test]
452    fn tick_counts_fire_errors_per_schedule() {
453        let recorder = PrometheusBuilder::new().build_recorder();
454        let handle = recorder.handle();
455        let runtime = Builder::new_current_thread()
456            .enable_all()
457            .build()
458            .expect("runtime");
459
460        let (broken, healthy) = with_local_recorder(&recorder, || {
461            runtime.block_on(async {
462                let (store, broken) = store_with_due_schedule("0 0 30 2 *").await;
463                let healthy = store
464                    .create_schedule(NewSchedule {
465                        workflow_name: "deploy".to_string(),
466                        cron_expression: "* * * * *".to_string(),
467                        inputs: json!({}),
468                        source: ScheduleSource::Api,
469                        priority: 0,
470                        created_by_user_id: None,
471                        next_trigger_at: Some(Utc::now() - ChronoDuration::seconds(10)),
472                    })
473                    .await
474                    .expect("create healthy");
475                ScheduleTicker::new(store).tick().await;
476                (broken.id, healthy.id)
477            })
478        });
479
480        let rendered = handle.render();
481        assert!(
482            rendered.contains(&format!(
483                "ironflow_schedule_fire_errors_total{{schedule=\"{broken}\"}} 1"
484            )),
485            "{rendered}"
486        );
487        assert!(!rendered.contains(&healthy.to_string()), "{rendered}");
488    }
489}