Skip to main content

runledger_runtime/
supervisor.rs

1use std::borrow::Borrow;
2use std::future::Future;
3use std::sync::Arc;
4use std::time::Duration;
5
6use tokio::runtime::Handle;
7use tracing::warn;
8
9use crate::catalog::JobCatalog;
10use crate::config::{IntentPromoterConfig, JobsConfig};
11use crate::observer::{JobLifecycleObserver, JobLifecycleObservers};
12use crate::registry::JobRegistry;
13use crate::scheduler::run_scheduler_loop;
14use crate::shutdown::{ShutdownHandle, ShutdownSignal};
15use crate::task_group::TaskGroup;
16use crate::{Result, RuntimeError};
17
18const WORKER_TASK: &str = "worker";
19const INTENT_PROMOTER_TASK: &str = "intent_promoter";
20const SCHEDULER_TASK: &str = "scheduler";
21const REAPER_TASK: &str = "reaper";
22
23/// Supervises the Runledger runtime loops spawned for a worker process.
24///
25/// A supervisor owns the worker, intent promoter, scheduler, and reaper task
26/// handles selected by [`SupervisorBuilder`]. Use
27/// [`Self::run_until_shutdown`] for a typical worker process that should exit on
28/// either an external shutdown signal or an internal runtime task failure.
29///
30/// Dropping a supervisor requests shutdown and detaches the task handles. Call
31/// [`Self::shutdown`] or [`Self::join`] when the owning process needs to observe
32/// panics or unexpected task exits.
33#[must_use]
34pub struct Supervisor {
35    shutdown: ShutdownSignal,
36    tasks: TaskGroup,
37}
38
39/// Builds a [`Supervisor`] with configurable runtime loops.
40///
41/// Worker execution, durable intent promotion, scheduler, and reaper loops are
42/// enabled by default. Disabling the worker also disables intent promotion;
43/// [`SupervisorBuilder::disable_intent_promoter`] can disable only promotion.
44/// Every enabled supervisor polls independently, including when no intents are
45/// pending. Deployments may tune [`IntentPromoterConfig`] or disable redundant
46/// promoters, but must retain promoter coverage for every registered type that
47/// can receive durable intents.
48/// Call [`SupervisorBuilder::with_registry`] or
49/// [`SupervisorBuilder::with_catalog`] before [`SupervisorBuilder::build`] when
50/// worker or reaper loops remain enabled.
51#[must_use]
52pub struct SupervisorBuilder<'a> {
53    pool: &'a runledger_postgres::DbPool,
54    runtime: Handle,
55    registry_selection: Option<RegistrySelection>,
56    config: JobsConfig,
57    observers: Vec<Arc<dyn JobLifecycleObserver>>,
58    worker_enabled: bool,
59    intent_promoter_enabled: bool,
60    intent_promoter_config: Option<IntentPromoterConfig>,
61    scheduler_enabled: bool,
62    reaper_enabled: bool,
63}
64
65/// Cloneable handle for requesting supervisor shutdown from another task.
66#[derive(Clone)]
67pub struct SupervisorShutdown {
68    handle: ShutdownHandle,
69}
70
71enum RegistrySelection {
72    Direct(JobRegistry),
73    Catalog(JobRegistry),
74    Mixed,
75}
76
77impl RegistrySelection {
78    fn direct(current: Option<Self>, registry: JobRegistry) -> Self {
79        match current {
80            None | Some(Self::Direct(_)) => Self::Direct(registry),
81            Some(Self::Catalog(_)) | Some(Self::Mixed) => Self::Mixed,
82        }
83    }
84
85    fn catalog(current: Option<Self>, registry: JobRegistry) -> Self {
86        match current {
87            None | Some(Self::Catalog(_)) => Self::Catalog(registry),
88            Some(Self::Direct(_)) | Some(Self::Mixed) => Self::Mixed,
89        }
90    }
91}
92
93impl Supervisor {
94    /// Returns a supervisor builder configured from the process environment.
95    ///
96    /// Worker settings come from [`JobsConfig::from_env`]. Intent-promotion
97    /// settings inherit the worker polling interval and batch size unless the
98    /// corresponding `JOBS_INTENT_PROMOTER_*` variable is set.
99    pub fn builder_from_env(
100        pool: &runledger_postgres::DbPool,
101    ) -> std::result::Result<SupervisorBuilder<'_>, RuntimeError> {
102        let config = JobsConfig::from_env();
103        let intent_promoter_config =
104            IntentPromoterConfig::from_env_with_jobs_config_defaults(&config);
105
106        Self::builder(pool, config)
107            .map(|builder| builder.with_intent_promoter_config(intent_promoter_config))
108    }
109
110    /// Returns a builder for a supervisor over a shared pool and runtime
111    /// configuration.
112    ///
113    /// This validates that the caller is inside the Tokio runtime that will own
114    /// spawned supervisor tasks.
115    pub fn builder(
116        pool: &runledger_postgres::DbPool,
117        config: JobsConfig,
118    ) -> std::result::Result<SupervisorBuilder<'_>, RuntimeError> {
119        let runtime =
120            Handle::try_current().map_err(|source| RuntimeError::MissingTokioRuntime { source })?;
121
122        Ok(SupervisorBuilder {
123            pool,
124            runtime,
125            registry_selection: None,
126            config,
127            observers: Vec::new(),
128            worker_enabled: true,
129            intent_promoter_enabled: true,
130            intent_promoter_config: None,
131            scheduler_enabled: true,
132            reaper_enabled: true,
133        })
134    }
135
136    /// Returns a cloneable shutdown handle that can request shutdown without
137    /// owning the supervisor task joins.
138    #[must_use]
139    pub fn shutdown_handle(&self) -> SupervisorShutdown {
140        SupervisorShutdown {
141            handle: self.shutdown.handle(),
142        }
143    }
144
145    /// Requests graceful shutdown of all supervised loops.
146    pub fn request_shutdown(&self) {
147        self.shutdown.request();
148    }
149
150    /// Returns whether shutdown has been requested through this supervisor or a
151    /// clone of its shutdown handle.
152    #[must_use]
153    pub fn is_shutdown_requested(&self) -> bool {
154        self.shutdown.is_requested()
155    }
156
157    /// Waits for all supervised loops to exit.
158    ///
159    /// With the default long-running loops, this method waits until shutdown is
160    /// requested through a [`SupervisorShutdown`] handle or until a task exits.
161    /// If a loop exits before shutdown was requested, the remaining loops are
162    /// asked to shut down and the first observed error is returned. Additional
163    /// task failures observed while draining are logged. This method does not
164    /// impose a deadline; use [`Self::shutdown_with_timeout`] when the caller
165    /// owns shutdown and needs a bounded wait.
166    pub async fn join(mut self) -> Result<()> {
167        let shutdown = self.shutdown.clone();
168        self.tasks.join(&shutdown).await
169    }
170
171    /// Requests graceful shutdown and waits for all supervised loops to exit.
172    ///
173    /// If a loop exits before shutdown was requested, the remaining loops are
174    /// asked to shut down and the pre-existing task exit is reported, even when
175    /// that exit is only observed after shutdown begins. This method does not
176    /// impose a deadline. Use [`Self::shutdown_with_timeout`] when the owning
177    /// process needs a shutdown budget; externally timing out this consuming
178    /// future can detach still-running task handles.
179    pub async fn shutdown(mut self) -> Result<()> {
180        let shutdown = self.shutdown.clone();
181        self.tasks.shutdown(&shutdown).await
182    }
183
184    /// Waits until `shutdown` resolves or a supervised task fails, then exits.
185    ///
186    /// If `shutdown` resolves first, graceful shutdown is requested and the
187    /// supervisor waits up to `timeout` for all loops to exit. If a loop panics
188    /// or exits unexpectedly before `shutdown` resolves, shutdown is requested
189    /// for the remaining loops and the original task error is returned after
190    /// those loops drain or a timeout is reported. If shutdown is requested
191    /// through a [`SupervisorShutdown`] handle and every loop exits cleanly before
192    /// `shutdown` resolves, this returns successfully.
193    ///
194    /// This is the preferred method for worker binaries because it observes
195    /// internal task failures during normal operation while still applying a
196    /// bounded shutdown budget to cooperative process termination.
197    ///
198    /// If `timeout` is too large to represent as a runtime deadline, this returns
199    /// [`RuntimeError::ShutdownTimeoutTooLarge`] immediately. A zero timeout
200    /// requests shutdown, aborts tasks without waiting for cooperative exits, and
201    /// reports [`RuntimeError::ShutdownTimeout`].
202    ///
203    /// If the initial timeout validation fails before `shutdown` resolves, the
204    /// supervisor is still dropped, so shutdown is requested, but task handles
205    /// are not aborted or drained. If a deadline overflow is detected after
206    /// shutdown begins, remaining tasks are aborted and drained before returning.
207    pub async fn run_until_shutdown<F>(mut self, shutdown: F, timeout: Duration) -> Result<()>
208    where
209        F: Future<Output = ()>,
210    {
211        let shutdown_signal = self.shutdown.clone();
212        self.tasks
213            .run_until_shutdown(shutdown, timeout, &shutdown_signal)
214            .await
215    }
216
217    /// Requests graceful shutdown and waits up to `timeout` for all supervised
218    /// loops to exit.
219    ///
220    /// If a loop had already exited before this method begins shutdown, that
221    /// failure is returned after the remaining loops have had the same shutdown
222    /// budget to exit cooperatively. If the timeout expires, remaining tasks are
223    /// aborted and drained with a bounded cleanup attempt before a timeout error
224    /// is returned. Abort cleanup can make total wall-clock time exceed `timeout`
225    /// by up to one second, or `timeout`, whichever is smaller. A zero timeout
226    /// requests shutdown, immediately aborts tasks that did not already finish,
227    /// and reports [`RuntimeError::ShutdownTimeout`].
228    ///
229    /// If `timeout` is too large to represent as a runtime deadline, this returns
230    /// [`RuntimeError::ShutdownTimeoutTooLarge`] immediately. The supervisor is
231    /// still dropped, so shutdown is requested, but task handles are not aborted
232    /// or drained.
233    pub async fn shutdown_with_timeout(mut self, timeout: Duration) -> Result<()> {
234        let shutdown = self.shutdown.clone();
235        self.tasks.shutdown_with_timeout(timeout, &shutdown).await
236    }
237}
238
239impl Drop for Supervisor {
240    fn drop(&mut self) {
241        if !self.tasks.is_empty() {
242            warn!(
243                task_count = self.tasks.len(),
244                "dropping jobs runtime supervisor before joining tasks; tasks may continue detached after shutdown is requested and later panics will not be observed"
245            );
246        }
247        // Drop cannot await task handles, so this only nudges loops to exit.
248        self.request_shutdown();
249    }
250}
251
252impl<'a> SupervisorBuilder<'a> {
253    /// Registers the handlers used by worker execution and reaper terminal hooks.
254    ///
255    /// A registry is required when worker or reaper loops are enabled. Scheduler-only
256    /// supervisors can be built without one.
257    #[must_use = "builder methods return an updated builder value"]
258    pub fn with_registry(mut self, registry: JobRegistry) -> Self {
259        self.registry_selection =
260            Some(RegistrySelection::direct(self.registry_selection, registry));
261        self
262    }
263
264    /// Registers handlers from a [`JobCatalog`].
265    ///
266    /// This does not sync database job definitions. Call
267    /// [`JobCatalog::sync_definitions`] before starting the supervisor or
268    /// creating schedules. Pass `&catalog` when the caller will continue using
269    /// the catalog for schedule, enqueue, or workflow helpers after building the
270    /// supervisor.
271    ///
272    /// # Registry Source
273    ///
274    /// Calling this and [`Self::with_registry`] on the same builder is rejected
275    /// by [`Self::build`]. Choose one registration source per builder.
276    #[must_use = "builder methods return an updated builder value"]
277    pub fn with_catalog(mut self, catalog: impl Borrow<JobCatalog>) -> Self {
278        self.registry_selection = Some(RegistrySelection::catalog(
279            self.registry_selection,
280            catalog.borrow().to_registry(),
281        ));
282        self
283    }
284
285    /// Disables worker job claiming, execution, and durable intent promotion
286    /// for this supervisor.
287    #[must_use = "builder methods return an updated builder value"]
288    pub fn disable_worker(mut self) -> Self {
289        self.worker_enabled = false;
290        self.intent_promoter_enabled = false;
291        self
292    }
293
294    /// Disables durable enqueue-intent promotion while leaving ordinary worker
295    /// claiming and execution enabled.
296    ///
297    /// Use this only when another compatible promoter covers every job type
298    /// that can receive intents, or when the application never records intents.
299    /// Disabling all applicable promoters leaves accepted intents pending
300    /// indefinitely.
301    #[must_use = "builder methods return an updated builder value"]
302    pub fn disable_intent_promoter(mut self) -> Self {
303        self.intent_promoter_enabled = false;
304        self
305    }
306
307    /// Overrides the intent promoter's polling and batch controls.
308    ///
309    /// This does not enable a promoter disabled by [`Self::disable_worker`] or
310    /// [`Self::disable_intent_promoter`].
311    #[must_use = "builder methods return an updated builder value"]
312    pub fn with_intent_promoter_config(mut self, config: IntentPromoterConfig) -> Self {
313        self.intent_promoter_config = Some(config);
314        self
315    }
316
317    /// Disables cron schedule materialization for this supervisor.
318    #[must_use = "builder methods return an updated builder value"]
319    pub fn disable_scheduler(mut self) -> Self {
320        self.scheduler_enabled = false;
321        self
322    }
323
324    /// Disables expired-lease reaping for this supervisor.
325    #[must_use = "builder methods return an updated builder value"]
326    pub fn disable_reaper(mut self) -> Self {
327        self.reaper_enabled = false;
328        self
329    }
330
331    /// Registers a best-effort observer for committed job lifecycle events.
332    ///
333    /// Observer callbacks run outside Runledger storage transactions. A callback
334    /// timeout or panic is logged and does not change durable job state.
335    #[must_use = "builder methods return an updated builder value"]
336    pub fn with_job_lifecycle_observer(
337        mut self,
338        observer: impl JobLifecycleObserver + 'static,
339    ) -> Self {
340        self.observers.push(Arc::new(observer));
341        self
342    }
343
344    /// Starts the enabled runtime loops and returns the owning supervisor.
345    ///
346    /// Returns an error when worker or reaper loops are enabled without a job
347    /// registry.
348    pub fn build(self) -> std::result::Result<Supervisor, RuntimeError> {
349        let Self {
350            pool,
351            runtime,
352            registry_selection,
353            config,
354            observers,
355            worker_enabled,
356            intent_promoter_enabled,
357            intent_promoter_config,
358            scheduler_enabled,
359            reaper_enabled,
360        } = self;
361
362        config
363            .validate()
364            .map_err(|source| RuntimeError::InvalidJobsConfig { source })?;
365        let intent_promoter_config = intent_promoter_config
366            .unwrap_or_else(|| IntentPromoterConfig::from_jobs_config(&config));
367        if intent_promoter_enabled {
368            intent_promoter_config
369                .validate()
370                .map_err(|source| RuntimeError::InvalidJobsConfig { source })?;
371        }
372
373        let registry = match registry_selection {
374            Some(RegistrySelection::Direct(registry) | RegistrySelection::Catalog(registry)) => {
375                registry
376            }
377            Some(RegistrySelection::Mixed) => return Err(RuntimeError::MixedRegistrySources),
378            None if worker_enabled || reaper_enabled => {
379                return Err(RuntimeError::MissingRegistry {
380                    worker_enabled,
381                    reaper_enabled,
382                });
383            }
384            None => JobRegistry::new(),
385        };
386
387        let (shutdown, shutdown_rx) = ShutdownSignal::channel();
388        let mut tasks = TaskGroup::new();
389        let observers = JobLifecycleObservers::from_arc_observers(observers);
390
391        if intent_promoter_enabled {
392            tasks.spawn_on(&runtime, INTENT_PROMOTER_TASK, {
393                let pool = pool.clone();
394                let registry = registry.clone();
395                let shutdown_rx = shutdown_rx.clone();
396                async move {
397                    crate::intent_promoter::run_intent_promoter_loop_with_config(
398                        pool,
399                        registry,
400                        intent_promoter_config,
401                        shutdown_rx,
402                    )
403                    .await
404                }
405            });
406        }
407
408        if worker_enabled {
409            tasks.spawn_on(&runtime, WORKER_TASK, {
410                let pool = pool.clone();
411                let registry = registry.clone();
412                let config = config.clone();
413                let shutdown_rx = shutdown_rx.clone();
414                let observers = observers.clone();
415                async move {
416                    crate::worker::run_worker_loop_with_observer(
417                        pool,
418                        registry,
419                        config,
420                        shutdown_rx,
421                        observers,
422                    )
423                    .await
424                }
425            });
426        }
427
428        if scheduler_enabled {
429            tasks.spawn_on(&runtime, SCHEDULER_TASK, {
430                let pool = pool.clone();
431                let config = config.clone();
432                let shutdown_rx = shutdown_rx.clone();
433                async move { run_scheduler_loop(pool, config, shutdown_rx).await }
434            });
435        }
436
437        if reaper_enabled {
438            let pool = pool.clone();
439            let registry = registry.clone();
440            let config = config.clone();
441            let shutdown_rx = shutdown_rx.clone();
442            let observers = observers.clone();
443            tasks.spawn_on(&runtime, REAPER_TASK, async move {
444                crate::reaper::run_reaper_loop_with_observer(
445                    pool,
446                    registry,
447                    config,
448                    shutdown_rx,
449                    observers,
450                )
451                .await
452            });
453        }
454
455        Ok(Supervisor { shutdown, tasks })
456    }
457}
458
459impl SupervisorShutdown {
460    /// Requests graceful shutdown of all loops watched by the supervisor.
461    pub fn request_shutdown(&self) {
462        self.handle.request();
463    }
464
465    /// Returns whether shutdown has been requested.
466    #[must_use]
467    pub fn is_shutdown_requested(&self) -> bool {
468        self.handle.is_requested()
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use std::time::Duration;
475
476    use async_trait::async_trait;
477    use runledger_core::jobs::{JobCompletion, JobContext, JobFailure, JobHandler, JobType};
478    use serde_json::Value;
479    use sqlx::postgres::PgPoolOptions;
480    use tokio::time::timeout;
481
482    use super::*;
483
484    const UNUSED_LAZY_POOL_URL: &str = "postgres://postgres:postgres@127.0.0.1:65535/runledger";
485
486    struct RegistrySelectionHandler(&'static str);
487
488    #[async_trait]
489    impl JobHandler for RegistrySelectionHandler {
490        fn job_type(&self) -> JobType<'static> {
491            JobType::new(self.0)
492        }
493
494        async fn execute(
495            &self,
496            _context: JobContext,
497            _payload: Value,
498        ) -> std::result::Result<JobCompletion, JobFailure> {
499            Ok(JobCompletion::success())
500        }
501    }
502
503    fn lazy_pool() -> runledger_postgres::DbPool {
504        PgPoolOptions::new()
505            // The disable-only tests never acquire this pool; this URL is only
506            // a valid PgPool value for supervisor wiring assertions.
507            .connect_lazy(UNUSED_LAZY_POOL_URL)
508            .expect("construct lazy pool")
509    }
510
511    fn test_config() -> JobsConfig {
512        JobsConfig {
513            worker_id: "supervisor-test-worker".to_string(),
514            poll_interval: Duration::from_millis(25),
515            claim_batch_size: 4,
516            lease_ttl_seconds: 10,
517            max_global_concurrency: 4,
518            reaper_interval: Duration::from_millis(50),
519            schedule_poll_interval: Duration::from_millis(50),
520            reaper_retry_delay_ms: 1_000,
521        }
522    }
523
524    fn empty_builder(pool: &runledger_postgres::DbPool) -> SupervisorBuilder<'_> {
525        Supervisor::builder(pool, test_config()).expect("supervisor builder has runtime")
526    }
527
528    fn registry_with(job_type: &'static str) -> JobRegistry {
529        let mut registry = JobRegistry::new();
530        registry.register(RegistrySelectionHandler(job_type));
531        registry
532    }
533
534    fn catalog_with(job_type: &'static str) -> JobCatalog {
535        JobCatalog::new().handler(RegistrySelectionHandler(job_type))
536    }
537
538    fn missing_registry_flags(builder: SupervisorBuilder<'_>) -> (bool, bool) {
539        match builder.build() {
540            Err(RuntimeError::MissingRegistry {
541                worker_enabled,
542                reaper_enabled,
543            }) => (worker_enabled, reaper_enabled),
544            Ok(_) => panic!("missing registry should be a build error"),
545            Err(other) => panic!("expected missing registry error, got {other:?}"),
546        }
547    }
548
549    fn task_names(supervisor: &Supervisor) -> Vec<&'static str> {
550        supervisor.tasks.names_for_tests()
551    }
552
553    async fn abort_supervisor_tasks(mut supervisor: Supervisor) {
554        supervisor.tasks.abort_all_for_tests().await;
555    }
556
557    #[tokio::test]
558    async fn builder_defaults_enable_all_loops() {
559        let pool = lazy_pool();
560        let builder = empty_builder(&pool);
561
562        assert!(builder.worker_enabled);
563        assert!(builder.intent_promoter_enabled);
564        assert_eq!(builder.intent_promoter_config, None);
565        assert!(builder.scheduler_enabled);
566        assert!(builder.reaper_enabled);
567        assert!(builder.registry_selection.is_none());
568    }
569
570    #[tokio::test]
571    async fn environment_builder_explicitly_configures_intent_promoter() {
572        let pool = lazy_pool();
573        let builder = Supervisor::builder_from_env(&pool).expect("build supervisor from env");
574
575        assert!(builder.intent_promoter_config.is_some());
576    }
577
578    #[tokio::test]
579    async fn builder_accepts_registry_for_worker_and_reaper_loops() {
580        let pool = lazy_pool();
581        let builder = empty_builder(&pool).with_registry(JobRegistry::new());
582
583        assert!(matches!(
584            builder.registry_selection,
585            Some(RegistrySelection::Direct(_))
586        ));
587    }
588
589    #[derive(Clone, Copy, Debug)]
590    enum SelectionState {
591        Unset,
592        Direct,
593        Catalog,
594        Mixed,
595    }
596
597    #[derive(Clone, Copy, Debug)]
598    enum SelectionInput {
599        Direct,
600        Catalog,
601    }
602
603    #[derive(Clone, Copy, Debug)]
604    enum ExpectedSelection {
605        Direct,
606        Catalog,
607        Mixed,
608    }
609
610    #[tokio::test]
611    async fn registry_selection_transition_table_is_complete() {
612        let pool = lazy_pool();
613        let cases = [
614            (
615                SelectionState::Unset,
616                SelectionInput::Direct,
617                ExpectedSelection::Direct,
618            ),
619            (
620                SelectionState::Unset,
621                SelectionInput::Catalog,
622                ExpectedSelection::Catalog,
623            ),
624            (
625                SelectionState::Direct,
626                SelectionInput::Direct,
627                ExpectedSelection::Direct,
628            ),
629            (
630                SelectionState::Direct,
631                SelectionInput::Catalog,
632                ExpectedSelection::Mixed,
633            ),
634            (
635                SelectionState::Catalog,
636                SelectionInput::Direct,
637                ExpectedSelection::Mixed,
638            ),
639            (
640                SelectionState::Catalog,
641                SelectionInput::Catalog,
642                ExpectedSelection::Catalog,
643            ),
644            (
645                SelectionState::Mixed,
646                SelectionInput::Direct,
647                ExpectedSelection::Mixed,
648            ),
649            (
650                SelectionState::Mixed,
651                SelectionInput::Catalog,
652                ExpectedSelection::Mixed,
653            ),
654        ];
655
656        for (state, input, expected) in cases {
657            let builder = match state {
658                SelectionState::Unset => empty_builder(&pool),
659                SelectionState::Direct => {
660                    empty_builder(&pool).with_registry(registry_with("jobs.selection.previous"))
661                }
662                SelectionState::Catalog => {
663                    empty_builder(&pool).with_catalog(catalog_with("jobs.selection.previous"))
664                }
665                SelectionState::Mixed => empty_builder(&pool)
666                    .with_registry(registry_with("jobs.selection.previous"))
667                    .with_catalog(catalog_with("jobs.selection.mixed")),
668            };
669            let builder = match input {
670                SelectionInput::Direct => {
671                    builder.with_registry(registry_with("jobs.selection.current"))
672                }
673                SelectionInput::Catalog => {
674                    builder.with_catalog(catalog_with("jobs.selection.current"))
675                }
676            };
677
678            match (&builder.registry_selection, expected) {
679                (Some(RegistrySelection::Direct(registry)), ExpectedSelection::Direct)
680                | (Some(RegistrySelection::Catalog(registry)), ExpectedSelection::Catalog) => {
681                    assert_eq!(
682                        registry.registered_types(),
683                        vec![JobType::new("jobs.selection.current")],
684                        "same-source selection should use the latest value for {state:?} + {input:?}"
685                    );
686                }
687                (Some(RegistrySelection::Mixed), ExpectedSelection::Mixed) => {}
688                _ => panic!(
689                    "unexpected registry selection for transition {state:?} + {input:?}: expected {expected:?}"
690                ),
691            }
692        }
693    }
694
695    #[tokio::test]
696    async fn builder_rejects_mixed_registry_sources() {
697        let pool = lazy_pool();
698        let registry_then_catalog = empty_builder(&pool)
699            .with_registry(JobRegistry::new())
700            .with_catalog(JobCatalog::new())
701            .disable_worker()
702            .disable_reaper()
703            .build();
704        let Err(registry_then_catalog) = registry_then_catalog else {
705            panic!("mixed registry sources should be rejected");
706        };
707        assert!(matches!(
708            registry_then_catalog,
709            RuntimeError::MixedRegistrySources
710        ));
711
712        let catalog_then_registry = empty_builder(&pool)
713            .with_catalog(JobCatalog::new())
714            .with_registry(JobRegistry::new())
715            .disable_worker()
716            .disable_reaper()
717            .build();
718        let Err(catalog_then_registry) = catalog_then_registry else {
719            panic!("mixed registry sources should be rejected");
720        };
721        assert!(matches!(
722            catalog_then_registry,
723            RuntimeError::MixedRegistrySources
724        ));
725    }
726
727    #[tokio::test]
728    async fn builder_validates_config_before_rejecting_mixed_registry_sources() {
729        let pool = lazy_pool();
730        let mut invalid_jobs_config = test_config();
731        invalid_jobs_config.claim_batch_size = 0;
732        let invalid_jobs = Supervisor::builder(&pool, invalid_jobs_config)
733            .expect("supervisor builder has runtime")
734            .with_registry(JobRegistry::new())
735            .with_catalog(JobCatalog::new())
736            .build();
737        assert!(matches!(
738            invalid_jobs,
739            Err(RuntimeError::InvalidJobsConfig {
740                source: crate::config::JobsConfigValidationError::InvalidClaimBatchSize {
741                    actual: 0
742                }
743            })
744        ));
745
746        let invalid_promoter = empty_builder(&pool)
747            .with_registry(JobRegistry::new())
748            .with_catalog(JobCatalog::new())
749            .with_intent_promoter_config(IntentPromoterConfig::new(Duration::ZERO, 1))
750            .build();
751        assert!(matches!(
752            invalid_promoter,
753            Err(RuntimeError::InvalidJobsConfig {
754                source: crate::config::JobsConfigValidationError::ZeroPollInterval
755            })
756        ));
757    }
758
759    #[tokio::test]
760    async fn builder_requires_registry_when_worker_or_reaper_is_enabled() {
761        let pool = lazy_pool();
762
763        assert_eq!(missing_registry_flags(empty_builder(&pool)), (true, true));
764        assert_eq!(
765            missing_registry_flags(empty_builder(&pool).disable_scheduler().disable_reaper()),
766            (true, false)
767        );
768        assert_eq!(
769            missing_registry_flags(empty_builder(&pool).disable_worker().disable_scheduler()),
770            (false, true)
771        );
772    }
773
774    #[tokio::test]
775    async fn builder_rejects_invalid_direct_config_values_before_spawning_loops() {
776        let cases = [
777            {
778                let mut config = test_config();
779                config.max_global_concurrency = 0;
780                (
781                    config,
782                    crate::config::JobsConfigValidationError::InvalidMaxGlobalConcurrency,
783                )
784            },
785            {
786                let mut config = test_config();
787                config.claim_batch_size = 0;
788                (
789                    config,
790                    crate::config::JobsConfigValidationError::InvalidClaimBatchSize { actual: 0 },
791                )
792            },
793            {
794                let mut config = test_config();
795                config.lease_ttl_seconds = 0;
796                (
797                    config,
798                    crate::config::JobsConfigValidationError::InvalidLeaseTtlSeconds { actual: 0 },
799                )
800            },
801        ];
802
803        for (config, expected) in cases {
804            let pool = lazy_pool();
805            let result = Supervisor::builder(&pool, config)
806                .expect("supervisor builder has runtime")
807                .disable_worker()
808                .disable_scheduler()
809                .disable_reaper()
810                .build();
811            let Err(error) = result else {
812                panic!("invalid direct config should be rejected");
813            };
814
815            match error {
816                RuntimeError::InvalidJobsConfig { source } => {
817                    assert_eq!(source, expected);
818                }
819                other => panic!("expected invalid jobs config error, got {other:?}"),
820            }
821        }
822    }
823
824    #[test]
825    fn builder_requires_tokio_runtime_before_cloning_pool() {
826        let runtime = tokio::runtime::Runtime::new().expect("construct Tokio runtime");
827        let pool = runtime.block_on(async { lazy_pool() });
828        let error = match Supervisor::builder(&pool, test_config()) {
829            Err(error) => error,
830            Ok(builder) => {
831                drop(builder);
832                runtime.block_on(async {
833                    pool.close().await;
834                });
835                std::mem::forget(pool);
836                panic!("missing Tokio runtime should be a builder error");
837            }
838        };
839
840        // The builder was intentionally called outside a runtime to exercise
841        // the pre-clone runtime check. Close and drop the pool inside the
842        // temporary runtime so sqlx's own drop precondition does not contaminate
843        // this assertion.
844        runtime.block_on(async {
845            pool.close().await;
846        });
847        std::mem::forget(pool);
848        match error {
849            RuntimeError::MissingTokioRuntime { .. } => {}
850            other => panic!("expected missing Tokio runtime error, got {other:?}"),
851        }
852    }
853
854    #[tokio::test]
855    async fn builder_can_disable_each_loop() {
856        let pool = lazy_pool();
857        let builder = empty_builder(&pool)
858            .disable_worker()
859            .disable_scheduler()
860            .disable_reaper();
861
862        assert!(!builder.worker_enabled);
863        assert!(!builder.intent_promoter_enabled);
864        assert!(!builder.scheduler_enabled);
865        assert!(!builder.reaper_enabled);
866
867        let worker_without_promoter = empty_builder(&pool).disable_intent_promoter();
868        assert!(worker_without_promoter.worker_enabled);
869        assert!(!worker_without_promoter.intent_promoter_enabled);
870
871        let promoter_config = IntentPromoterConfig::new(Duration::from_secs(2), 7);
872        let customized = empty_builder(&pool).with_intent_promoter_config(promoter_config);
873        assert_eq!(customized.intent_promoter_config, Some(promoter_config));
874    }
875
876    #[tokio::test]
877    async fn builder_spawns_only_enabled_tasks() {
878        let pool = lazy_pool();
879
880        let all_disabled = empty_builder(&pool)
881            .disable_worker()
882            .disable_scheduler()
883            .disable_reaper()
884            .build()
885            .expect("all-disabled supervisor should build");
886        assert_eq!(task_names(&all_disabled), Vec::<&'static str>::new());
887        abort_supervisor_tasks(all_disabled).await;
888
889        let scheduler_only = empty_builder(&pool)
890            .disable_worker()
891            .disable_reaper()
892            .build()
893            .expect("scheduler-only supervisor should not require registry");
894        assert_eq!(task_names(&scheduler_only), vec![SCHEDULER_TASK]);
895        abort_supervisor_tasks(scheduler_only).await;
896
897        let worker_only = empty_builder(&pool)
898            .with_registry(JobRegistry::new())
899            .disable_scheduler()
900            .disable_reaper()
901            .build()
902            .expect("worker-only supervisor should build with registry");
903        assert_eq!(
904            task_names(&worker_only),
905            vec![INTENT_PROMOTER_TASK, WORKER_TASK]
906        );
907        abort_supervisor_tasks(worker_only).await;
908
909        let worker_without_promoter = empty_builder(&pool)
910            .with_registry(JobRegistry::new())
911            .disable_intent_promoter()
912            .disable_scheduler()
913            .disable_reaper()
914            .build()
915            .expect("worker should run without intent promotion");
916        assert_eq!(task_names(&worker_without_promoter), vec![WORKER_TASK]);
917        abort_supervisor_tasks(worker_without_promoter).await;
918
919        let reaper_only = empty_builder(&pool)
920            .with_registry(JobRegistry::new())
921            .disable_worker()
922            .disable_scheduler()
923            .build()
924            .expect("reaper-only supervisor should build with registry");
925        assert_eq!(task_names(&reaper_only), vec![REAPER_TASK]);
926        abort_supervisor_tasks(reaper_only).await;
927
928        let all_enabled = empty_builder(&pool)
929            .with_registry(JobRegistry::new())
930            .build()
931            .expect("all-enabled supervisor should build with registry");
932        assert_eq!(
933            task_names(&all_enabled),
934            vec![
935                INTENT_PROMOTER_TASK,
936                WORKER_TASK,
937                SCHEDULER_TASK,
938                REAPER_TASK
939            ]
940        );
941        abort_supervisor_tasks(all_enabled).await;
942    }
943
944    #[tokio::test]
945    async fn all_disabled_supervisor_join_and_shutdown_succeed() {
946        Supervisor::builder(&lazy_pool(), test_config())
947            .expect("supervisor builder has runtime")
948            .disable_worker()
949            .disable_scheduler()
950            .disable_reaper()
951            .build()
952            .expect("all-disabled supervisor should build")
953            .join()
954            .await
955            .expect("all-disabled supervisor should join");
956
957        Supervisor::builder(&lazy_pool(), test_config())
958            .expect("supervisor builder has runtime")
959            .disable_worker()
960            .disable_scheduler()
961            .disable_reaper()
962            .build()
963            .expect("all-disabled supervisor should build")
964            .shutdown()
965            .await
966            .expect("all-disabled supervisor should shut down");
967    }
968
969    #[tokio::test]
970    async fn repeated_shutdown_handle_requests_are_observable_before_join() {
971        let supervisor = Supervisor::builder(&lazy_pool(), test_config())
972            .expect("supervisor builder has runtime")
973            .disable_worker()
974            .disable_scheduler()
975            .disable_reaper()
976            .build()
977            .expect("all-disabled supervisor should build");
978        let shutdown = supervisor.shutdown_handle();
979        let cloned_shutdown = shutdown.clone();
980
981        cloned_shutdown.request_shutdown();
982        shutdown.request_shutdown();
983        supervisor.request_shutdown();
984
985        assert!(shutdown.is_shutdown_requested());
986        assert!(supervisor.is_shutdown_requested());
987        supervisor
988            .join()
989            .await
990            .expect("supervisor should join after shutdown handle request");
991    }
992
993    #[tokio::test]
994    async fn run_until_shutdown_with_no_tasks_waits_for_signal() {
995        let supervisor = Supervisor::builder(&lazy_pool(), test_config())
996            .expect("supervisor builder has runtime")
997            .disable_worker()
998            .disable_scheduler()
999            .disable_reaper()
1000            .build()
1001            .expect("all-disabled supervisor should build");
1002        let (signal_tx, signal_rx) = tokio::sync::oneshot::channel();
1003        let mut run = tokio::spawn(supervisor.run_until_shutdown(
1004            async move {
1005                signal_rx.await.expect("shutdown signal should be sent");
1006            },
1007            Duration::from_secs(1),
1008        ));
1009
1010        assert!(
1011            timeout(Duration::from_millis(50), &mut run).await.is_err(),
1012            "all-disabled supervisor should wait for the shutdown signal"
1013        );
1014
1015        signal_tx.send(()).expect("signal receiver should be alive");
1016        run.await
1017            .expect("run-until-shutdown task should join")
1018            .expect("all-disabled supervisor should complete after signal");
1019    }
1020}