1use 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
21pub(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
36pub(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
55pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
57
58pub struct ScheduleTicker {
84 store: Arc<dyn Store>,
85 interval: Duration,
86}
87
88impl ScheduleTicker {
89 pub fn new(store: Arc<dyn Store>) -> Self {
91 Self {
92 store,
93 interval: DEFAULT_TICK_INTERVAL,
94 }
95 }
96
97 pub fn interval(mut self, interval: Duration) -> Self {
99 self.interval = interval;
100 self
101 }
102
103 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 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}