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 [`claim_due_schedules`] and creates a run for each claimed
8//! schedule.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::Duration;
13
14use croner::Cron;
15use ironflow_store::entities::{NewRun, RunActor, Schedule, ScheduleUpdate, TriggerKind};
16use ironflow_store::store::Store;
17use tokio::time::interval;
18use tokio_util::sync::CancellationToken;
19use tracing::{error, info, warn};
20
21/// Compute the next trigger time from a cron expression (5-field standard format).
22pub(crate) fn next_trigger(
23    cron_str: &str,
24) -> Result<Option<chrono::DateTime<chrono::Utc>>, String> {
25    let mut cron = Cron::new(cron_str);
26    cron.pattern.with_seconds_optional = true;
27    let cron = cron
28        .parse()
29        .map_err(|e| format!("invalid cron expression: {e}"))?;
30    let next = cron
31        .find_next_occurrence(&chrono::Utc::now(), false)
32        .map_err(|e| format!("cannot compute next trigger: {e}"))?;
33    Ok(Some(next))
34}
35
36/// Build a [`NewRun`] from a schedule's fields.
37pub(crate) fn new_run_from_schedule(schedule: &Schedule, created_by: Option<RunActor>) -> NewRun {
38    NewRun {
39        workflow_name: schedule.workflow_name.clone(),
40        trigger: TriggerKind::Cron {
41            schedule: schedule.cron_expression.clone(),
42        },
43        payload: schedule.inputs.clone(),
44        max_retries: 0,
45        handler_version: None,
46        labels: HashMap::new(),
47        scheduled_at: None,
48        created_by,
49        idempotency_key: None,
50        concurrency_key: None,
51        max_cost_usd: None,
52    }
53}
54
55/// How often the ticker checks for due schedules.
56pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
57
58/// Periodic task that fires DB-persisted schedules.
59///
60/// # Examples
61///
62/// ```no_run
63/// use std::sync::Arc;
64/// use std::time::Duration;
65/// use ironflow_api::schedule_ticker::ScheduleTicker;
66/// use ironflow_api::schedule_sync::sync_handler_schedules;
67/// use ironflow_engine::engine::Engine;
68/// use ironflow_core::providers::claude::ClaudeCodeProvider;
69/// use ironflow_store::memory::InMemoryStore;
70/// use ironflow_store::store::Store;
71/// use tokio_util::sync::CancellationToken;
72///
73/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
74/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
75/// let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
76/// sync_handler_schedules(&engine, store.as_ref()).await?;
77///
78/// let ticker = ScheduleTicker::new(store).interval(Duration::from_secs(10));
79/// tokio::spawn(ticker.run(CancellationToken::new()));
80/// # Ok(())
81/// # }
82/// ```
83pub struct ScheduleTicker {
84    store: Arc<dyn Store>,
85    interval: Duration,
86}
87
88impl ScheduleTicker {
89    /// Create a ticker with the default interval.
90    pub fn new(store: Arc<dyn Store>) -> Self {
91        Self {
92            store,
93            interval: DEFAULT_TICK_INTERVAL,
94        }
95    }
96
97    /// Set how often due schedules are polled.
98    pub fn interval(mut self, interval: Duration) -> Self {
99        self.interval = interval;
100        self
101    }
102
103    /// Run the tick loop until `shutdown` is cancelled.
104    ///
105    /// Store errors are logged per-schedule; the loop keeps going.
106    pub async fn run(self, shutdown: CancellationToken) {
107        let mut ticker = interval(self.interval);
108        ticker.tick().await;
109
110        info!(
111            interval_secs = self.interval.as_secs(),
112            "schedule ticker started"
113        );
114
115        loop {
116            tokio::select! {
117                _ = shutdown.cancelled() => {
118                    info!("schedule ticker stopped");
119                    return;
120                }
121                _ = ticker.tick() => {
122                    self.tick().await;
123                }
124            }
125        }
126    }
127
128    /// Process one batch of due schedules.
129    ///
130    /// Exposed for tests and for callers that drive the tick themselves.
131    pub async fn tick(&self) {
132        let claimed = match self.store.claim_due_schedules().await {
133            Ok(claimed) => claimed,
134            Err(err) => {
135                error!(error = %err, "failed to claim due schedules");
136                return;
137            }
138        };
139
140        if claimed.is_empty() {
141            return;
142        }
143
144        info!(count = claimed.len(), "firing due schedules");
145
146        for schedule in claimed {
147            let run_result = self
148                .store
149                .create_run(new_run_from_schedule(&schedule, None))
150                .await;
151
152            match run_result {
153                Ok(creation) => {
154                    let run = creation.into_run();
155                    info!(
156                        schedule_id = %schedule.id,
157                        workflow = %schedule.workflow_name,
158                        run_id = %run.id,
159                        "schedule fired"
160                    );
161                }
162                Err(err) => {
163                    error!(
164                        schedule_id = %schedule.id,
165                        workflow = %schedule.workflow_name,
166                        error = %err,
167                        "failed to create run for schedule"
168                    );
169                    continue;
170                }
171            }
172
173            let next = match next_trigger(&schedule.cron_expression) {
174                Ok(next) => next,
175                Err(err) => {
176                    warn!(
177                        schedule_id = %schedule.id,
178                        error = %err,
179                        "cannot compute next trigger, schedule will remain paused"
180                    );
181                    None
182                }
183            };
184
185            if let Err(err) = self
186                .store
187                .update_schedule(
188                    schedule.id,
189                    ScheduleUpdate {
190                        next_trigger_at: Some(next),
191                        ..Default::default()
192                    },
193                )
194                .await
195            {
196                error!(
197                    schedule_id = %schedule.id,
198                    error = %err,
199                    "failed to set next trigger time after firing"
200                );
201            }
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use chrono::Utc;
209    use ironflow_store::entities::{NewSchedule, RunFilter, ScheduleSource};
210    use ironflow_store::memory::InMemoryStore;
211    use ironflow_store::store::Store;
212    use serde_json::json;
213    use std::sync::Arc;
214    use uuid::Uuid;
215
216    use super::*;
217
218    async fn make_store_with_due_schedule() -> (Arc<dyn Store>, Uuid) {
219        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
220        let schedule = store
221            .create_schedule(NewSchedule {
222                workflow_name: "deploy".to_string(),
223                cron_expression: "* * * * *".to_string(),
224                inputs: json!({}),
225                source: ScheduleSource::Api,
226                created_by_user_id: Some(Uuid::now_v7()),
227                next_trigger_at: Some(Utc::now() - chrono::Duration::seconds(10)),
228            })
229            .await
230            .expect("create schedule");
231        (store, schedule.id)
232    }
233
234    #[tokio::test]
235    async fn tick_creates_run_for_due_schedule() {
236        let (store, schedule_id) = make_store_with_due_schedule().await;
237        let ticker = ScheduleTicker::new(store.clone());
238
239        ticker.tick().await;
240
241        let runs = store
242            .list_runs(RunFilter::default(), 1, 10)
243            .await
244            .expect("list runs");
245        assert_eq!(runs.items.len(), 1);
246        assert_eq!(runs.items[0].workflow_name, "deploy");
247
248        let updated = store
249            .find_schedule_by_id(schedule_id)
250            .await
251            .expect("find")
252            .expect("exists");
253        assert!(updated.last_triggered_at.is_some());
254        assert!(updated.next_trigger_at.is_some());
255        assert!(updated.next_trigger_at.unwrap() > Utc::now() - chrono::Duration::seconds(1));
256    }
257
258    #[tokio::test]
259    async fn tick_skips_disabled_schedule() {
260        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
261        let schedule = store
262            .create_schedule(NewSchedule {
263                workflow_name: "deploy".to_string(),
264                cron_expression: "* * * * *".to_string(),
265                inputs: json!({}),
266                source: ScheduleSource::Api,
267                created_by_user_id: Some(Uuid::now_v7()),
268                next_trigger_at: Some(Utc::now() - chrono::Duration::seconds(10)),
269            })
270            .await
271            .expect("create");
272
273        store
274            .update_schedule(
275                schedule.id,
276                ScheduleUpdate {
277                    disabled_at: Some(Some(Utc::now())),
278                    ..Default::default()
279                },
280            )
281            .await
282            .expect("disable");
283
284        let ticker = ScheduleTicker::new(store.clone());
285        ticker.tick().await;
286
287        let runs = store
288            .list_runs(RunFilter::default(), 1, 10)
289            .await
290            .expect("list runs");
291        assert!(runs.items.is_empty());
292    }
293
294    #[tokio::test]
295    async fn tick_skips_future_schedule() {
296        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
297        store
298            .create_schedule(NewSchedule {
299                workflow_name: "deploy".to_string(),
300                cron_expression: "* * * * *".to_string(),
301                inputs: json!({}),
302                source: ScheduleSource::Api,
303                created_by_user_id: Some(Uuid::now_v7()),
304                next_trigger_at: Some(Utc::now() + chrono::Duration::hours(1)),
305            })
306            .await
307            .expect("create");
308
309        let ticker = ScheduleTicker::new(store.clone());
310        ticker.tick().await;
311
312        let runs = store
313            .list_runs(RunFilter::default(), 1, 10)
314            .await
315            .expect("list runs");
316        assert!(runs.items.is_empty());
317    }
318
319    #[tokio::test]
320    async fn tick_does_nothing_when_empty() {
321        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
322        let ticker = ScheduleTicker::new(store.clone());
323        ticker.tick().await;
324
325        let runs = store
326            .list_runs(RunFilter::default(), 1, 10)
327            .await
328            .expect("list runs");
329        assert!(runs.items.is_empty());
330    }
331
332    #[tokio::test]
333    async fn tick_does_not_double_fire() {
334        let (store, _) = make_store_with_due_schedule().await;
335        let ticker = ScheduleTicker::new(store.clone());
336
337        ticker.tick().await;
338        ticker.tick().await;
339
340        let runs = store
341            .list_runs(RunFilter::default(), 1, 10)
342            .await
343            .expect("list runs");
344        assert_eq!(
345            runs.items.len(),
346            1,
347            "second tick must not create a duplicate run"
348        );
349    }
350}