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