Skip to main content

runledger_runtime/catalog/
mod.rs

1//! Catalog-backed startup API for keeping handlers, job definitions, schedules,
2//! and workflow steps aligned.
3//!
4//! [`JobCatalog`] is the primary facade. Applications register handlers once,
5//! sync the catalog's definition defaults to PostgreSQL, and use the catalog
6//! helpers to build enqueue, schedule, and workflow inputs with job-type
7//! validation at the call site.
8//!
9//! Catalog defaults apply to every registered entry unless a job is registered
10//! with job-specific definition overrides.
11//!
12//! ## Schedule APIs
13//!
14//! Choose a schedule API by who owns the schedule definition:
15//!
16//! - Use [`JobCatalog::schedule`] plus [`JobCatalog::sync_schedules`] for static
17//!   schedules registered in the worker catalog next to their handler.
18//! - Use [`JobCatalog::sync_schedules_with`] for schedule specs assembled at
19//!   startup from config, feature flags, tenants, or another source outside the
20//!   builder chain.
21//! - Use [`JobCatalog::sync_schedules_exact`] or
22//!   [`JobCatalog::sync_schedules_exact_with`] when this deployment owns a
23//!   bounded schedule-name scope and missing schedules in that scope should be
24//!   deactivated. Exact sync takes a bounded table lock so overlapping startup
25//!   syncs do not interleave their active sets; scheduler claims and
26//!   fire-cursor updates can briefly wait behind the same lock. During rolling
27//!   deploys, keep scopes narrow enough that old and new workers do not
28//!   deactivate each other's schedules unintentionally, and keep owned scopes
29//!   deployment-stable. Feature-flagged schedules should usually remain
30//!   registered with `is_active: false` rather than disappear from the scope.
31//! - Use [`JobCatalog::job_schedule`] plus
32//!   [`runledger_postgres::jobs::upsert_job_schedule`] for one-off setup,
33//!   migrations, admin tools, or schedules that should not be catalog-owned.
34//!   Call [`runledger_postgres::jobs::set_job_schedule_active`] separately to
35//!   change active state on an existing lower-level schedule.
36//!
37//! [`JobCatalog::schedule_sync_scope`] derives an exact-sync scope from
38//! schedules registered on the catalog, so startup code does not need to repeat
39//! those names. If a deployment needs both registered schedules and dynamic
40//! startup specs in one exact source-of-truth set, build one explicit spec list
41//! and scope for [`JobCatalog::sync_schedules_exact_with`]; Runledger does not
42//! provide an implicit union helper because that can hide ownership mistakes.
43
44mod error;
45mod inputs;
46mod registration;
47mod schedule_spec;
48mod sync;
49mod sync_schedules;
50mod types;
51mod workflow;
52
53pub use error::CatalogError;
54pub use inputs::{CatalogJobEnqueueInput, CatalogJobScheduleInput};
55pub use schedule_spec::CatalogJobScheduleSpec;
56pub use types::{
57    JobCatalog, JobCatalogDefaults, JobCatalogDefinitionOverrides, JobCatalogExactSyncReport,
58    JobCatalogScheduleSyncReport, JobCatalogScheduleSyncScope, JobCatalogSyncReport,
59    JobCatalogSyncScope,
60};
61pub use workflow::CatalogWorkflowDagBuilder;
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use async_trait::async_trait;
67    use runledger_core::jobs::{
68        JobCompletion, JobContext, JobFailure, JobHandler, JobType, WorkflowBuildError,
69    };
70    use serde_json::Value;
71    use serde_json::json;
72
73    struct StaticHandler(&'static str);
74
75    #[async_trait]
76    impl JobHandler for StaticHandler {
77        fn job_type(&self) -> JobType<'static> {
78            JobType::new(self.0)
79        }
80
81        async fn execute(
82            &self,
83            _context: JobContext,
84            _payload: Value,
85        ) -> Result<JobCompletion, JobFailure> {
86            Ok(JobCompletion::success())
87        }
88    }
89
90    struct BlankHandler;
91
92    #[async_trait]
93    impl JobHandler for BlankHandler {
94        fn job_type(&self) -> JobType<'static> {
95            JobType::new("   ")
96        }
97
98        async fn execute(
99            &self,
100            _context: JobContext,
101            _payload: Value,
102        ) -> Result<JobCompletion, JobFailure> {
103            Ok(JobCompletion::success())
104        }
105    }
106
107    struct MismatchHandler;
108
109    #[async_trait]
110    impl JobHandler for MismatchHandler {
111        fn job_type(&self) -> JobType<'static> {
112            JobType::new("jobs.other")
113        }
114
115        async fn execute(
116            &self,
117            _context: JobContext,
118            _payload: Value,
119        ) -> Result<JobCompletion, JobFailure> {
120            Ok(JobCompletion::success())
121        }
122    }
123
124    #[test]
125    fn rejects_blank_declared_job_type() {
126        let error = JobCatalog::new()
127            .try_job("   ", StaticHandler("jobs.test"))
128            .expect_err("blank declared job type");
129        assert!(matches!(
130            error,
131            CatalogError::InvalidJobType {
132                job_type,
133                source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
134            } if job_type == "   "
135        ));
136    }
137
138    #[test]
139    fn rejects_blank_handler_job_type() {
140        let error = JobCatalog::new()
141            .try_job("jobs.test", BlankHandler)
142            .expect_err("blank handler job type");
143        assert!(matches!(
144            error,
145            CatalogError::InvalidHandlerJobType {
146                handler_job_type,
147                source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
148            } if handler_job_type == "   "
149        ));
150    }
151
152    #[test]
153    fn rejects_handler_job_type_mismatch() {
154        let error = JobCatalog::new()
155            .try_job("jobs.catalog.expected", MismatchHandler)
156            .expect_err("handler mismatch");
157        assert!(matches!(error, CatalogError::HandlerJobTypeMismatch { .. }));
158    }
159
160    #[test]
161    fn rejects_duplicate_job_type() {
162        let error = JobCatalog::new()
163            .try_job("jobs.dup", StaticHandler("jobs.dup"))
164            .expect("first registration")
165            .try_job("jobs.dup", StaticHandler("jobs.dup"))
166            .expect_err("duplicate job type");
167        assert!(matches!(error, CatalogError::DuplicateJobType { .. }));
168    }
169
170    #[test]
171    fn to_registry_preserves_handlers_and_retry_overrides() {
172        let catalog = JobCatalog::new()
173            .job("jobs.test", StaticHandler("jobs.test"))
174            .try_retry_delay_override("jobs.test", "job.test.wait", 42)
175            .expect("retry override");
176        let registry = catalog.to_registry();
177        assert!(registry.get(JobType::new("jobs.test")).is_some());
178        assert_eq!(
179            registry.retry_delay_override(JobType::new("jobs.test"), "job.test.wait"),
180            Some(42)
181        );
182    }
183
184    #[test]
185    fn retry_override_rejects_unknown_job_type() {
186        let error = JobCatalog::new()
187            .try_retry_delay_override("jobs.missing", "job.test.wait", 42)
188            .expect_err("unknown job type");
189        assert!(matches!(error, CatalogError::UnknownJobType { .. }));
190    }
191
192    #[test]
193    fn retry_override_validates_job_type_before_override_values() {
194        let error = JobCatalog::new()
195            .try_retry_delay_override("jobs.missing", "   ", 0)
196            .expect_err("unknown job type");
197        assert!(matches!(error, CatalogError::UnknownJobType { .. }));
198    }
199
200    #[test]
201    fn retry_override_rejects_blank_failure_code() {
202        let error = JobCatalog::new()
203            .job("jobs.test", StaticHandler("jobs.test"))
204            .try_retry_delay_override("jobs.test", "   ", 42)
205            .expect_err("blank failure code");
206        assert!(matches!(error, CatalogError::InvalidFailureCode));
207    }
208
209    #[test]
210    fn retry_override_rejects_non_positive_delay() {
211        let error = JobCatalog::new()
212            .job("jobs.test", StaticHandler("jobs.test"))
213            .try_retry_delay_override("jobs.test", "job.test.wait", 0)
214            .expect_err("invalid retry delay");
215        assert!(matches!(error, CatalogError::InvalidRetryDelay));
216    }
217
218    #[test]
219    fn exact_sync_scope_rejects_blank_job_type() {
220        let error = JobCatalogSyncScope::job_type("   ").expect_err("blank exact sync job type");
221        assert!(matches!(
222            error,
223            CatalogError::InvalidExactSyncScopeJobType {
224                job_type,
225                source: runledger_core::jobs::IdentifierValidationError::BlankJobType,
226            } if job_type == "   "
227        ));
228    }
229
230    #[test]
231    fn exact_sync_scope_rejects_empty_job_type_list() {
232        let error = JobCatalogSyncScope::job_types(Vec::<String>::new())
233            .expect_err("empty exact sync scope");
234        assert!(matches!(error, CatalogError::InvalidExactSyncScope));
235    }
236
237    #[test]
238    fn job_enqueue_rejects_unknown_job_type() {
239        let catalog = JobCatalog::new();
240        let error = catalog
241            .job_enqueue(&CatalogJobEnqueueInput {
242                job_type: "jobs.missing",
243                organization_id: None,
244                payload: &json!({}),
245                priority: None,
246                max_attempts: None,
247                timeout_seconds: None,
248                next_run_at: None,
249                idempotency_key: None,
250                stage: None,
251            })
252            .expect_err("unknown job type");
253        assert!(matches!(error, CatalogError::UnknownJobType { .. }));
254    }
255
256    #[test]
257    fn job_enqueue_rejects_disabled_catalog_defaults() {
258        let catalog = JobCatalog::new()
259            .job("jobs.test", StaticHandler("jobs.test"))
260            .defaults(JobCatalogDefaults::new().enabled(false));
261        let error = catalog
262            .job_enqueue(&CatalogJobEnqueueInput {
263                job_type: "jobs.test",
264                organization_id: None,
265                payload: &json!({}),
266                priority: None,
267                max_attempts: None,
268                timeout_seconds: None,
269                next_run_at: None,
270                idempotency_key: None,
271                stage: None,
272            })
273            .expect_err("disabled job type");
274        assert!(matches!(error, CatalogError::DisabledJobType { .. }));
275    }
276
277    #[test]
278    fn job_enqueue_rejects_disabled_job_definition_overrides() {
279        let catalog = JobCatalog::new().job_with_definition_overrides(
280            "jobs.test",
281            StaticHandler("jobs.test"),
282            JobCatalogDefinitionOverrides::new().enabled(false),
283        );
284        let error = catalog
285            .job_enqueue(&CatalogJobEnqueueInput {
286                job_type: "jobs.test",
287                organization_id: None,
288                payload: &json!({}),
289                priority: None,
290                max_attempts: None,
291                timeout_seconds: None,
292                next_run_at: None,
293                idempotency_key: None,
294                stage: None,
295            })
296            .expect_err("disabled job type");
297        assert!(matches!(error, CatalogError::DisabledJobType { .. }));
298    }
299
300    #[test]
301    fn job_definition_overrides_take_precedence_over_disabled_catalog_defaults() {
302        let catalog = JobCatalog::new()
303            .job_with_definition_overrides(
304                "jobs.test",
305                StaticHandler("jobs.test"),
306                JobCatalogDefinitionOverrides::new().enabled(true),
307            )
308            .defaults(JobCatalogDefaults::new().enabled(false));
309        let payload = json!({});
310        let enqueue = catalog
311            .job_enqueue(&CatalogJobEnqueueInput {
312                job_type: "jobs.test",
313                organization_id: None,
314                payload: &payload,
315                priority: None,
316                max_attempts: None,
317                timeout_seconds: None,
318                next_run_at: None,
319                idempotency_key: None,
320                stage: None,
321            })
322            .expect("job-specific enabled override should win");
323        assert_eq!(enqueue.job_type.as_str(), "jobs.test");
324    }
325
326    #[test]
327    fn job_schedule_accepts_enabled_job_definition_override() {
328        let catalog = JobCatalog::new()
329            .job_with_definition_overrides(
330                "jobs.test",
331                StaticHandler("jobs.test"),
332                JobCatalogDefinitionOverrides::new().enabled(true),
333            )
334            .defaults(JobCatalogDefaults::new().enabled(false));
335        let payload = json!({});
336        let schedule = catalog
337            .job_schedule(&CatalogJobScheduleInput {
338                name: "jobs.test.schedule",
339                job_type: "jobs.test",
340                organization_id: None,
341                payload_template: &payload,
342                cron_expr: "0 * * * * *",
343                is_active: true,
344                next_fire_at: chrono::Utc::now(),
345                max_jitter_seconds: 0,
346            })
347            .expect("job-specific enabled override should win");
348        assert_eq!(schedule.job_type.as_str(), "jobs.test");
349    }
350
351    #[test]
352    fn try_schedule_rejects_invalid_cron_before_database() {
353        let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
354        let payload = json!({});
355        let error = catalog
356            .try_schedule(CatalogJobScheduleSpec {
357                name: "jobs.test.schedule",
358                job_type: "jobs.test",
359                cron_expr: "not a cron expression",
360                payload_template: &payload,
361                is_active: true,
362                organization_id: None,
363                max_jitter_seconds: 0,
364                next_fire_at: None,
365            })
366            .expect_err("invalid cron");
367        assert!(matches!(
368            error,
369            CatalogError::InvalidScheduleSpec {
370                field: "cron_expr",
371                ..
372            }
373        ));
374    }
375
376    #[test]
377    fn try_schedule_rejects_excessive_jitter_before_database() {
378        let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
379        let payload = json!({});
380        let error = catalog
381            .try_schedule(CatalogJobScheduleSpec {
382                name: "jobs.test.schedule",
383                job_type: "jobs.test",
384                cron_expr: "0 * * * * *",
385                payload_template: &payload,
386                is_active: true,
387                organization_id: None,
388                max_jitter_seconds: 86_401,
389                next_fire_at: None,
390            })
391            .expect_err("excessive jitter");
392        assert!(matches!(
393            error,
394            CatalogError::InvalidScheduleSpec {
395                field: "max_jitter_seconds",
396                ..
397            }
398        ));
399    }
400
401    #[test]
402    fn schedule_sync_scope_uses_registered_schedule_names() {
403        let payload = json!({});
404        let catalog = JobCatalog::new()
405            .job("jobs.test", StaticHandler("jobs.test"))
406            .schedule(CatalogJobScheduleSpec {
407                name: "jobs.test.hourly",
408                job_type: "jobs.test",
409                cron_expr: "0 0 * * * *",
410                payload_template: &payload,
411                is_active: true,
412                organization_id: None,
413                max_jitter_seconds: 0,
414                next_fire_at: None,
415            })
416            .schedule(CatalogJobScheduleSpec {
417                name: "jobs.test.daily",
418                job_type: "jobs.test",
419                cron_expr: "0 0 0 * * *",
420                payload_template: &payload,
421                is_active: true,
422                organization_id: None,
423                max_jitter_seconds: 0,
424                next_fire_at: None,
425            });
426
427        let scope = catalog
428            .schedule_sync_scope()
429            .expect("registered schedules should build scope");
430        assert_eq!(
431            scope,
432            JobCatalogScheduleSyncScope::schedule_names(["jobs.test.daily", "jobs.test.hourly"])
433                .expect("expected scope")
434        );
435    }
436
437    #[test]
438    fn schedule_sync_scope_rejects_empty_registered_schedule_set() {
439        let error = JobCatalog::new()
440            .job("jobs.test", StaticHandler("jobs.test"))
441            .schedule_sync_scope()
442            .expect_err("empty schedule scope");
443        assert!(matches!(error, CatalogError::InvalidExactScheduleSyncScope));
444    }
445
446    #[test]
447    fn definition_overrides_apply_after_job_registration() {
448        let catalog = JobCatalog::new()
449            .job("jobs.test", StaticHandler("jobs.test"))
450            .definition_overrides(
451                "jobs.test",
452                JobCatalogDefinitionOverrides::new().enabled(true),
453            )
454            .defaults(JobCatalogDefaults::new().enabled(false));
455        let payload = json!({});
456        let enqueue = catalog
457            .job_enqueue(&CatalogJobEnqueueInput {
458                job_type: "jobs.test",
459                organization_id: None,
460                payload: &payload,
461                priority: None,
462                max_attempts: None,
463                timeout_seconds: None,
464                next_run_at: None,
465                idempotency_key: None,
466                stage: None,
467            })
468            .expect("post-registration override should apply");
469        assert_eq!(enqueue.job_type.as_str(), "jobs.test");
470    }
471
472    #[test]
473    fn definition_overrides_reject_unknown_job_type() {
474        let error = JobCatalog::new()
475            .try_definition_overrides(
476                "jobs.missing",
477                JobCatalogDefinitionOverrides::new().enabled(true),
478            )
479            .expect_err("unknown job type");
480        assert!(matches!(error, CatalogError::UnknownJobType { .. }));
481    }
482
483    #[test]
484    fn definition_overrides_reject_invalid_job_type() {
485        let error = JobCatalog::new()
486            .job("jobs.test", StaticHandler("jobs.test"))
487            .try_definition_overrides("   ", JobCatalogDefinitionOverrides::new().enabled(true))
488            .expect_err("invalid job type");
489        assert!(matches!(error, CatalogError::InvalidJobType { .. }));
490    }
491
492    #[test]
493    fn try_definition_overrides_reject_invalid_values() {
494        let error = JobCatalog::new()
495            .job("jobs.test", StaticHandler("jobs.test"))
496            .try_definition_overrides(
497                "jobs.test",
498                JobCatalogDefinitionOverrides::new().max_attempts(0),
499            )
500            .expect_err("invalid override");
501        assert!(matches!(
502            error,
503            CatalogError::InvalidJobDefinitionValue {
504                job_type,
505                field: "max_attempts",
506            } if job_type == "jobs.test"
507        ));
508    }
509
510    #[test]
511    fn workflow_dag_propagates_blank_step_key_error() {
512        let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
513        let error = catalog
514            .workflow_dag("workflow.test", &json!({}))
515            .job("   ", "jobs.test", &json!({}))
516            .expect_err("blank step key");
517        assert!(matches!(
518            error,
519            CatalogError::WorkflowBuild(WorkflowBuildError::BlankStepKey { .. })
520        ));
521    }
522
523    #[test]
524    fn workflow_dag_propagates_unknown_dependency_error() {
525        let catalog = JobCatalog::new().job("jobs.test", StaticHandler("jobs.test"));
526        let error = catalog
527            .workflow_dag("workflow.test", &json!({}))
528            .job("first", "jobs.test", &json!({}))
529            .expect("first step")
530            .after_success("missing", ["first"])
531            .expect_err("unknown dependency target");
532        assert!(matches!(
533            error,
534            CatalogError::WorkflowBuild(WorkflowBuildError::UnknownStepKey { .. })
535        ));
536    }
537
538    #[test]
539    fn workflow_dag_propagates_empty_workflow_error() {
540        let catalog = JobCatalog::new();
541        let error = catalog
542            .workflow_dag("workflow.test", &json!({}))
543            .try_build()
544            .expect_err("empty workflow");
545        assert!(matches!(
546            error,
547            CatalogError::WorkflowBuild(WorkflowBuildError::EmptySteps)
548        ));
549    }
550}