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#[must_use]
34pub struct Supervisor {
35 shutdown: ShutdownSignal,
36 tasks: TaskGroup,
37}
38
39#[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#[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 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 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 #[must_use]
139 pub fn shutdown_handle(&self) -> SupervisorShutdown {
140 SupervisorShutdown {
141 handle: self.shutdown.handle(),
142 }
143 }
144
145 pub fn request_shutdown(&self) {
147 self.shutdown.request();
148 }
149
150 #[must_use]
153 pub fn is_shutdown_requested(&self) -> bool {
154 self.shutdown.is_requested()
155 }
156
157 pub async fn join(mut self) -> Result<()> {
167 let shutdown = self.shutdown.clone();
168 self.tasks.join(&shutdown).await
169 }
170
171 pub async fn shutdown(mut self) -> Result<()> {
180 let shutdown = self.shutdown.clone();
181 self.tasks.shutdown(&shutdown).await
182 }
183
184 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 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 self.request_shutdown();
249 }
250}
251
252impl<'a> SupervisorBuilder<'a> {
253 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 pub fn request_shutdown(&self) {
462 self.handle.request();
463 }
464
465 #[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 .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 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}