1use std::collections::{HashMap, HashSet};
8
9use chrono::Utc;
10use ironflow_engine::engine::Engine;
11use ironflow_store::entities::{
12 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> {
53 let handlers = engine.scheduled_handlers();
54 let handler_map: HashMap<&str, &str> = handlers
55 .iter()
56 .map(|(name, cron)| (*name, cron.as_str()))
57 .collect();
58 let handler_names: HashSet<&str> = handler_map.keys().copied().collect();
59
60 let existing = store.list_schedules(1, 1000).await?;
61
62 let handler_schedules: Vec<_> = existing
63 .items
64 .iter()
65 .filter(|s| s.source == ScheduleSource::Handler)
66 .collect();
67
68 for schedule in &handler_schedules {
70 if !handler_names.contains(schedule.workflow_name.as_str()) {
71 store.delete_schedule(schedule.id).await?;
72 info!(
73 workflow = %schedule.workflow_name,
74 "removed orphan handler schedule (handler no longer exists)"
75 );
76 }
77 }
78
79 let existing_map: HashMap<&str, &Schedule> = handler_schedules
81 .iter()
82 .filter(|s| handler_names.contains(s.workflow_name.as_str()))
83 .map(|s| (s.workflow_name.as_str(), *s))
84 .collect();
85
86 for (name, cron_str) in &handler_map {
87 match existing_map.get(name) {
88 Some(existing) if existing.cron_expression != *cron_str => {
89 let next = schedule_next(cron_str);
92 let reenable = existing.last_error.is_some() && matches!(next, ScheduleNext::At(_));
93 let mut update = next_trigger_update(next);
94 update.cron_expression = Some(cron_str.to_string());
95 if reenable {
96 update.disabled_at = Some(None);
97 update.last_error = Some(None);
98 }
99 store.update_schedule(existing.id, update).await?;
100 info!(
101 workflow = %name,
102 old_cron = %existing.cron_expression,
103 new_cron = %cron_str,
104 "updated handler schedule cron"
105 );
106 }
107 Some(_) => {}
108 None => {
109 let next = schedule_next(cron_str);
111 let next_trigger_at = match next {
112 ScheduleNext::At(at) => Some(at),
113 ScheduleNext::Disable { .. } => None,
114 };
115 let created = store
116 .create_schedule(NewSchedule {
117 workflow_name: name.to_string(),
118 cron_expression: cron_str.to_string(),
119 inputs: json!({}),
120 source: ScheduleSource::Handler,
121 created_by_user_id: None,
122 next_trigger_at,
123 })
124 .await?;
125 if let ScheduleNext::Disable { error } = next {
126 warn!(
127 workflow = %name,
128 schedule = %cron_str,
129 error = %error,
130 "cannot compute next trigger, handler schedule disabled"
131 );
132 store
133 .update_schedule(
134 created.id,
135 next_trigger_update(ScheduleNext::Disable { error }),
136 )
137 .await?;
138 }
139 info!(
140 workflow = %name,
141 schedule = %cron_str,
142 "synced handler-declared schedule to DB"
143 );
144 }
145 }
146 }
147
148 Ok(())
149}
150
151fn next_trigger_update(next: ScheduleNext) -> ScheduleUpdate {
154 match next {
155 ScheduleNext::At(at) => ScheduleUpdate {
156 next_trigger_at: Some(Some(at)),
157 ..Default::default()
158 },
159 ScheduleNext::Disable { error } => ScheduleUpdate {
160 disabled_at: Some(Some(Utc::now())),
161 next_trigger_at: Some(None),
162 last_error: Some(Some(error)),
163 ..Default::default()
164 },
165 }
166}
167
168const REPAIR_PAGE_SIZE: u32 = 100;
170
171pub async fn repair_unscheduled_schedules(store: &dyn Store) -> Result<usize, StoreError> {
203 let mut broken: Vec<Schedule> = Vec::new();
204 let mut page = 1;
205 loop {
206 let batch = store.list_schedules(page, REPAIR_PAGE_SIZE).await?;
207 let read = (page as u64) * (REPAIR_PAGE_SIZE as u64);
208 let last = batch.items.is_empty() || read >= batch.total;
209 broken.extend(
210 batch
211 .items
212 .into_iter()
213 .filter(|s| s.is_active() && s.next_trigger_at.is_none()),
214 );
215 if last {
216 break;
217 }
218 page += 1;
219 }
220
221 for schedule in &broken {
222 let next = schedule_next(&schedule.cron_expression);
223 match &next {
224 ScheduleNext::At(at) => warn!(
225 schedule_id = %schedule.id,
226 workflow = %schedule.workflow_name,
227 next_trigger_at = %at,
228 "repaired active schedule without next trigger"
229 ),
230 ScheduleNext::Disable { error } => warn!(
231 schedule_id = %schedule.id,
232 workflow = %schedule.workflow_name,
233 error = %error,
234 "disabled active schedule without next trigger: cannot compute it"
235 ),
236 }
237 store
238 .update_schedule(schedule.id, next_trigger_update(next))
239 .await?;
240 }
241
242 Ok(broken.len())
243}
244
245#[cfg(test)]
246mod tests {
247 use ironflow_core::providers::claude::ClaudeCodeProvider;
248 use ironflow_engine::context::WorkflowContext;
249 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
250 use ironflow_engine::prelude::CronSchedule;
251 use ironflow_store::memory::InMemoryStore;
252 use std::sync::Arc;
253 use uuid::Uuid;
254
255 use super::*;
256
257 struct NamedScheduled {
258 wf_name: &'static str,
259 cron: CronSchedule,
260 }
261 impl WorkflowHandler for NamedScheduled {
262 fn name(&self) -> &str {
263 self.wf_name
264 }
265 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
266 Box::pin(async { Ok(()) })
267 }
268 fn schedule(&self) -> Option<&CronSchedule> {
269 Some(&self.cron)
270 }
271 }
272
273 #[tokio::test]
274 async fn creates_missing_handler_schedules() {
275 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
276 let provider = Arc::new(ClaudeCodeProvider::new());
277 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
278 engine
279 .register(NamedScheduled {
280 wf_name: "nightly-cleanup",
281 cron: CronSchedule::new("0 0 * * *").unwrap(),
282 })
283 .expect("register");
284
285 sync_handler_schedules(&engine, store.as_ref())
286 .await
287 .expect("sync");
288
289 let page = store.list_schedules(1, 10).await.expect("list");
290 assert_eq!(page.items.len(), 1);
291 assert_eq!(page.items[0].workflow_name, "nightly-cleanup");
292 assert_eq!(page.items[0].source, ScheduleSource::Handler);
293 }
294
295 #[tokio::test]
296 async fn skips_existing_handler_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: "*/5 * * * *".to_string(),
302 inputs: json!({"env": "prod"}),
303 source: ScheduleSource::Handler,
304 created_by_user_id: None,
305 next_trigger_at: None,
306 })
307 .await
308 .expect("seed");
309
310 let provider = Arc::new(ClaudeCodeProvider::new());
311 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
312 engine
313 .register(NamedScheduled {
314 wf_name: "deploy",
315 cron: CronSchedule::new("*/5 * * * *").unwrap(),
316 })
317 .expect("register");
318
319 sync_handler_schedules(&engine, store.as_ref())
320 .await
321 .expect("sync");
322
323 let page = store.list_schedules(1, 10).await.expect("list");
324 assert_eq!(page.items.len(), 1);
325 assert_eq!(page.items[0].cron_expression, "*/5 * * * *");
326 }
327
328 #[tokio::test]
329 async fn removes_orphan_handler_schedules() {
330 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
331 store
332 .create_schedule(NewSchedule {
333 workflow_name: "removed-workflow".to_string(),
334 cron_expression: "0 0 * * *".to_string(),
335 inputs: json!({}),
336 source: ScheduleSource::Handler,
337 created_by_user_id: None,
338 next_trigger_at: None,
339 })
340 .await
341 .expect("seed orphan");
342
343 store
345 .create_schedule(NewSchedule {
346 workflow_name: "user-created".to_string(),
347 cron_expression: "0 12 * * *".to_string(),
348 inputs: json!({}),
349 source: ScheduleSource::Api,
350 created_by_user_id: Some(Uuid::now_v7()),
351 next_trigger_at: None,
352 })
353 .await
354 .expect("seed api");
355
356 let provider = Arc::new(ClaudeCodeProvider::new());
357 let engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
358
359 sync_handler_schedules(&engine, store.as_ref())
360 .await
361 .expect("sync");
362
363 let page = store.list_schedules(1, 10).await.expect("list");
364 assert_eq!(page.items.len(), 1);
365 assert_eq!(page.items[0].workflow_name, "user-created");
366 assert_eq!(page.items[0].source, ScheduleSource::Api);
367 }
368
369 #[tokio::test]
370 async fn updates_changed_cron() {
371 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
372 store
373 .create_schedule(NewSchedule {
374 workflow_name: "deploy".to_string(),
375 cron_expression: "0 0 * * *".to_string(),
376 inputs: json!({}),
377 source: ScheduleSource::Handler,
378 created_by_user_id: None,
379 next_trigger_at: None,
380 })
381 .await
382 .expect("seed");
383
384 let provider = Arc::new(ClaudeCodeProvider::new());
385 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
386 engine
387 .register(NamedScheduled {
388 wf_name: "deploy",
389 cron: CronSchedule::new("0 */6 * * *").unwrap(),
390 })
391 .expect("register");
392
393 sync_handler_schedules(&engine, store.as_ref())
394 .await
395 .expect("sync");
396
397 let page = store.list_schedules(1, 10).await.expect("list");
398 assert_eq!(page.items.len(), 1);
399 assert_eq!(page.items[0].cron_expression, "0 */6 * * *");
400 }
401
402 fn engine_with(store: &Arc<dyn Store>, wf_name: &'static str, cron: &str) -> Engine {
403 let provider = Arc::new(ClaudeCodeProvider::new());
404 let mut engine = Engine::new(store.clone(), provider);
405 engine
406 .register(NamedScheduled {
407 wf_name,
408 cron: CronSchedule::new(cron).unwrap(),
409 })
410 .expect("register");
411 engine
412 }
413
414 async fn seed(store: &Arc<dyn Store>, workflow: &str, cron: &str) -> Schedule {
415 store
416 .create_schedule(NewSchedule {
417 workflow_name: workflow.to_string(),
418 cron_expression: cron.to_string(),
419 inputs: json!({}),
420 source: ScheduleSource::Api,
421 created_by_user_id: None,
422 next_trigger_at: None,
423 })
424 .await
425 .expect("seed")
426 }
427
428 async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
429 store
430 .find_schedule_by_id(id)
431 .await
432 .expect("find")
433 .expect("exists")
434 }
435
436 #[tokio::test]
439 async fn sync_disables_a_handler_schedule_whose_next_trigger_cannot_be_computed() {
440 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
441 let engine = engine_with(&store, "leap", "0 0 30 2 *");
443
444 sync_handler_schedules(&engine, store.as_ref())
445 .await
446 .expect("sync");
447
448 let page = store.list_schedules(1, 10).await.expect("list");
449 assert_eq!(page.items.len(), 1);
450 let s = &page.items[0];
451 assert!(
452 !s.is_active(),
453 "active schedule without next trigger: {s:?}"
454 );
455 assert!(s.next_trigger_at.is_none());
456 let error = s.last_error.as_deref().expect("disable reason stored");
457 assert!(error.contains("cannot compute next trigger"), "{error}");
458 }
459
460 #[tokio::test]
461 async fn sync_reenables_a_handler_schedule_disabled_on_error_once_its_cron_works() {
462 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
463 sync_handler_schedules(&engine_with(&store, "leap", "0 0 30 2 *"), store.as_ref())
464 .await
465 .expect("first sync");
466
467 sync_handler_schedules(&engine_with(&store, "leap", "0 0 1 3 *"), store.as_ref())
468 .await
469 .expect("second sync");
470
471 let page = store.list_schedules(1, 10).await.expect("list");
472 let s = &page.items[0];
473 assert!(s.is_active());
474 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
475 assert!(s.last_error.is_none());
476 }
477
478 #[tokio::test]
479 async fn sync_keeps_a_user_paused_handler_schedule_paused_on_cron_change() {
480 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
481 sync_handler_schedules(&engine_with(&store, "deploy", "0 0 * * *"), store.as_ref())
482 .await
483 .expect("first sync");
484 let id = store.list_schedules(1, 10).await.expect("list").items[0].id;
485 store
486 .update_schedule(
487 id,
488 ScheduleUpdate {
489 disabled_at: Some(Some(Utc::now())),
490 ..Default::default()
491 },
492 )
493 .await
494 .expect("pause");
495
496 sync_handler_schedules(
497 &engine_with(&store, "deploy", "0 */6 * * *"),
498 store.as_ref(),
499 )
500 .await
501 .expect("second sync");
502
503 let s = reload(&store, id).await;
504 assert!(!s.is_active(), "a user pause survives a cron change");
505 assert!(s.next_trigger_at.is_some());
506 }
507
508 #[tokio::test]
509 async fn repair_gives_a_next_trigger_to_an_active_schedule_without_one() {
510 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
511 let broken = seed(&store, "hourly", "0 * * * *").await;
512 let paused = seed(&store, "paused", "0 * * * *").await;
513 store
514 .update_schedule(
515 paused.id,
516 ScheduleUpdate {
517 disabled_at: Some(Some(Utc::now())),
518 ..Default::default()
519 },
520 )
521 .await
522 .expect("pause");
523
524 let repaired = repair_unscheduled_schedules(store.as_ref())
525 .await
526 .expect("repair");
527
528 assert_eq!(repaired, 1);
529 let s = reload(&store, broken.id).await;
530 assert!(s.is_active());
531 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
532 assert!(s.last_error.is_none());
533 let p = reload(&store, paused.id).await;
535 assert!(!p.is_active());
536 assert!(p.next_trigger_at.is_none());
537 }
538
539 #[tokio::test]
540 async fn repair_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
541 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
542 let broken = seed(&store, "leap", "0 0 30 2 *").await;
543
544 let repaired = repair_unscheduled_schedules(store.as_ref())
545 .await
546 .expect("repair");
547
548 assert_eq!(repaired, 1);
549 let s = reload(&store, broken.id).await;
550 assert!(!s.is_active());
551 let error = s.last_error.expect("disable reason stored");
552 assert!(error.contains("cannot compute next trigger"), "{error}");
553 }
554
555 #[tokio::test]
556 async fn repair_reads_every_page() {
557 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
558 let count = REPAIR_PAGE_SIZE as usize * 2 + 3;
559 for i in 0..count {
560 seed(&store, &format!("wf-{i}"), "0 * * * *").await;
561 }
562
563 let repaired = repair_unscheduled_schedules(store.as_ref())
564 .await
565 .expect("repair");
566
567 assert_eq!(repaired, count);
568 let left = store
569 .list_schedules(1, 1000)
570 .await
571 .expect("list")
572 .items
573 .into_iter()
574 .filter(|s| s.is_active() && s.next_trigger_at.is_none())
575 .count();
576 assert_eq!(left, 0);
577 }
578
579 #[tokio::test]
580 async fn repair_with_nothing_to_repair_changes_nothing() {
581 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
582 let repaired = repair_unscheduled_schedules(store.as_ref())
583 .await
584 .expect("repair");
585 assert_eq!(repaired, 0);
586 }
587}