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