1use std::collections::{HashMap, HashSet};
8
9use chrono::Utc;
10use ironflow_engine::engine::Engine;
11use ironflow_store::entities::{
12 MAX_PRIORITY, MIN_PRIORITY, NewSchedule, Schedule, ScheduleNext, ScheduleSource, ScheduleUpdate,
13};
14use ironflow_store::error::StoreError;
15use ironflow_store::store::Store;
16use serde_json::json;
17use tracing::{info, warn};
18
19use crate::schedule_ticker::schedule_next;
20
21pub async fn sync_handler_schedules(engine: &Engine, store: &dyn Store) -> Result<(), StoreError> {
55 let handlers = engine.scheduled_handlers();
56 let handler_map: HashMap<&str, (&str, i16)> = handlers
59 .iter()
60 .map(|(name, cron)| {
61 let priority = engine.get_handler(name).map_or(0, |handler| {
62 handler.priority().clamp(MIN_PRIORITY, MAX_PRIORITY)
63 });
64 (*name, (cron.as_str(), priority))
65 })
66 .collect();
67 let handler_names: HashSet<&str> = handler_map.keys().copied().collect();
68
69 let existing = store.list_schedules(1, 1000).await?;
70
71 let handler_schedules: Vec<_> = existing
72 .items
73 .iter()
74 .filter(|s| s.source == ScheduleSource::Handler)
75 .collect();
76
77 for schedule in &handler_schedules {
79 if !handler_names.contains(schedule.workflow_name.as_str()) {
80 store.delete_schedule(schedule.id).await?;
81 info!(
82 workflow = %schedule.workflow_name,
83 "removed orphan handler schedule (handler no longer exists)"
84 );
85 }
86 }
87
88 let existing_map: HashMap<&str, &Schedule> = handler_schedules
90 .iter()
91 .filter(|s| handler_names.contains(s.workflow_name.as_str()))
92 .map(|s| (s.workflow_name.as_str(), *s))
93 .collect();
94
95 for (name, (cron_str, priority)) in &handler_map {
96 match existing_map.get(name) {
97 Some(existing) => {
98 let cron_changed = existing.cron_expression != *cron_str;
99 let priority_changed = existing.priority != *priority;
100 if !cron_changed && !priority_changed {
101 continue;
102 }
103 let mut update = ScheduleUpdate::default();
104 if cron_changed {
105 let next = schedule_next(cron_str);
108 let reenable =
109 existing.last_error.is_some() && matches!(next, ScheduleNext::At(_));
110 update = next_trigger_update(next);
111 update.cron_expression = Some(cron_str.to_string());
112 if reenable {
113 update.disabled_at = Some(None);
114 update.last_error = Some(None);
115 }
116 }
117 if priority_changed {
118 update.priority = Some(*priority);
119 }
120 store.update_schedule(existing.id, update).await?;
121 info!(
122 workflow = %name,
123 old_cron = %existing.cron_expression,
124 new_cron = %cron_str,
125 old_priority = existing.priority,
126 new_priority = *priority,
127 "updated handler schedule"
128 );
129 }
130 None => {
131 let next = schedule_next(cron_str);
133 let next_trigger_at = match next {
134 ScheduleNext::At(at) => Some(at),
135 ScheduleNext::Disable { .. } => None,
136 };
137 let created = store
138 .create_schedule(NewSchedule {
139 workflow_name: name.to_string(),
140 cron_expression: cron_str.to_string(),
141 inputs: json!({}),
142 source: ScheduleSource::Handler,
143 priority: *priority,
144 created_by_user_id: None,
145 next_trigger_at,
146 })
147 .await?;
148 if let ScheduleNext::Disable { error } = next {
149 warn!(
150 workflow = %name,
151 schedule = %cron_str,
152 error = %error,
153 "cannot compute next trigger, handler schedule disabled"
154 );
155 store
156 .update_schedule(
157 created.id,
158 next_trigger_update(ScheduleNext::Disable { error }),
159 )
160 .await?;
161 }
162 info!(
163 workflow = %name,
164 schedule = %cron_str,
165 "synced handler-declared schedule to DB"
166 );
167 }
168 }
169 }
170
171 Ok(())
172}
173
174fn next_trigger_update(next: ScheduleNext) -> ScheduleUpdate {
177 match next {
178 ScheduleNext::At(at) => ScheduleUpdate {
179 next_trigger_at: Some(Some(at)),
180 ..Default::default()
181 },
182 ScheduleNext::Disable { error } => ScheduleUpdate {
183 disabled_at: Some(Some(Utc::now())),
184 next_trigger_at: Some(None),
185 last_error: Some(Some(error)),
186 ..Default::default()
187 },
188 }
189}
190
191const REPAIR_PAGE_SIZE: u32 = 100;
193
194pub async fn repair_unscheduled_schedules(store: &dyn Store) -> Result<usize, StoreError> {
226 let mut broken: Vec<Schedule> = Vec::new();
227 let mut page = 1;
228 loop {
229 let batch = store.list_schedules(page, REPAIR_PAGE_SIZE).await?;
230 let read = (page as u64) * (REPAIR_PAGE_SIZE as u64);
231 let last = batch.items.is_empty() || read >= batch.total;
232 broken.extend(
233 batch
234 .items
235 .into_iter()
236 .filter(|s| s.is_active() && s.next_trigger_at.is_none()),
237 );
238 if last {
239 break;
240 }
241 page += 1;
242 }
243
244 for schedule in &broken {
245 let next = schedule_next(&schedule.cron_expression);
246 match &next {
247 ScheduleNext::At(at) => warn!(
248 schedule_id = %schedule.id,
249 workflow = %schedule.workflow_name,
250 next_trigger_at = %at,
251 "repaired active schedule without next trigger"
252 ),
253 ScheduleNext::Disable { error } => warn!(
254 schedule_id = %schedule.id,
255 workflow = %schedule.workflow_name,
256 error = %error,
257 "disabled active schedule without next trigger: cannot compute it"
258 ),
259 }
260 store
261 .update_schedule(schedule.id, next_trigger_update(next))
262 .await?;
263 }
264
265 Ok(broken.len())
266}
267
268#[cfg(test)]
269mod tests {
270 use ironflow_core::providers::claude::ClaudeCodeProvider;
271 use ironflow_engine::context::WorkflowContext;
272 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
273 use ironflow_engine::prelude::CronSchedule;
274 use ironflow_store::memory::InMemoryStore;
275 use std::sync::Arc;
276 use uuid::Uuid;
277
278 use super::*;
279
280 struct NamedScheduled {
281 wf_name: &'static str,
282 cron: CronSchedule,
283 }
284 impl WorkflowHandler for NamedScheduled {
285 fn name(&self) -> &str {
286 self.wf_name
287 }
288 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
289 Box::pin(async { Ok(()) })
290 }
291 fn schedule(&self) -> Option<&CronSchedule> {
292 Some(&self.cron)
293 }
294 }
295
296 #[tokio::test]
297 async fn creates_missing_handler_schedules() {
298 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
299 let provider = Arc::new(ClaudeCodeProvider::new());
300 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
301 engine
302 .register(NamedScheduled {
303 wf_name: "nightly-cleanup",
304 cron: CronSchedule::new("0 0 * * *").unwrap(),
305 })
306 .expect("register");
307
308 sync_handler_schedules(&engine, store.as_ref())
309 .await
310 .expect("sync");
311
312 let page = store.list_schedules(1, 10).await.expect("list");
313 assert_eq!(page.items.len(), 1);
314 assert_eq!(page.items[0].workflow_name, "nightly-cleanup");
315 assert_eq!(page.items[0].source, ScheduleSource::Handler);
316 }
317
318 #[tokio::test]
319 async fn skips_existing_handler_schedule() {
320 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
321 store
322 .create_schedule(NewSchedule {
323 workflow_name: "deploy".to_string(),
324 cron_expression: "*/5 * * * *".to_string(),
325 inputs: json!({"env": "prod"}),
326 source: ScheduleSource::Handler,
327 priority: 0,
328 created_by_user_id: None,
329 next_trigger_at: None,
330 })
331 .await
332 .expect("seed");
333
334 let provider = Arc::new(ClaudeCodeProvider::new());
335 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
336 engine
337 .register(NamedScheduled {
338 wf_name: "deploy",
339 cron: CronSchedule::new("*/5 * * * *").unwrap(),
340 })
341 .expect("register");
342
343 sync_handler_schedules(&engine, store.as_ref())
344 .await
345 .expect("sync");
346
347 let page = store.list_schedules(1, 10).await.expect("list");
348 assert_eq!(page.items.len(), 1);
349 assert_eq!(page.items[0].cron_expression, "*/5 * * * *");
350 }
351
352 #[tokio::test]
353 async fn removes_orphan_handler_schedules() {
354 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
355 store
356 .create_schedule(NewSchedule {
357 workflow_name: "removed-workflow".to_string(),
358 cron_expression: "0 0 * * *".to_string(),
359 inputs: json!({}),
360 source: ScheduleSource::Handler,
361 priority: 0,
362 created_by_user_id: None,
363 next_trigger_at: None,
364 })
365 .await
366 .expect("seed orphan");
367
368 store
370 .create_schedule(NewSchedule {
371 workflow_name: "user-created".to_string(),
372 cron_expression: "0 12 * * *".to_string(),
373 inputs: json!({}),
374 source: ScheduleSource::Api,
375 priority: 0,
376 created_by_user_id: Some(Uuid::now_v7()),
377 next_trigger_at: None,
378 })
379 .await
380 .expect("seed api");
381
382 let provider = Arc::new(ClaudeCodeProvider::new());
383 let engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
384
385 sync_handler_schedules(&engine, store.as_ref())
386 .await
387 .expect("sync");
388
389 let page = store.list_schedules(1, 10).await.expect("list");
390 assert_eq!(page.items.len(), 1);
391 assert_eq!(page.items[0].workflow_name, "user-created");
392 assert_eq!(page.items[0].source, ScheduleSource::Api);
393 }
394
395 #[tokio::test]
396 async fn updates_changed_cron() {
397 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
398 store
399 .create_schedule(NewSchedule {
400 workflow_name: "deploy".to_string(),
401 cron_expression: "0 0 * * *".to_string(),
402 inputs: json!({}),
403 source: ScheduleSource::Handler,
404 priority: 0,
405 created_by_user_id: None,
406 next_trigger_at: None,
407 })
408 .await
409 .expect("seed");
410
411 let provider = Arc::new(ClaudeCodeProvider::new());
412 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
413 engine
414 .register(NamedScheduled {
415 wf_name: "deploy",
416 cron: CronSchedule::new("0 */6 * * *").unwrap(),
417 })
418 .expect("register");
419
420 sync_handler_schedules(&engine, store.as_ref())
421 .await
422 .expect("sync");
423
424 let page = store.list_schedules(1, 10).await.expect("list");
425 assert_eq!(page.items.len(), 1);
426 assert_eq!(page.items[0].cron_expression, "0 */6 * * *");
427 }
428
429 fn engine_with(store: &Arc<dyn Store>, wf_name: &'static str, cron: &str) -> Engine {
430 let provider = Arc::new(ClaudeCodeProvider::new());
431 let mut engine = Engine::new(store.clone(), provider);
432 engine
433 .register(NamedScheduled {
434 wf_name,
435 cron: CronSchedule::new(cron).unwrap(),
436 })
437 .expect("register");
438 engine
439 }
440
441 async fn seed(store: &Arc<dyn Store>, workflow: &str, cron: &str) -> Schedule {
442 store
443 .create_schedule(NewSchedule {
444 workflow_name: workflow.to_string(),
445 cron_expression: cron.to_string(),
446 inputs: json!({}),
447 source: ScheduleSource::Api,
448 priority: 0,
449 created_by_user_id: None,
450 next_trigger_at: None,
451 })
452 .await
453 .expect("seed")
454 }
455
456 async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
457 store
458 .find_schedule_by_id(id)
459 .await
460 .expect("find")
461 .expect("exists")
462 }
463
464 #[tokio::test]
467 async fn sync_disables_a_handler_schedule_whose_next_trigger_cannot_be_computed() {
468 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
469 let engine = engine_with(&store, "leap", "0 0 30 2 *");
471
472 sync_handler_schedules(&engine, store.as_ref())
473 .await
474 .expect("sync");
475
476 let page = store.list_schedules(1, 10).await.expect("list");
477 assert_eq!(page.items.len(), 1);
478 let s = &page.items[0];
479 assert!(
480 !s.is_active(),
481 "active schedule without next trigger: {s:?}"
482 );
483 assert!(s.next_trigger_at.is_none());
484 let error = s.last_error.as_deref().expect("disable reason stored");
485 assert!(error.contains("cannot compute next trigger"), "{error}");
486 }
487
488 #[tokio::test]
489 async fn sync_reenables_a_handler_schedule_disabled_on_error_once_its_cron_works() {
490 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
491 sync_handler_schedules(&engine_with(&store, "leap", "0 0 30 2 *"), store.as_ref())
492 .await
493 .expect("first sync");
494
495 sync_handler_schedules(&engine_with(&store, "leap", "0 0 1 3 *"), store.as_ref())
496 .await
497 .expect("second sync");
498
499 let page = store.list_schedules(1, 10).await.expect("list");
500 let s = &page.items[0];
501 assert!(s.is_active());
502 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
503 assert!(s.last_error.is_none());
504 }
505
506 #[tokio::test]
507 async fn sync_keeps_a_user_paused_handler_schedule_paused_on_cron_change() {
508 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
509 sync_handler_schedules(&engine_with(&store, "deploy", "0 0 * * *"), store.as_ref())
510 .await
511 .expect("first sync");
512 let id = store.list_schedules(1, 10).await.expect("list").items[0].id;
513 store
514 .update_schedule(
515 id,
516 ScheduleUpdate {
517 disabled_at: Some(Some(Utc::now())),
518 ..Default::default()
519 },
520 )
521 .await
522 .expect("pause");
523
524 sync_handler_schedules(
525 &engine_with(&store, "deploy", "0 */6 * * *"),
526 store.as_ref(),
527 )
528 .await
529 .expect("second sync");
530
531 let s = reload(&store, id).await;
532 assert!(!s.is_active(), "a user pause survives a cron change");
533 assert!(s.next_trigger_at.is_some());
534 }
535
536 #[tokio::test]
537 async fn repair_gives_a_next_trigger_to_an_active_schedule_without_one() {
538 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
539 let broken = seed(&store, "hourly", "0 * * * *").await;
540 let paused = seed(&store, "paused", "0 * * * *").await;
541 store
542 .update_schedule(
543 paused.id,
544 ScheduleUpdate {
545 disabled_at: Some(Some(Utc::now())),
546 ..Default::default()
547 },
548 )
549 .await
550 .expect("pause");
551
552 let repaired = repair_unscheduled_schedules(store.as_ref())
553 .await
554 .expect("repair");
555
556 assert_eq!(repaired, 1);
557 let s = reload(&store, broken.id).await;
558 assert!(s.is_active());
559 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
560 assert!(s.last_error.is_none());
561 let p = reload(&store, paused.id).await;
563 assert!(!p.is_active());
564 assert!(p.next_trigger_at.is_none());
565 }
566
567 #[tokio::test]
568 async fn repair_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
569 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
570 let broken = seed(&store, "leap", "0 0 30 2 *").await;
571
572 let repaired = repair_unscheduled_schedules(store.as_ref())
573 .await
574 .expect("repair");
575
576 assert_eq!(repaired, 1);
577 let s = reload(&store, broken.id).await;
578 assert!(!s.is_active());
579 let error = s.last_error.expect("disable reason stored");
580 assert!(error.contains("cannot compute next trigger"), "{error}");
581 }
582
583 #[tokio::test]
584 async fn repair_reads_every_page() {
585 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
586 let count = REPAIR_PAGE_SIZE as usize * 2 + 3;
587 for i in 0..count {
588 seed(&store, &format!("wf-{i}"), "0 * * * *").await;
589 }
590
591 let repaired = repair_unscheduled_schedules(store.as_ref())
592 .await
593 .expect("repair");
594
595 assert_eq!(repaired, count);
596 let left = store
597 .list_schedules(1, 1000)
598 .await
599 .expect("list")
600 .items
601 .into_iter()
602 .filter(|s| s.is_active() && s.next_trigger_at.is_none())
603 .count();
604 assert_eq!(left, 0);
605 }
606
607 #[tokio::test]
608 async fn repair_with_nothing_to_repair_changes_nothing() {
609 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
610 let repaired = repair_unscheduled_schedules(store.as_ref())
611 .await
612 .expect("repair");
613 assert_eq!(repaired, 0);
614 }
615
616 struct PriorityScheduled {
617 priority: i16,
618 cron: CronSchedule,
619 }
620 impl WorkflowHandler for PriorityScheduled {
621 fn name(&self) -> &str {
622 "urgent-report"
623 }
624 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
625 Box::pin(async { Ok(()) })
626 }
627 fn schedule(&self) -> Option<&CronSchedule> {
628 Some(&self.cron)
629 }
630 fn priority(&self) -> i16 {
631 self.priority
632 }
633 }
634
635 fn engine_with_priority(store: &Arc<dyn Store>, priority: i16) -> Engine {
636 let provider = Arc::new(ClaudeCodeProvider::new());
637 let mut engine = Engine::new(store.clone(), provider);
638 engine
639 .register(PriorityScheduled {
640 priority,
641 cron: CronSchedule::new("0 0 * * *").unwrap(),
642 })
643 .expect("register");
644 engine
645 }
646
647 async fn only_schedule(store: &Arc<dyn Store>) -> Schedule {
648 let page = store.list_schedules(1, 10).await.expect("list");
649 assert_eq!(page.items.len(), 1);
650 page.items.into_iter().next().expect("one schedule")
651 }
652
653 #[tokio::test]
654 async fn sync_creates_a_handler_schedule_with_the_handler_priority() {
655 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
656 let engine = engine_with_priority(&store, 40);
657
658 sync_handler_schedules(&engine, store.as_ref())
659 .await
660 .expect("sync");
661
662 let schedule = only_schedule(&store).await;
663 assert_eq!(schedule.source, ScheduleSource::Handler);
664 assert_eq!(schedule.priority, 40);
665 assert_eq!(schedule.new_run(None).priority, 40);
666 }
667
668 #[tokio::test]
669 async fn sync_updates_the_priority_when_the_handler_priority_changed() {
670 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
671 sync_handler_schedules(&engine_with_priority(&store, 0), store.as_ref())
672 .await
673 .expect("first sync");
674 let before = only_schedule(&store).await;
675 assert_eq!(before.priority, 0);
676
677 sync_handler_schedules(&engine_with_priority(&store, -30), store.as_ref())
678 .await
679 .expect("second sync");
680
681 let after = only_schedule(&store).await;
682 assert_eq!(after.id, before.id);
683 assert_eq!(after.priority, -30);
684 assert_eq!(after.cron_expression, before.cron_expression);
685 assert_eq!(after.next_trigger_at, before.next_trigger_at);
686 }
687
688 #[tokio::test]
689 async fn sync_clamps_an_out_of_range_handler_priority() {
690 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
691 sync_handler_schedules(&engine_with_priority(&store, 500), store.as_ref())
692 .await
693 .expect("sync high");
694 assert_eq!(only_schedule(&store).await.priority, MAX_PRIORITY);
695
696 sync_handler_schedules(&engine_with_priority(&store, -500), store.as_ref())
697 .await
698 .expect("sync low");
699 assert_eq!(only_schedule(&store).await.priority, MIN_PRIORITY);
700 }
701}