Skip to main content

runledger_runtime/catalog/
sync_schedules.rs

1use chrono::Utc;
2use runledger_postgres::DbPool;
3use runledger_postgres::jobs::{
4    JobScheduleCatalogSyncEntry, JobScheduleUpsert, deactivate_schedules_absent_from_names_tx,
5    prepare_schedule_exact_sync_critical_section_tx, sync_catalog_job_schedules_tx,
6};
7
8use super::schedule_spec::CatalogJobScheduleSpec;
9use super::{CatalogError, JobCatalog, JobCatalogScheduleSyncReport, JobCatalogScheduleSyncScope};
10
11enum ScheduleSyncMode<'a> {
12    Additive,
13    Exact {
14        scope: &'a JobCatalogScheduleSyncScope,
15    },
16}
17
18impl JobCatalog {
19    /// Builds an exact-sync scope from schedules registered with [`Self::schedule`].
20    ///
21    /// Use this with [`Self::sync_schedules_exact`] when the registered
22    /// schedules are the source of truth for their owned name set. The helper
23    /// avoids duplicating schedule names in startup code.
24    ///
25    /// # Errors
26    /// Returns [`CatalogError::InvalidExactScheduleSyncScope`] when the catalog
27    /// has no registered schedules.
28    pub fn schedule_sync_scope(&self) -> Result<JobCatalogScheduleSyncScope, CatalogError> {
29        JobCatalogScheduleSyncScope::schedule_names(
30            self.schedules.iter().map(|stored| stored.name.clone()),
31        )
32    }
33
34    /// Upserts every catalog-registered schedule and applies each spec's
35    /// `is_active` value.
36    ///
37    /// Use this for static schedules registered with [`Self::schedule`] next to
38    /// their handler registration. It does not sync caller-provided startup
39    /// specs; use [`Self::sync_schedules_with`] for those.
40    ///
41    /// Call [`Self::sync_definitions`] before this method so referenced job
42    /// definitions exist. Safe to call repeatedly on worker startup. Existing
43    /// rows keep their `organization_id`, keep `next_fire_at` unless the cron
44    /// expression changes, and have `is_active` overwritten from the registered
45    /// spec on every sync. Use lower-level schedule APIs for schedules whose
46    /// active state should be owned by an admin pause/resume workflow instead of
47    /// startup catalog sync.
48    ///
49    /// Sync applies one schedule upsert at a time so persistence errors can be
50    /// reported with the schedule name that failed. Keep catalog-owned schedule
51    /// sets on the order of dozens, not thousands; use a custom lower-level
52    /// setup path for very large schedule inventories.
53    ///
54    /// # Errors
55    /// Returns [`CatalogError`] when schedule validation fails, a schedule
56    /// references an unknown or disabled catalog job type, or persistence fails.
57    pub async fn sync_schedules(
58        &self,
59        pool: &DbPool,
60    ) -> Result<JobCatalogScheduleSyncReport, CatalogError> {
61        let specs: Vec<CatalogJobScheduleSpec<'_>> = self
62            .schedules
63            .iter()
64            .map(|stored| stored.as_spec())
65            .collect();
66        self.sync_schedules_impl(pool, &specs, ScheduleSyncMode::Additive)
67            .await
68    }
69
70    /// Upserts caller-provided schedule specs and applies each spec's
71    /// `is_active` value.
72    ///
73    /// Use this for schedule specs assembled at startup from config, feature
74    /// flags, tenants, or another source outside the builder chain. This syncs
75    /// only `specs`; it does not include schedules registered with
76    /// [`Self::schedule`]. Existing rows keep their `organization_id`, and keep
77    /// `next_fire_at` unless the cron expression changes. Existing rows have
78    /// `is_active` overwritten from the provided spec on every sync.
79    ///
80    /// Use lower-level schedule APIs for schedules whose active state should be
81    /// owned by an admin pause/resume workflow instead of startup catalog sync.
82    /// Sync applies one schedule upsert at a time so persistence errors can be
83    /// reported with the schedule name that failed. Keep catalog-owned schedule
84    /// sets on the order of dozens, not thousands; use a custom lower-level
85    /// setup path for very large schedule inventories.
86    ///
87    /// # Errors
88    /// Returns [`CatalogError`] when schedule validation fails, a schedule
89    /// references an unknown or disabled catalog job type, duplicate names are
90    /// supplied, or persistence fails.
91    pub async fn sync_schedules_with<'a>(
92        &self,
93        pool: &DbPool,
94        specs: &[CatalogJobScheduleSpec<'a>],
95    ) -> Result<JobCatalogScheduleSyncReport, CatalogError> {
96        self.sync_schedules_impl(pool, specs, ScheduleSyncMode::Additive)
97            .await
98    }
99
100    /// Upserts catalog-registered schedules, then deactivates enabled schedules
101    /// in `scope` whose names are absent from the synced spec set.
102    ///
103    /// Use this when registered catalog schedules are the active source of truth
104    /// for a bounded deployment-owned schedule-name scope.
105    ///
106    /// Every registered schedule name must be included in `scope`. Exact sync
107    /// takes a bounded PostgreSQL lock around the upsert/deactivation window so
108    /// overlapping exact syncs cannot interleave their active schedule sets.
109    /// Scheduler claims and fire-cursor updates can also wait briefly behind
110    /// this lock, bounded by the exact-sync transaction settings.
111    /// Exact sync still applies each present schedule's `is_active` value on
112    /// every sync; absent active schedules inside `scope` are deactivated.
113    /// Keep ownership scopes deployment-stable during rolling deploys. For a
114    /// feature-flagged schedule, prefer registering the schedule with
115    /// `is_active: false` over omitting it from the catalog, so exact sync still
116    /// owns and can deactivate the row.
117    ///
118    /// # Errors
119    /// Returns [`CatalogError`] when any schedule is outside `scope`, schedule
120    /// validation fails, a schedule references an unknown or disabled catalog
121    /// job type, lock acquisition fails, or persistence fails.
122    pub async fn sync_schedules_exact(
123        &self,
124        pool: &DbPool,
125        scope: &JobCatalogScheduleSyncScope,
126    ) -> Result<JobCatalogScheduleSyncReport, CatalogError> {
127        let specs: Vec<CatalogJobScheduleSpec<'_>> = self
128            .schedules
129            .iter()
130            .map(|stored| stored.as_spec())
131            .collect();
132        self.sync_schedules_impl(pool, &specs, ScheduleSyncMode::Exact { scope })
133            .await
134    }
135
136    /// Upserts caller-provided schedule specs, then deactivates enabled
137    /// schedules in `scope` whose names are absent from the synced spec set.
138    ///
139    /// Use this when startup-provided specs are the active source of truth for a
140    /// bounded deployment-owned schedule-name scope.
141    ///
142    /// This exact syncs only `specs`; it does not include schedules registered
143    /// with [`Self::schedule`]. Every provided schedule name must be included in
144    /// `scope`. Passing an empty `specs` slice deactivates every enabled
145    /// schedule in the owned scope. Exact sync still applies each present
146    /// schedule's `is_active` value on every sync. Scheduler claims and
147    /// fire-cursor updates can wait briefly behind the exact-sync table lock,
148    /// bounded by the exact-sync transaction settings.
149    /// Keep ownership scopes deployment-stable during rolling deploys; when a
150    /// dynamic source disables a schedule, keep its name in `scope` if exact
151    /// sync should deactivate the stored row.
152    ///
153    /// # Errors
154    /// Returns [`CatalogError`] when any schedule is outside `scope`, schedule
155    /// validation fails, a schedule references an unknown or disabled catalog
156    /// job type, duplicate names are supplied, lock acquisition fails, or
157    /// persistence fails.
158    pub async fn sync_schedules_exact_with<'a>(
159        &self,
160        pool: &DbPool,
161        scope: &JobCatalogScheduleSyncScope,
162        specs: &[CatalogJobScheduleSpec<'a>],
163    ) -> Result<JobCatalogScheduleSyncReport, CatalogError> {
164        self.sync_schedules_impl(pool, specs, ScheduleSyncMode::Exact { scope })
165            .await
166    }
167
168    async fn sync_schedules_impl<'a>(
169        &self,
170        pool: &DbPool,
171        specs: &[CatalogJobScheduleSpec<'a>],
172        mode: ScheduleSyncMode<'_>,
173    ) -> Result<JobCatalogScheduleSyncReport, CatalogError> {
174        let entries = self.materialize_schedule_sync_entries(specs)?;
175        if let ScheduleSyncMode::Exact { scope } = &mode {
176            Self::validate_exact_schedule_sync_scope(scope, &entries)?;
177        }
178
179        // Exact sync with empty specs still needs a transaction so it can
180        // deactivate every active schedule in the owned scope.
181        if entries.is_empty() && matches!(&mode, ScheduleSyncMode::Additive) {
182            return Ok(JobCatalogScheduleSyncReport {
183                synced_schedule_names: Vec::new(),
184                deactivated_absent_schedule_names: Vec::new(),
185            });
186        }
187
188        let mut tx = pool.begin().await.map_err(|error| {
189            CatalogError::ScheduleSyncFailure(Box::new(
190                runledger_postgres::Error::from_query_sqlx_with_context(
191                    "begin job catalog schedule sync",
192                    error,
193                ),
194            ))
195        })?;
196
197        if matches!(&mode, ScheduleSyncMode::Exact { .. }) {
198            prepare_schedule_exact_sync_critical_section_tx(&mut tx)
199                .await
200                .map_err(|error| {
201                    CatalogError::ScheduleSyncCriticalSectionFailure(Box::new(error))
202                })?;
203        }
204
205        let mut synced_schedule_names = Vec::with_capacity(entries.len());
206        for entry in &entries {
207            // Keep per-entry upserts so persistence failures can name the
208            // schedule that failed. Catalog schedule sets are expected to stay
209            // small enough for startup-time one-by-one sync.
210            let report = sync_catalog_job_schedules_tx(&mut tx, std::slice::from_ref(entry))
211                .await
212                .map_err(|error| CatalogError::ScheduleSyncEntryFailure {
213                    name: entry.upsert.name.to_owned(),
214                    source: Box::new(error),
215                })?;
216            synced_schedule_names.extend(report.synced_schedule_names);
217        }
218
219        let deactivated_absent_schedule_names = match mode {
220            ScheduleSyncMode::Additive => Vec::new(),
221            ScheduleSyncMode::Exact { scope } => deactivate_schedules_absent_from_names_tx(
222                &mut tx,
223                &scope.schedule_names_for_storage(),
224                &synced_schedule_names,
225            )
226            .await
227            .map_err(|error| CatalogError::DeactivateAbsentSchedulesFailure(Box::new(error)))?,
228        };
229
230        tx.commit().await.map_err(|error| {
231            CatalogError::ScheduleSyncCommitFailure(Box::new(
232                runledger_postgres::Error::from_query_sqlx_with_context(
233                    "commit job catalog schedule sync",
234                    error,
235                ),
236            ))
237        })?;
238
239        Ok(JobCatalogScheduleSyncReport {
240            synced_schedule_names,
241            deactivated_absent_schedule_names,
242        })
243    }
244
245    fn validate_exact_schedule_sync_scope(
246        scope: &JobCatalogScheduleSyncScope,
247        entries: &[JobScheduleCatalogSyncEntry<'_>],
248    ) -> Result<(), CatalogError> {
249        for entry in entries {
250            if !scope.contains(entry.upsert.name) {
251                return Err(CatalogError::ScheduleNameOutsideExactSyncScope {
252                    name: entry.upsert.name.to_owned(),
253                });
254            }
255        }
256
257        Ok(())
258    }
259
260    fn materialize_schedule_sync_entries<'a>(
261        &self,
262        specs: &[CatalogJobScheduleSpec<'a>],
263    ) -> Result<Vec<JobScheduleCatalogSyncEntry<'a>>, CatalogError> {
264        let mut seen_names = std::collections::BTreeSet::new();
265        let now = Utc::now();
266        let mut entries = Vec::with_capacity(specs.len());
267
268        for spec in specs {
269            spec.validate_shape()
270                .map_err(|field| CatalogError::InvalidScheduleSpec {
271                    name: spec.name.to_owned(),
272                    field,
273                })?;
274
275            if !seen_names.insert(spec.name) {
276                return Err(CatalogError::DuplicateScheduleNameInSyncBatch {
277                    name: spec.name.to_owned(),
278                });
279            }
280
281            let job_type = self.require_catalog_enabled_job_type(spec.job_type)?;
282            let next_fire_at = spec.next_fire_at.unwrap_or(now);
283
284            entries.push(JobScheduleCatalogSyncEntry {
285                upsert: JobScheduleUpsert {
286                    name: spec.name,
287                    job_type,
288                    organization_id: spec.organization_id,
289                    payload_template: spec.payload_template,
290                    cron_expr: spec.cron_expr,
291                    is_active: spec.is_active,
292                    next_fire_at,
293                    max_jitter_seconds: spec.max_jitter_seconds,
294                },
295            });
296        }
297
298        Ok(entries)
299    }
300}