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    MAX_PRIORITY, MIN_PRIORITY, NewSchedule, Schedule, ScheduleNext, SchedulePolicy,
13    ScheduleSource, ScheduleUpdate,
14};
15use ironflow_store::error::StoreError;
16use ironflow_store::store::Store;
17use serde_json::json;
18use tracing::{info, warn};
19
20use crate::schedule_clock::schedule_next;
21
22/// Reconcile DB schedules with handler-declared schedules.
23///
24/// Performs a three-way sync at startup:
25/// 1. **Create** DB rows for handlers that declare a schedule but have no
26///    corresponding `source = handler` row.
27/// 2. **Update** the cron expression when the handler's cron changed, the
28///    priority when the handler's
29///    [`priority`](ironflow_engine::handler::WorkflowHandler::priority) changed,
30///    and the catch-up, overlap and timezone policy when the handler's
31///    [`CronSchedule`](ironflow_engine::schedule::CronSchedule) changed it.
32/// 3. **Delete** orphan `source = handler` rows whose handler was removed
33///    from the code.
34///
35/// # Errors
36///
37/// Returns the first store error encountered. Changes applied before the
38/// error are kept.
39///
40/// # Examples
41///
42/// ```no_run
43/// use std::sync::Arc;
44/// use ironflow_api::schedule_sync::sync_handler_schedules;
45/// use ironflow_engine::engine::Engine;
46/// use ironflow_core::providers::claude::ClaudeCodeProvider;
47/// use ironflow_store::memory::InMemoryStore;
48/// use ironflow_store::store::Store;
49///
50/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
51/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
52/// let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()));
53/// sync_handler_schedules(&engine, store.as_ref()).await?;
54/// # Ok(())
55/// # }
56/// ```
57pub async fn sync_handler_schedules(engine: &Engine, store: &dyn Store) -> Result<(), StoreError> {
58    let handlers = engine.scheduled_handlers();
59    // The priority every run of the schedule gets, clamped like a run the
60    // handler creates itself.
61    let handler_map: HashMap<&str, (&str, i16, &SchedulePolicy)> = handlers
62        .iter()
63        .map(|(name, sched)| {
64            let priority = engine.get_handler(name).map_or(0, |handler| {
65                handler.priority().clamp(MIN_PRIORITY, MAX_PRIORITY)
66            });
67            (*name, (sched.as_str(), priority, sched.policy()))
68        })
69        .collect();
70    let handler_names: HashSet<&str> = handler_map.keys().copied().collect();
71
72    let existing = store.list_schedules(1, 1000).await?;
73
74    let handler_schedules: Vec<_> = existing
75        .items
76        .iter()
77        .filter(|s| s.source == ScheduleSource::Handler)
78        .collect();
79
80    // 1. Delete orphans: handler-declared schedules whose handler no longer exists.
81    for schedule in &handler_schedules {
82        if !handler_names.contains(schedule.workflow_name.as_str()) {
83            store.delete_schedule(schedule.id).await?;
84            info!(
85                workflow = %schedule.workflow_name,
86                "removed orphan handler schedule (handler no longer exists)"
87            );
88        }
89    }
90
91    // Build lookup of remaining handler schedules by workflow name.
92    let existing_map: HashMap<&str, &Schedule> = handler_schedules
93        .iter()
94        .filter(|s| handler_names.contains(s.workflow_name.as_str()))
95        .map(|s| (s.workflow_name.as_str(), *s))
96        .collect();
97
98    for (name, (cron_str, priority, policy)) in &handler_map {
99        match existing_map.get(name) {
100            Some(existing) => {
101                let cron_changed = existing.cron_expression != *cron_str;
102                let priority_changed = existing.priority != *priority;
103                let policy_changed = existing.policy != **policy;
104                if !cron_changed && !priority_changed && !policy_changed {
105                    continue;
106                }
107                let mut update = ScheduleUpdate::default();
108                // The next occurrence depends on the cron and the timezone.
109                if cron_changed || existing.policy.timezone != policy.timezone {
110                    // 2. Cron changed in code: update DB row. A schedule Ironflow
111                    // disabled on an error is re-enabled by a cron that works.
112                    let next = schedule_next(cron_str, policy.timezone);
113                    let reenable =
114                        existing.last_error.is_some() && matches!(next, ScheduleNext::At(_));
115                    update = next_trigger_update(next);
116                    if reenable {
117                        update.disabled_at = Some(None);
118                        update.last_error = Some(None);
119                    }
120                }
121                if cron_changed {
122                    update.cron_expression = Some(cron_str.to_string());
123                }
124                if priority_changed {
125                    update.priority = Some(*priority);
126                }
127                if policy_changed {
128                    update.policy = Some((*policy).clone());
129                }
130                store.update_schedule(existing.id, update).await?;
131                info!(
132                    workflow = %name,
133                    old_cron = %existing.cron_expression,
134                    new_cron = %cron_str,
135                    old_priority = existing.priority,
136                    new_priority = *priority,
137                    old_policy = ?existing.policy,
138                    new_policy = ?policy,
139                    "updated handler schedule"
140                );
141            }
142            None => {
143                // 3. Missing: create a new handler schedule.
144                let next = schedule_next(cron_str, policy.timezone);
145                let next_trigger_at = match next {
146                    ScheduleNext::At(at) => Some(at),
147                    ScheduleNext::Disable { .. } => None,
148                };
149                let created = store
150                    .create_schedule(NewSchedule {
151                        workflow_name: name.to_string(),
152                        cron_expression: cron_str.to_string(),
153                        inputs: json!({}),
154                        source: ScheduleSource::Handler,
155                        priority: *priority,
156                        created_by_user_id: None,
157                        next_trigger_at,
158                        policy: (*policy).clone(),
159                    })
160                    .await?;
161                if let ScheduleNext::Disable { error } = next {
162                    warn!(
163                        workflow = %name,
164                        schedule = %cron_str,
165                        error = %error,
166                        "cannot compute next trigger, handler schedule disabled"
167                    );
168                    store
169                        .update_schedule(
170                            created.id,
171                            next_trigger_update(ScheduleNext::Disable { error }),
172                        )
173                        .await?;
174                }
175                info!(
176                    workflow = %name,
177                    schedule = %cron_str,
178                    "synced handler-declared schedule to DB"
179                );
180            }
181        }
182    }
183
184    Ok(())
185}
186
187/// The update that applies `next` to a schedule: its next trigger time, or
188/// disabled with the reason in `last_error`.
189fn next_trigger_update(next: ScheduleNext) -> ScheduleUpdate {
190    match next {
191        ScheduleNext::At(at) => ScheduleUpdate {
192            next_trigger_at: Some(Some(at)),
193            ..Default::default()
194        },
195        ScheduleNext::Disable { error } => ScheduleUpdate {
196            disabled_at: Some(Some(Utc::now())),
197            next_trigger_at: Some(None),
198            last_error: Some(Some(error)),
199            ..Default::default()
200        },
201    }
202}
203
204/// How many schedules [`repair_unscheduled_schedules`] reads per page.
205const REPAIR_PAGE_SIZE: u32 = 100;
206
207/// Repair active schedules that have no next trigger time.
208///
209/// Such a schedule never fires again. Earlier releases left schedules in that
210/// state when a run creation failed, when the server stopped between the
211/// claim and the update, or when the next trigger could not be computed.
212/// Each one receives its next trigger time, or is disabled with the reason in
213/// `last_error` when that time cannot be computed. Logs a `warn` per repaired
214/// schedule and returns how many were repaired.
215///
216/// Call it once at server startup, after
217/// [`sync_handler_schedules`] and before spawning the
218/// [`ScheduleTicker`](super::schedule_ticker::ScheduleTicker).
219///
220/// # Errors
221///
222/// Returns the first store error encountered. Repairs applied before the
223/// error are kept.
224///
225/// # Examples
226///
227/// ```no_run
228/// use ironflow_api::schedule_sync::repair_unscheduled_schedules;
229/// use ironflow_store::memory::InMemoryStore;
230///
231/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
232/// let store = InMemoryStore::new();
233/// let repaired = repair_unscheduled_schedules(&store).await?;
234/// println!("{repaired} schedules repaired");
235/// # Ok(())
236/// # }
237/// ```
238pub async fn repair_unscheduled_schedules(store: &dyn Store) -> Result<usize, StoreError> {
239    let mut broken: Vec<Schedule> = Vec::new();
240    let mut page = 1;
241    loop {
242        let batch = store.list_schedules(page, REPAIR_PAGE_SIZE).await?;
243        let read = (page as u64) * (REPAIR_PAGE_SIZE as u64);
244        let last = batch.items.is_empty() || read >= batch.total;
245        broken.extend(
246            batch
247                .items
248                .into_iter()
249                .filter(|s| s.is_active() && s.next_trigger_at.is_none()),
250        );
251        if last {
252            break;
253        }
254        page += 1;
255    }
256
257    for schedule in &broken {
258        let next = schedule_next(&schedule.cron_expression, schedule.policy.timezone);
259        match &next {
260            ScheduleNext::At(at) => warn!(
261                schedule_id = %schedule.id,
262                workflow = %schedule.workflow_name,
263                next_trigger_at = %at,
264                "repaired active schedule without next trigger"
265            ),
266            ScheduleNext::Disable { error } => warn!(
267                schedule_id = %schedule.id,
268                workflow = %schedule.workflow_name,
269                error = %error,
270                "disabled active schedule without next trigger: cannot compute it"
271            ),
272        }
273        store
274            .update_schedule(schedule.id, next_trigger_update(next))
275            .await?;
276    }
277
278    Ok(broken.len())
279}
280
281#[cfg(test)]
282mod tests {
283    use std::time::Duration;
284
285    use chrono::Timelike;
286    use chrono_tz::America::New_York;
287    use chrono_tz::Europe::Paris;
288    use chrono_tz::Tz;
289    use ironflow_core::providers::claude::ClaudeCodeProvider;
290    use ironflow_engine::context::WorkflowContext;
291    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
292    use ironflow_engine::prelude::CronSchedule;
293    use ironflow_store::entities::{CatchupPolicy, OverlapPolicy};
294    use ironflow_store::memory::InMemoryStore;
295    use std::sync::Arc;
296    use uuid::Uuid;
297
298    use super::*;
299
300    struct NamedScheduled {
301        wf_name: &'static str,
302        cron: CronSchedule,
303    }
304    impl WorkflowHandler for NamedScheduled {
305        fn name(&self) -> &str {
306            self.wf_name
307        }
308        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
309            Box::pin(async { Ok(()) })
310        }
311        fn schedule(&self) -> Option<&CronSchedule> {
312            Some(&self.cron)
313        }
314    }
315
316    #[tokio::test]
317    async fn creates_missing_handler_schedules() {
318        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
319        let provider = Arc::new(ClaudeCodeProvider::new());
320        let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
321        engine
322            .register(NamedScheduled {
323                wf_name: "nightly-cleanup",
324                cron: CronSchedule::new("0 0 * * *").unwrap(),
325            })
326            .expect("register");
327
328        sync_handler_schedules(&engine, store.as_ref())
329            .await
330            .expect("sync");
331
332        let page = store.list_schedules(1, 10).await.expect("list");
333        assert_eq!(page.items.len(), 1);
334        assert_eq!(page.items[0].workflow_name, "nightly-cleanup");
335        assert_eq!(page.items[0].source, ScheduleSource::Handler);
336    }
337
338    #[tokio::test]
339    async fn skips_existing_handler_schedule() {
340        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
341        store
342            .create_schedule(NewSchedule {
343                workflow_name: "deploy".to_string(),
344                cron_expression: "*/5 * * * *".to_string(),
345                inputs: json!({"env": "prod"}),
346                source: ScheduleSource::Handler,
347                priority: 0,
348                created_by_user_id: None,
349                next_trigger_at: None,
350                policy: SchedulePolicy::default(),
351            })
352            .await
353            .expect("seed");
354
355        let provider = Arc::new(ClaudeCodeProvider::new());
356        let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
357        engine
358            .register(NamedScheduled {
359                wf_name: "deploy",
360                cron: CronSchedule::new("*/5 * * * *").unwrap(),
361            })
362            .expect("register");
363
364        sync_handler_schedules(&engine, store.as_ref())
365            .await
366            .expect("sync");
367
368        let page = store.list_schedules(1, 10).await.expect("list");
369        assert_eq!(page.items.len(), 1);
370        assert_eq!(page.items[0].cron_expression, "*/5 * * * *");
371    }
372
373    #[tokio::test]
374    async fn removes_orphan_handler_schedules() {
375        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
376        store
377            .create_schedule(NewSchedule {
378                workflow_name: "removed-workflow".to_string(),
379                cron_expression: "0 0 * * *".to_string(),
380                inputs: json!({}),
381                source: ScheduleSource::Handler,
382                priority: 0,
383                created_by_user_id: None,
384                next_trigger_at: None,
385                policy: SchedulePolicy::default(),
386            })
387            .await
388            .expect("seed orphan");
389
390        // API schedule must NOT be removed.
391        store
392            .create_schedule(NewSchedule {
393                workflow_name: "user-created".to_string(),
394                cron_expression: "0 12 * * *".to_string(),
395                inputs: json!({}),
396                source: ScheduleSource::Api,
397                priority: 0,
398                created_by_user_id: Some(Uuid::now_v7()),
399                next_trigger_at: None,
400                policy: SchedulePolicy::default(),
401            })
402            .await
403            .expect("seed api");
404
405        let provider = Arc::new(ClaudeCodeProvider::new());
406        let engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
407
408        sync_handler_schedules(&engine, store.as_ref())
409            .await
410            .expect("sync");
411
412        let page = store.list_schedules(1, 10).await.expect("list");
413        assert_eq!(page.items.len(), 1);
414        assert_eq!(page.items[0].workflow_name, "user-created");
415        assert_eq!(page.items[0].source, ScheduleSource::Api);
416    }
417
418    #[tokio::test]
419    async fn updates_changed_cron() {
420        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
421        store
422            .create_schedule(NewSchedule {
423                workflow_name: "deploy".to_string(),
424                cron_expression: "0 0 * * *".to_string(),
425                inputs: json!({}),
426                source: ScheduleSource::Handler,
427                priority: 0,
428                created_by_user_id: None,
429                next_trigger_at: None,
430                policy: SchedulePolicy::default(),
431            })
432            .await
433            .expect("seed");
434
435        let provider = Arc::new(ClaudeCodeProvider::new());
436        let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
437        engine
438            .register(NamedScheduled {
439                wf_name: "deploy",
440                cron: CronSchedule::new("0 */6 * * *").unwrap(),
441            })
442            .expect("register");
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        assert_eq!(page.items[0].cron_expression, "0 */6 * * *");
451    }
452
453    fn engine_with(store: &Arc<dyn Store>, wf_name: &'static str, cron: &str) -> Engine {
454        let provider = Arc::new(ClaudeCodeProvider::new());
455        let mut engine = Engine::new(store.clone(), provider);
456        engine
457            .register(NamedScheduled {
458                wf_name,
459                cron: CronSchedule::new(cron).unwrap(),
460            })
461            .expect("register");
462        engine
463    }
464
465    async fn seed(store: &Arc<dyn Store>, workflow: &str, cron: &str) -> Schedule {
466        store
467            .create_schedule(NewSchedule {
468                workflow_name: workflow.to_string(),
469                cron_expression: cron.to_string(),
470                inputs: json!({}),
471                source: ScheduleSource::Api,
472                priority: 0,
473                created_by_user_id: None,
474                next_trigger_at: None,
475                policy: SchedulePolicy::default(),
476            })
477            .await
478            .expect("seed")
479    }
480
481    async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
482        store
483            .find_schedule_by_id(id)
484            .await
485            .expect("find")
486            .expect("exists")
487    }
488
489    /// #173: a handler cron with no computable next occurrence used to be
490    /// synced as an active schedule without next trigger, never fired.
491    #[tokio::test]
492    async fn sync_disables_a_handler_schedule_whose_next_trigger_cannot_be_computed() {
493        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
494        // February 30th never happens.
495        let engine = engine_with(&store, "leap", "0 0 30 2 *");
496
497        sync_handler_schedules(&engine, store.as_ref())
498            .await
499            .expect("sync");
500
501        let page = store.list_schedules(1, 10).await.expect("list");
502        assert_eq!(page.items.len(), 1);
503        let s = &page.items[0];
504        assert!(
505            !s.is_active(),
506            "active schedule without next trigger: {s:?}"
507        );
508        assert!(s.next_trigger_at.is_none());
509        let error = s.last_error.as_deref().expect("disable reason stored");
510        assert!(error.contains("cannot compute next trigger"), "{error}");
511    }
512
513    #[tokio::test]
514    async fn sync_reenables_a_handler_schedule_disabled_on_error_once_its_cron_works() {
515        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
516        sync_handler_schedules(&engine_with(&store, "leap", "0 0 30 2 *"), store.as_ref())
517            .await
518            .expect("first sync");
519
520        sync_handler_schedules(&engine_with(&store, "leap", "0 0 1 3 *"), store.as_ref())
521            .await
522            .expect("second sync");
523
524        let page = store.list_schedules(1, 10).await.expect("list");
525        let s = &page.items[0];
526        assert!(s.is_active());
527        assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
528        assert!(s.last_error.is_none());
529    }
530
531    #[tokio::test]
532    async fn sync_keeps_a_user_paused_handler_schedule_paused_on_cron_change() {
533        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
534        sync_handler_schedules(&engine_with(&store, "deploy", "0 0 * * *"), store.as_ref())
535            .await
536            .expect("first sync");
537        let id = store.list_schedules(1, 10).await.expect("list").items[0].id;
538        store
539            .update_schedule(
540                id,
541                ScheduleUpdate {
542                    disabled_at: Some(Some(Utc::now())),
543                    ..Default::default()
544                },
545            )
546            .await
547            .expect("pause");
548
549        sync_handler_schedules(
550            &engine_with(&store, "deploy", "0 */6 * * *"),
551            store.as_ref(),
552        )
553        .await
554        .expect("second sync");
555
556        let s = reload(&store, id).await;
557        assert!(!s.is_active(), "a user pause survives a cron change");
558        assert!(s.next_trigger_at.is_some());
559    }
560
561    #[tokio::test]
562    async fn repair_gives_a_next_trigger_to_an_active_schedule_without_one() {
563        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
564        let broken = seed(&store, "hourly", "0 * * * *").await;
565        let paused = seed(&store, "paused", "0 * * * *").await;
566        store
567            .update_schedule(
568                paused.id,
569                ScheduleUpdate {
570                    disabled_at: Some(Some(Utc::now())),
571                    ..Default::default()
572                },
573            )
574            .await
575            .expect("pause");
576
577        let repaired = repair_unscheduled_schedules(store.as_ref())
578            .await
579            .expect("repair");
580
581        assert_eq!(repaired, 1);
582        let s = reload(&store, broken.id).await;
583        assert!(s.is_active());
584        assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
585        assert!(s.last_error.is_none());
586        // A paused schedule needs no next trigger: left alone.
587        let p = reload(&store, paused.id).await;
588        assert!(!p.is_active());
589        assert!(p.next_trigger_at.is_none());
590    }
591
592    #[tokio::test]
593    async fn repair_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
594        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
595        let broken = seed(&store, "leap", "0 0 30 2 *").await;
596
597        let repaired = repair_unscheduled_schedules(store.as_ref())
598            .await
599            .expect("repair");
600
601        assert_eq!(repaired, 1);
602        let s = reload(&store, broken.id).await;
603        assert!(!s.is_active());
604        let error = s.last_error.expect("disable reason stored");
605        assert!(error.contains("cannot compute next trigger"), "{error}");
606    }
607
608    #[tokio::test]
609    async fn repair_reads_every_page() {
610        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
611        let count = REPAIR_PAGE_SIZE as usize * 2 + 3;
612        for i in 0..count {
613            seed(&store, &format!("wf-{i}"), "0 * * * *").await;
614        }
615
616        let repaired = repair_unscheduled_schedules(store.as_ref())
617            .await
618            .expect("repair");
619
620        assert_eq!(repaired, count);
621        let left = store
622            .list_schedules(1, 1000)
623            .await
624            .expect("list")
625            .items
626            .into_iter()
627            .filter(|s| s.is_active() && s.next_trigger_at.is_none())
628            .count();
629        assert_eq!(left, 0);
630    }
631
632    #[tokio::test]
633    async fn repair_with_nothing_to_repair_changes_nothing() {
634        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
635        let repaired = repair_unscheduled_schedules(store.as_ref())
636            .await
637            .expect("repair");
638        assert_eq!(repaired, 0);
639    }
640
641    struct PriorityScheduled {
642        priority: i16,
643        cron: CronSchedule,
644    }
645    impl WorkflowHandler for PriorityScheduled {
646        fn name(&self) -> &str {
647            "urgent-report"
648        }
649        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
650            Box::pin(async { Ok(()) })
651        }
652        fn schedule(&self) -> Option<&CronSchedule> {
653            Some(&self.cron)
654        }
655        fn priority(&self) -> i16 {
656            self.priority
657        }
658    }
659
660    fn engine_with_priority(store: &Arc<dyn Store>, priority: i16) -> Engine {
661        let provider = Arc::new(ClaudeCodeProvider::new());
662        let mut engine = Engine::new(store.clone(), provider);
663        engine
664            .register(PriorityScheduled {
665                priority,
666                cron: CronSchedule::new("0 0 * * *").unwrap(),
667            })
668            .expect("register");
669        engine
670    }
671
672    async fn only_schedule(store: &Arc<dyn Store>) -> Schedule {
673        let page = store.list_schedules(1, 10).await.expect("list");
674        assert_eq!(page.items.len(), 1);
675        page.items.into_iter().next().expect("one schedule")
676    }
677
678    #[tokio::test]
679    async fn sync_creates_a_handler_schedule_with_the_handler_priority() {
680        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
681        let engine = engine_with_priority(&store, 40);
682
683        sync_handler_schedules(&engine, store.as_ref())
684            .await
685            .expect("sync");
686
687        let schedule = only_schedule(&store).await;
688        assert_eq!(schedule.source, ScheduleSource::Handler);
689        assert_eq!(schedule.priority, 40);
690        assert_eq!(schedule.new_run(None, None).priority, 40);
691    }
692
693    #[tokio::test]
694    async fn sync_updates_the_priority_when_the_handler_priority_changed() {
695        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
696        sync_handler_schedules(&engine_with_priority(&store, 0), store.as_ref())
697            .await
698            .expect("first sync");
699        let before = only_schedule(&store).await;
700        assert_eq!(before.priority, 0);
701
702        sync_handler_schedules(&engine_with_priority(&store, -30), store.as_ref())
703            .await
704            .expect("second sync");
705
706        let after = only_schedule(&store).await;
707        assert_eq!(after.id, before.id);
708        assert_eq!(after.priority, -30);
709        assert_eq!(after.cron_expression, before.cron_expression);
710        assert_eq!(after.next_trigger_at, before.next_trigger_at);
711    }
712
713    #[tokio::test]
714    async fn sync_clamps_an_out_of_range_handler_priority() {
715        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
716        sync_handler_schedules(&engine_with_priority(&store, 500), store.as_ref())
717            .await
718            .expect("sync high");
719        assert_eq!(only_schedule(&store).await.priority, MAX_PRIORITY);
720
721        sync_handler_schedules(&engine_with_priority(&store, -500), store.as_ref())
722            .await
723            .expect("sync low");
724        assert_eq!(only_schedule(&store).await.priority, MIN_PRIORITY);
725    }
726
727    fn engine_with_schedule(store: &Arc<dyn Store>, cron: CronSchedule) -> Engine {
728        let provider = Arc::new(ClaudeCodeProvider::new());
729        let mut engine = Engine::new(store.clone(), provider);
730        engine
731            .register(NamedScheduled {
732                wf_name: "report",
733                cron,
734            })
735            .expect("register");
736        engine
737    }
738
739    #[tokio::test]
740    async fn sync_applies_handler_catchup_overlap_timezone() {
741        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
742        let cron = CronSchedule::new("0 9 * * *")
743            .unwrap()
744            .with_timezone("Europe/Paris")
745            .unwrap()
746            .with_catchup(CatchupPolicy::All)
747            .with_catchup_max(5)
748            .with_catchup_window(Duration::from_secs(7200))
749            .with_overlap(OverlapPolicy::Skip);
750
751        sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
752            .await
753            .expect("sync");
754
755        let schedule = only_schedule(&store).await;
756        assert_eq!(
757            schedule.policy,
758            SchedulePolicy {
759                catchup: CatchupPolicy::All,
760                catchup_max: 5,
761                catchup_window_secs: 7200,
762                overlap: OverlapPolicy::Skip,
763                timezone: Tz::Europe__Paris,
764            }
765        );
766        let next = schedule.next_trigger_at.expect("next trigger");
767        let local = next.with_timezone(&Paris);
768        assert_eq!((local.hour(), local.minute()), (9, 0));
769    }
770
771    #[tokio::test]
772    async fn sync_updates_policy_when_handler_changes_it() {
773        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
774        let cron = CronSchedule::new("0 9 * * *").unwrap();
775        sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
776            .await
777            .expect("first sync");
778        let before = only_schedule(&store).await;
779        assert_eq!(before.policy, SchedulePolicy::default());
780
781        let cron = CronSchedule::new("0 9 * * *")
782            .unwrap()
783            .with_timezone("America/New_York")
784            .unwrap()
785            .with_overlap(OverlapPolicy::Skip);
786        sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
787            .await
788            .expect("second sync");
789
790        let after = only_schedule(&store).await;
791        assert_eq!(after.id, before.id);
792        assert_eq!(after.policy.timezone, Tz::America__New_York);
793        assert_eq!(after.policy.overlap, OverlapPolicy::Skip);
794        assert_eq!(after.policy.catchup, CatchupPolicy::Latest);
795        // The timezone moved the next occurrence to 9:00 in New York.
796        let next = after.next_trigger_at.expect("next trigger");
797        let local = next.with_timezone(&New_York);
798        assert_eq!((local.hour(), local.minute()), (9, 0));
799        assert_ne!(after.next_trigger_at, before.next_trigger_at);
800    }
801}