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