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}