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::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 SCHEDULER_TASK: &str = "scheduler";
20const REAPER_TASK: &str = "reaper";
21
22#[must_use]
33pub struct Supervisor {
34 shutdown: ShutdownSignal,
35 tasks: TaskGroup,
36}
37
38#[must_use]
44pub struct SupervisorBuilder<'a> {
45 pool: &'a runledger_postgres::DbPool,
46 runtime: Handle,
47 registry: Option<JobRegistry>,
48 registry_source: Option<RegistrySource>,
49 mixed_registry_sources: bool,
50 config: JobsConfig,
51 observers: Vec<Arc<dyn JobLifecycleObserver>>,
52 worker_enabled: bool,
53 scheduler_enabled: bool,
54 reaper_enabled: bool,
55}
56
57#[derive(Clone)]
59pub struct SupervisorShutdown {
60 handle: ShutdownHandle,
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64enum RegistrySource {
65 Registry,
66 Catalog,
67}
68
69impl Supervisor {
70 pub fn builder(
76 pool: &runledger_postgres::DbPool,
77 config: JobsConfig,
78 ) -> std::result::Result<SupervisorBuilder<'_>, RuntimeError> {
79 let runtime =
80 Handle::try_current().map_err(|source| RuntimeError::MissingTokioRuntime { source })?;
81
82 Ok(SupervisorBuilder {
83 pool,
84 runtime,
85 registry: None,
86 registry_source: None,
87 mixed_registry_sources: false,
88 config,
89 observers: Vec::new(),
90 worker_enabled: true,
91 scheduler_enabled: true,
92 reaper_enabled: true,
93 })
94 }
95
96 #[must_use]
99 pub fn shutdown_handle(&self) -> SupervisorShutdown {
100 SupervisorShutdown {
101 handle: self.shutdown.handle(),
102 }
103 }
104
105 pub fn request_shutdown(&self) {
107 self.shutdown.request();
108 }
109
110 #[must_use]
113 pub fn is_shutdown_requested(&self) -> bool {
114 self.shutdown.is_requested()
115 }
116
117 pub async fn join(mut self) -> Result<()> {
127 let shutdown = self.shutdown.clone();
128 self.tasks.join(&shutdown).await
129 }
130
131 pub async fn shutdown(mut self) -> Result<()> {
140 let shutdown = self.shutdown.clone();
141 self.tasks.shutdown(&shutdown).await
142 }
143
144 pub async fn run_until_shutdown<F>(mut self, shutdown: F, timeout: Duration) -> Result<()>
168 where
169 F: Future<Output = ()>,
170 {
171 let shutdown_signal = self.shutdown.clone();
172 self.tasks
173 .run_until_shutdown(shutdown, timeout, &shutdown_signal)
174 .await
175 }
176
177 pub async fn shutdown_with_timeout(mut self, timeout: Duration) -> Result<()> {
194 let shutdown = self.shutdown.clone();
195 self.tasks.shutdown_with_timeout(timeout, &shutdown).await
196 }
197}
198
199impl Drop for Supervisor {
200 fn drop(&mut self) {
201 if !self.tasks.is_empty() {
202 warn!(
203 task_count = self.tasks.len(),
204 "dropping jobs runtime supervisor before joining tasks; tasks may continue detached after shutdown is requested and later panics will not be observed"
205 );
206 }
207 self.request_shutdown();
209 }
210}
211
212impl<'a> SupervisorBuilder<'a> {
213 #[must_use = "builder methods return an updated builder value"]
218 pub fn with_registry(mut self, registry: JobRegistry) -> Self {
219 self.mixed_registry_sources |= self.registry_source == Some(RegistrySource::Catalog);
220 self.registry_source = Some(RegistrySource::Registry);
221 self.registry = Some(registry);
222 self
223 }
224
225 #[must_use = "builder methods return an updated builder value"]
238 pub fn with_catalog(mut self, catalog: impl Borrow<JobCatalog>) -> Self {
239 self.mixed_registry_sources |= self.registry_source == Some(RegistrySource::Registry);
240 self.registry_source = Some(RegistrySource::Catalog);
241 self.registry = Some(catalog.borrow().to_registry());
242 self
243 }
244
245 #[must_use = "builder methods return an updated builder value"]
247 pub fn disable_worker(mut self) -> Self {
248 self.worker_enabled = false;
249 self
250 }
251
252 #[must_use = "builder methods return an updated builder value"]
254 pub fn disable_scheduler(mut self) -> Self {
255 self.scheduler_enabled = false;
256 self
257 }
258
259 #[must_use = "builder methods return an updated builder value"]
261 pub fn disable_reaper(mut self) -> Self {
262 self.reaper_enabled = false;
263 self
264 }
265
266 #[must_use = "builder methods return an updated builder value"]
271 pub fn with_job_lifecycle_observer(
272 mut self,
273 observer: impl JobLifecycleObserver + 'static,
274 ) -> Self {
275 self.observers.push(Arc::new(observer));
276 self
277 }
278
279 pub fn build(self) -> std::result::Result<Supervisor, RuntimeError> {
284 let Self {
285 pool,
286 runtime,
287 registry,
288 registry_source: _,
289 mixed_registry_sources,
290 config,
291 observers,
292 worker_enabled,
293 scheduler_enabled,
294 reaper_enabled,
295 } = self;
296
297 config
298 .validate()
299 .map_err(|source| RuntimeError::InvalidJobsConfig { source })?;
300
301 if mixed_registry_sources {
302 return Err(RuntimeError::MixedRegistrySources);
303 }
304
305 let registry = match registry {
306 Some(registry) => registry,
307 None if worker_enabled || reaper_enabled => {
308 return Err(RuntimeError::MissingRegistry {
309 worker_enabled,
310 reaper_enabled,
311 });
312 }
313 None => JobRegistry::new(),
314 };
315
316 let (shutdown, shutdown_rx) = ShutdownSignal::channel();
317 let mut tasks = TaskGroup::new();
318 let observers = JobLifecycleObservers::from_arc_observers(observers);
319
320 if worker_enabled {
321 tasks.spawn_on(&runtime, WORKER_TASK, {
322 let pool = pool.clone();
323 let registry = registry.clone();
324 let config = config.clone();
325 let shutdown_rx = shutdown_rx.clone();
326 let observers = observers.clone();
327 async move {
328 crate::worker::run_worker_loop_with_observer(
329 pool,
330 registry,
331 config,
332 shutdown_rx,
333 observers,
334 )
335 .await
336 }
337 });
338 }
339
340 if scheduler_enabled {
341 tasks.spawn_on(&runtime, SCHEDULER_TASK, {
342 let pool = pool.clone();
343 let config = config.clone();
344 let shutdown_rx = shutdown_rx.clone();
345 async move { run_scheduler_loop(pool, config, shutdown_rx).await }
346 });
347 }
348
349 if reaper_enabled {
350 let pool = pool.clone();
351 let registry = registry.clone();
352 let config = config.clone();
353 let shutdown_rx = shutdown_rx.clone();
354 let observers = observers.clone();
355 tasks.spawn_on(&runtime, REAPER_TASK, async move {
356 crate::reaper::run_reaper_loop_with_observer(
357 pool,
358 registry,
359 config,
360 shutdown_rx,
361 observers,
362 )
363 .await
364 });
365 }
366
367 Ok(Supervisor { shutdown, tasks })
368 }
369}
370
371impl SupervisorShutdown {
372 pub fn request_shutdown(&self) {
374 self.handle.request();
375 }
376
377 #[must_use]
379 pub fn is_shutdown_requested(&self) -> bool {
380 self.handle.is_requested()
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use std::time::Duration;
387
388 use sqlx::postgres::PgPoolOptions;
389 use tokio::time::timeout;
390
391 use super::*;
392
393 const UNUSED_LAZY_POOL_URL: &str = "postgres://postgres:postgres@127.0.0.1:65535/runledger";
394
395 fn lazy_pool() -> runledger_postgres::DbPool {
396 PgPoolOptions::new()
397 .connect_lazy(UNUSED_LAZY_POOL_URL)
400 .expect("construct lazy pool")
401 }
402
403 fn test_config() -> JobsConfig {
404 JobsConfig {
405 worker_id: "supervisor-test-worker".to_string(),
406 poll_interval: Duration::from_millis(25),
407 claim_batch_size: 4,
408 lease_ttl_seconds: 10,
409 max_global_concurrency: 4,
410 reaper_interval: Duration::from_millis(50),
411 schedule_poll_interval: Duration::from_millis(50),
412 reaper_retry_delay_ms: 1_000,
413 }
414 }
415
416 fn empty_builder(pool: &runledger_postgres::DbPool) -> SupervisorBuilder<'_> {
417 Supervisor::builder(pool, test_config()).expect("supervisor builder has runtime")
418 }
419
420 fn missing_registry_flags(builder: SupervisorBuilder<'_>) -> (bool, bool) {
421 match builder.build() {
422 Err(RuntimeError::MissingRegistry {
423 worker_enabled,
424 reaper_enabled,
425 }) => (worker_enabled, reaper_enabled),
426 Ok(_) => panic!("missing registry should be a build error"),
427 Err(other) => panic!("expected missing registry error, got {other:?}"),
428 }
429 }
430
431 fn task_names(supervisor: &Supervisor) -> Vec<&'static str> {
432 supervisor.tasks.names_for_tests()
433 }
434
435 async fn abort_supervisor_tasks(mut supervisor: Supervisor) {
436 supervisor.tasks.abort_all_for_tests().await;
437 }
438
439 #[tokio::test]
440 async fn builder_defaults_enable_all_loops() {
441 let pool = lazy_pool();
442 let builder = empty_builder(&pool);
443
444 assert!(builder.worker_enabled);
445 assert!(builder.scheduler_enabled);
446 assert!(builder.reaper_enabled);
447 assert!(builder.registry.is_none());
448 assert_eq!(builder.registry_source, None);
449 assert!(!builder.mixed_registry_sources);
450 }
451
452 #[tokio::test]
453 async fn builder_accepts_registry_for_worker_and_reaper_loops() {
454 let pool = lazy_pool();
455 let builder = empty_builder(&pool).with_registry(JobRegistry::new());
456
457 assert!(builder.registry.is_some());
458 assert_eq!(builder.registry_source, Some(RegistrySource::Registry));
459 assert!(!builder.mixed_registry_sources);
460 }
461
462 #[tokio::test]
463 async fn builder_rejects_mixed_registry_sources() {
464 let pool = lazy_pool();
465 let registry_then_catalog = empty_builder(&pool)
466 .with_registry(JobRegistry::new())
467 .with_catalog(JobCatalog::new())
468 .disable_worker()
469 .disable_reaper()
470 .build();
471 let Err(registry_then_catalog) = registry_then_catalog else {
472 panic!("mixed registry sources should be rejected");
473 };
474 assert!(matches!(
475 registry_then_catalog,
476 RuntimeError::MixedRegistrySources
477 ));
478
479 let catalog_then_registry = empty_builder(&pool)
480 .with_catalog(JobCatalog::new())
481 .with_registry(JobRegistry::new())
482 .disable_worker()
483 .disable_reaper()
484 .build();
485 let Err(catalog_then_registry) = catalog_then_registry else {
486 panic!("mixed registry sources should be rejected");
487 };
488 assert!(matches!(
489 catalog_then_registry,
490 RuntimeError::MixedRegistrySources
491 ));
492 }
493
494 #[tokio::test]
495 async fn builder_requires_registry_when_worker_or_reaper_is_enabled() {
496 let pool = lazy_pool();
497
498 assert_eq!(missing_registry_flags(empty_builder(&pool)), (true, true));
499 assert_eq!(
500 missing_registry_flags(empty_builder(&pool).disable_scheduler().disable_reaper()),
501 (true, false)
502 );
503 assert_eq!(
504 missing_registry_flags(empty_builder(&pool).disable_worker().disable_scheduler()),
505 (false, true)
506 );
507 }
508
509 #[tokio::test]
510 async fn builder_rejects_invalid_direct_config_values_before_spawning_loops() {
511 let cases = [
512 {
513 let mut config = test_config();
514 config.max_global_concurrency = 0;
515 (
516 config,
517 crate::config::JobsConfigValidationError::InvalidMaxGlobalConcurrency,
518 )
519 },
520 {
521 let mut config = test_config();
522 config.claim_batch_size = 0;
523 (
524 config,
525 crate::config::JobsConfigValidationError::InvalidClaimBatchSize { actual: 0 },
526 )
527 },
528 {
529 let mut config = test_config();
530 config.lease_ttl_seconds = 0;
531 (
532 config,
533 crate::config::JobsConfigValidationError::InvalidLeaseTtlSeconds { actual: 0 },
534 )
535 },
536 ];
537
538 for (config, expected) in cases {
539 let pool = lazy_pool();
540 let result = Supervisor::builder(&pool, config)
541 .expect("supervisor builder has runtime")
542 .disable_worker()
543 .disable_scheduler()
544 .disable_reaper()
545 .build();
546 let Err(error) = result else {
547 panic!("invalid direct config should be rejected");
548 };
549
550 match error {
551 RuntimeError::InvalidJobsConfig { source } => {
552 assert_eq!(source, expected);
553 }
554 other => panic!("expected invalid jobs config error, got {other:?}"),
555 }
556 }
557 }
558
559 #[test]
560 fn builder_requires_tokio_runtime_before_cloning_pool() {
561 let runtime = tokio::runtime::Runtime::new().expect("construct Tokio runtime");
562 let pool = runtime.block_on(async { lazy_pool() });
563 let error = match Supervisor::builder(&pool, test_config()) {
564 Err(error) => error,
565 Ok(builder) => {
566 drop(builder);
567 runtime.block_on(async {
568 pool.close().await;
569 });
570 std::mem::forget(pool);
571 panic!("missing Tokio runtime should be a builder error");
572 }
573 };
574
575 runtime.block_on(async {
580 pool.close().await;
581 });
582 std::mem::forget(pool);
583 match error {
584 RuntimeError::MissingTokioRuntime { .. } => {}
585 other => panic!("expected missing Tokio runtime error, got {other:?}"),
586 }
587 }
588
589 #[tokio::test]
590 async fn builder_can_disable_each_loop() {
591 let pool = lazy_pool();
592 let builder = empty_builder(&pool)
593 .disable_worker()
594 .disable_scheduler()
595 .disable_reaper();
596
597 assert!(!builder.worker_enabled);
598 assert!(!builder.scheduler_enabled);
599 assert!(!builder.reaper_enabled);
600 }
601
602 #[tokio::test]
603 async fn builder_spawns_only_enabled_tasks() {
604 let pool = lazy_pool();
605
606 let all_disabled = empty_builder(&pool)
607 .disable_worker()
608 .disable_scheduler()
609 .disable_reaper()
610 .build()
611 .expect("all-disabled supervisor should build");
612 assert_eq!(task_names(&all_disabled), Vec::<&'static str>::new());
613 abort_supervisor_tasks(all_disabled).await;
614
615 let scheduler_only = empty_builder(&pool)
616 .disable_worker()
617 .disable_reaper()
618 .build()
619 .expect("scheduler-only supervisor should not require registry");
620 assert_eq!(task_names(&scheduler_only), vec![SCHEDULER_TASK]);
621 abort_supervisor_tasks(scheduler_only).await;
622
623 let worker_only = empty_builder(&pool)
624 .with_registry(JobRegistry::new())
625 .disable_scheduler()
626 .disable_reaper()
627 .build()
628 .expect("worker-only supervisor should build with registry");
629 assert_eq!(task_names(&worker_only), vec![WORKER_TASK]);
630 abort_supervisor_tasks(worker_only).await;
631
632 let reaper_only = empty_builder(&pool)
633 .with_registry(JobRegistry::new())
634 .disable_worker()
635 .disable_scheduler()
636 .build()
637 .expect("reaper-only supervisor should build with registry");
638 assert_eq!(task_names(&reaper_only), vec![REAPER_TASK]);
639 abort_supervisor_tasks(reaper_only).await;
640
641 let all_enabled = empty_builder(&pool)
642 .with_registry(JobRegistry::new())
643 .build()
644 .expect("all-enabled supervisor should build with registry");
645 assert_eq!(
646 task_names(&all_enabled),
647 vec![WORKER_TASK, SCHEDULER_TASK, REAPER_TASK]
648 );
649 abort_supervisor_tasks(all_enabled).await;
650 }
651
652 #[tokio::test]
653 async fn all_disabled_supervisor_join_and_shutdown_succeed() {
654 Supervisor::builder(&lazy_pool(), test_config())
655 .expect("supervisor builder has runtime")
656 .disable_worker()
657 .disable_scheduler()
658 .disable_reaper()
659 .build()
660 .expect("all-disabled supervisor should build")
661 .join()
662 .await
663 .expect("all-disabled supervisor should join");
664
665 Supervisor::builder(&lazy_pool(), test_config())
666 .expect("supervisor builder has runtime")
667 .disable_worker()
668 .disable_scheduler()
669 .disable_reaper()
670 .build()
671 .expect("all-disabled supervisor should build")
672 .shutdown()
673 .await
674 .expect("all-disabled supervisor should shut down");
675 }
676
677 #[tokio::test]
678 async fn shutdown_handle_can_request_shutdown_before_join() {
679 let supervisor = Supervisor::builder(&lazy_pool(), test_config())
680 .expect("supervisor builder has runtime")
681 .disable_worker()
682 .disable_scheduler()
683 .disable_reaper()
684 .build()
685 .expect("all-disabled supervisor should build");
686 let shutdown = supervisor.shutdown_handle();
687 let cloned_shutdown = shutdown.clone();
688
689 cloned_shutdown.request_shutdown();
690
691 assert!(shutdown.is_shutdown_requested());
692 assert!(supervisor.is_shutdown_requested());
693 supervisor
694 .join()
695 .await
696 .expect("supervisor should join after shutdown handle request");
697 }
698
699 #[tokio::test]
700 async fn run_until_shutdown_with_no_tasks_waits_for_signal() {
701 let supervisor = Supervisor::builder(&lazy_pool(), test_config())
702 .expect("supervisor builder has runtime")
703 .disable_worker()
704 .disable_scheduler()
705 .disable_reaper()
706 .build()
707 .expect("all-disabled supervisor should build");
708 let (signal_tx, signal_rx) = tokio::sync::oneshot::channel();
709 let mut run = tokio::spawn(supervisor.run_until_shutdown(
710 async move {
711 signal_rx.await.expect("shutdown signal should be sent");
712 },
713 Duration::from_secs(1),
714 ));
715
716 assert!(
717 timeout(Duration::from_millis(50), &mut run).await.is_err(),
718 "all-disabled supervisor should wait for the shutdown signal"
719 );
720
721 signal_tx.send(()).expect("signal receiver should be alive");
722 run.await
723 .expect("run-until-shutdown task should join")
724 .expect("all-disabled supervisor should complete after signal");
725 }
726}