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 concurrency_limits: Vec::new(),
52 max_cost_usd: None,
53 }
54}
55
56pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(15);
58
59pub struct ScheduleTicker {
85 store: Arc<dyn Store>,
86 interval: Duration,
87}
88
89impl ScheduleTicker {
90 pub fn new(store: Arc<dyn Store>) -> Self {
92 Self {
93 store,
94 interval: DEFAULT_TICK_INTERVAL,
95 }
96 }
97
98 pub fn interval(mut self, interval: Duration) -> Self {
100 self.interval = interval;
101 self
102 }
103
104 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 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}