Skip to main content

ironflow_api/
schedule_sync.rs

1//! Reconciliation of handler-declared schedules with the database.
2//!
3//! Call [`sync_handler_schedules`] then [`repair_unscheduled_schedules`] once
4//! at server startup, before spawning the
5//! [`ScheduleTicker`](super::schedule_ticker::ScheduleTicker).
6
7use 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
21/// Reconcile DB schedules with handler-declared schedules.
22///
23/// Performs a three-way sync at startup:
24/// 1. **Create** DB rows for handlers that declare a schedule but have no
25///    corresponding `source = handler` row.
26/// 2. **Update** the cron expression when the handler's cron changed.
27/// 3. **Delete** orphan `source = handler` rows whose handler was removed
28///    from the code.
29///
30/// # Errors
31///
32/// Returns the first store error encountered. Changes applied before the
33/// error are kept.
34///
35/// # Examples
36///
37/// ```no_run
38/// use std::sync::Arc;
39/// use ironflow_api::schedule_sync::sync_handler_schedules;
40/// use ironflow_engine::engine::Engine;
41/// use ironflow_core::providers::claude::ClaudeCodeProvider;
42/// use ironflow_store::memory::InMemoryStore;
43/// use ironflow_store::store::Store;
44///
45/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
47/// let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
48/// sync_handler_schedules(&engine, store.as_ref()).await?;
49/// # Ok(())
50/// # }
51/// ```
52pub 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    // 1. Delete orphans: handler-declared schedules whose handler no longer exists.
69    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    // Build lookup of remaining handler schedules by workflow name.
80    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                // 2. Cron changed in code: update DB row. A schedule Ironflow
90                // disabled on an error is re-enabled by a cron that works.
91                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                // 3. Missing: create a new handler schedule.
110                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
151/// The update that applies `next` to a schedule: its next trigger time, or
152/// disabled with the reason in `last_error`.
153fn 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
168/// How many schedules [`repair_unscheduled_schedules`] reads per page.
169const REPAIR_PAGE_SIZE: u32 = 100;
170
171/// Repair active schedules that have no next trigger time.
172///
173/// Such a schedule never fires again. Earlier releases left schedules in that
174/// state when a run creation failed, when the server stopped between the
175/// claim and the update, or when the next trigger could not be computed.
176/// Each one receives its next trigger time, or is disabled with the reason in
177/// `last_error` when that time cannot be computed. Logs a `warn` per repaired
178/// schedule and returns how many were repaired.
179///
180/// Call it once at server startup, after
181/// [`sync_handler_schedules`] and before spawning the
182/// [`ScheduleTicker`](super::schedule_ticker::ScheduleTicker).
183///
184/// # Errors
185///
186/// Returns the first store error encountered. Repairs applied before the
187/// error are kept.
188///
189/// # Examples
190///
191/// ```no_run
192/// use ironflow_api::schedule_sync::repair_unscheduled_schedules;
193/// use ironflow_store::memory::InMemoryStore;
194///
195/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
196/// let store = InMemoryStore::new();
197/// let repaired = repair_unscheduled_schedules(&store).await?;
198/// println!("{repaired} schedules repaired");
199/// # Ok(())
200/// # }
201/// ```
202pub 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        // API schedule must NOT be removed.
344        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    /// #173: a handler cron with no computable next occurrence used to be
437    /// synced as an active schedule without next trigger, never fired.
438    #[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        // February 30th never happens.
442        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        // A paused schedule needs no next trigger: left alone.
534        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}