Skip to main content

aster_forge_runtime/
component.rs

1//! Runtime component registration primitives.
2//!
3//! This module ties together the reusable runtime registries that already live
4//! in this crate. It lets product crates describe subsystems once, then attach
5//! health checks and shutdown phases without duplicating central dispatch
6//! tables. Product crates still own resource construction, application state
7//! assembly, business-specific startup ordering, and how reports are exposed.
8//!
9//! Component dependencies are enforced by [`RuntimeComponentRegistry`] when it
10//! runs component-owned shutdown phases. Lower-level coordinators such as
11//! [`crate::ShutdownCoordinator`] remain simple ordered executors for callers
12//! that already have a fixed sequence.
13
14use std::collections::{HashMap, HashSet};
15use std::future::Future;
16use std::pin::Pin;
17use std::time::Duration;
18
19use crate::{
20    HealthCheckDescriptor, HealthCheckOptions, HealthCheckRegistry, HealthCheckScope,
21    HealthComponentReport, ShutdownPhaseReport, ShutdownPhaseStatus, ShutdownReport,
22    StartupCoordinator, StartupPhaseFailurePolicy, StartupReport, SystemHealthReport,
23};
24
25type RuntimeShutdownFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
26type RuntimeShutdownPhaseFn = dyn FnMut() -> RuntimeShutdownFuture + Send;
27
28/// Product-owned runtime component bundle.
29///
30/// A bundle is useful when registration needs to consume owned handles such as database pools,
31/// background task collections, or other shutdown-only resources. Product subsystems should expose
32/// component factory functions that return a bundle registration instead of asking entrypoints to
33/// call low-level registry functions directly.
34pub trait RuntimeComponentBundle {
35    /// Registers this bundle into the runtime component registry.
36    fn register(self, registry: &mut RuntimeComponentRegistry);
37}
38
39impl<F> RuntimeComponentBundle for F
40where
41    F: FnOnce(&mut RuntimeComponentRegistry),
42{
43    fn register(self, registry: &mut RuntimeComponentRegistry) {
44        self(registry);
45    }
46}
47
48/// Broad category for a registered runtime component.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RuntimeComponentKind {
51    /// Core process-level component.
52    Core,
53    /// Database or database connection pool component.
54    Database,
55    /// Cache component.
56    Cache,
57    /// Object storage or file storage component.
58    Storage,
59    /// Mail sender, outbox, or delivery component.
60    Mail,
61    /// Background task scheduler or worker component.
62    Tasks,
63    /// External authentication connector component.
64    ExternalAuth,
65    /// Product-specific component that does not fit another shared kind.
66    Product,
67}
68
69impl RuntimeComponentKind {
70    /// Returns a stable lowercase wire value.
71    pub const fn as_str(self) -> &'static str {
72        match self {
73            Self::Core => "core",
74            Self::Database => "database",
75            Self::Cache => "cache",
76            Self::Storage => "storage",
77            Self::Mail => "mail",
78            Self::Tasks => "tasks",
79            Self::ExternalAuth => "external_auth",
80            Self::Product => "product",
81        }
82    }
83}
84
85/// Static shutdown metadata for a registered component.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct RuntimeShutdownDescriptor {
88    /// Stable shutdown phase name.
89    pub phase_name: &'static str,
90    /// Optional phase timeout.
91    pub timeout: Option<Duration>,
92}
93
94/// Static startup metadata for a registered component.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct RuntimeStartupDescriptor {
97    /// Stable startup phase name.
98    pub phase_name: &'static str,
99    /// Failure policy used by this startup phase.
100    pub failure_policy: StartupPhaseFailurePolicy,
101}
102
103/// Static runtime task metadata for a registered component.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct RuntimeTaskDescriptor {
106    /// Stable task name used in logs, persisted runtime payloads, or admin UI.
107    pub task_name: &'static str,
108    /// Operator-facing display name.
109    pub display_name: &'static str,
110}
111
112/// Static metadata for a registered runtime component.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RuntimeComponentDescriptor {
115    /// Stable component name.
116    pub name: &'static str,
117    /// Broad component category.
118    pub kind: RuntimeComponentKind,
119    /// Stable names of components that should be initialized before this one.
120    pub dependencies: Vec<&'static str>,
121    /// Registered startup phases owned by this component.
122    pub startup: Vec<RuntimeStartupDescriptor>,
123    /// Registered health checks owned by this component.
124    pub health_checks: Vec<HealthCheckDescriptor>,
125    /// Registered runtime tasks owned by this component.
126    pub tasks: Vec<RuntimeTaskDescriptor>,
127    /// Registered shutdown phases owned by this component.
128    pub shutdown: Vec<RuntimeShutdownDescriptor>,
129}
130
131impl RuntimeComponentDescriptor {
132    fn new(name: &'static str) -> Self {
133        Self {
134            name,
135            kind: RuntimeComponentKind::Product,
136            dependencies: Vec::new(),
137            startup: Vec::new(),
138            health_checks: Vec::new(),
139            tasks: Vec::new(),
140            shutdown: Vec::new(),
141        }
142    }
143}
144
145/// Component dependency graph validation error.
146#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
147pub enum RuntimeComponentGraphError {
148    /// A component depends on a component that was never registered.
149    #[error("runtime component '{component}' depends on missing component '{dependency}'")]
150    MissingDependency {
151        /// Component declaring the dependency.
152        component: &'static str,
153        /// Missing dependency component name.
154        dependency: &'static str,
155    },
156    /// Component dependencies contain a cycle.
157    #[error("runtime component dependency cycle detected: {cycle}")]
158    Cycle {
159        /// Human-readable cycle path.
160        cycle: String,
161    },
162}
163
164struct RuntimeComponentShutdownPhase {
165    component_name: &'static str,
166    phase_name: &'static str,
167    timeout: Option<Duration>,
168    phase: Box<RuntimeShutdownPhaseFn>,
169}
170
171/// Registry for runtime component metadata and lifecycle hooks.
172#[derive(Default)]
173pub struct RuntimeComponentRegistry {
174    components: Vec<RuntimeComponentDescriptor>,
175    startup: StartupCoordinator,
176    health: HealthCheckRegistry,
177    shutdown: Vec<RuntimeComponentShutdownPhase>,
178}
179
180impl RuntimeComponentRegistry {
181    /// Creates an empty component registry.
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// Creates a registry and applies one product or subsystem registration function.
187    pub fn configured<F>(configure: F) -> Self
188    where
189        F: FnOnce(&mut Self),
190    {
191        let mut registry = Self::new();
192        registry.configure(configure);
193        registry
194    }
195
196    /// Applies a product or subsystem registration function.
197    pub fn configure<F>(&mut self, configure: F) -> &mut Self
198    where
199        F: FnOnce(&mut Self),
200    {
201        configure(self);
202        self
203    }
204
205    /// Registers one product-owned component bundle.
206    pub fn register_bundle<B>(&mut self, bundle: B) -> &mut Self
207    where
208        B: RuntimeComponentBundle,
209    {
210        bundle.register(self);
211        self
212    }
213
214    /// Registers a component health check with explicit options.
215    pub fn component_health_with_options<F, Fut>(
216        &mut self,
217        component_name: &'static str,
218        kind: RuntimeComponentKind,
219        check_name: &'static str,
220        options: HealthCheckOptions,
221        check: F,
222    ) -> &mut Self
223    where
224        F: Fn() -> Fut + Send + Sync + 'static,
225        Fut: Future<Output = HealthComponentReport> + Send + 'static,
226    {
227        self.component(component_name)
228            .kind(kind)
229            .health_with_options(check_name, options, check);
230        self
231    }
232
233    /// Registers a component startup phase.
234    pub fn component_startup<F, Fut>(
235        &mut self,
236        component_name: &'static str,
237        kind: RuntimeComponentKind,
238        phase_name: &'static str,
239        failure_policy: StartupPhaseFailurePolicy,
240        phase: F,
241    ) -> &mut Self
242    where
243        F: FnMut() -> Fut + Send + 'static,
244        Fut: Future<Output = Result<(), String>> + Send + 'static,
245    {
246        self.component(component_name)
247            .kind(kind)
248            .startup(phase_name, failure_policy, phase);
249        self
250    }
251
252    /// Registers a component-owned runtime task descriptor.
253    pub fn component_task(
254        &mut self,
255        component_name: &'static str,
256        kind: RuntimeComponentKind,
257        task_name: &'static str,
258        display_name: &'static str,
259    ) -> &mut Self {
260        self.component(component_name)
261            .kind(kind)
262            .task(task_name, display_name);
263        self
264    }
265
266    /// Registers a component shutdown phase.
267    pub fn component_shutdown<F, Fut>(
268        &mut self,
269        component_name: &'static str,
270        kind: RuntimeComponentKind,
271        phase_name: &'static str,
272        timeout: Option<Duration>,
273        phase: F,
274    ) -> &mut Self
275    where
276        F: FnMut() -> Fut + Send + 'static,
277        Fut: Future<Output = Result<(), String>> + Send + 'static,
278    {
279        self.component(component_name)
280            .kind(kind)
281            .shutdown(phase_name, timeout, phase);
282        self
283    }
284
285    /// Registers a component shutdown phase that consumes one owned value at most once.
286    pub fn component_shutdown_once<T, F, Fut>(
287        &mut self,
288        component_name: &'static str,
289        kind: RuntimeComponentKind,
290        phase_name: &'static str,
291        timeout: Option<Duration>,
292        value: T,
293        phase: F,
294    ) -> &mut Self
295    where
296        T: Send + 'static,
297        F: FnOnce(T) -> Fut + Send + 'static,
298        Fut: Future<Output = Result<(), String>> + Send + 'static,
299    {
300        self.component(component_name)
301            .kind(kind)
302            .shutdown_once(phase_name, timeout, value, phase);
303        self
304    }
305
306    /// Returns a builder for `name`, creating the component when needed.
307    pub fn component(&mut self, name: &'static str) -> RuntimeComponentBuilder<'_> {
308        let index = match self
309            .components
310            .iter()
311            .position(|component| component.name == name)
312        {
313            Some(index) => index,
314            None => {
315                self.components.push(RuntimeComponentDescriptor::new(name));
316                self.components.len() - 1
317            }
318        };
319
320        RuntimeComponentBuilder {
321            registry: self,
322            index,
323        }
324    }
325
326    /// Returns registered component descriptors in registration order.
327    pub fn descriptors(&self) -> &[RuntimeComponentDescriptor] {
328        &self.components
329    }
330
331    /// Returns one descriptor by component name.
332    pub fn descriptor(&self, name: &str) -> Option<&RuntimeComponentDescriptor> {
333        self.components
334            .iter()
335            .find(|component| component.name == name)
336    }
337
338    /// Returns the underlying health registry.
339    pub const fn health_registry(&self) -> &HealthCheckRegistry {
340        &self.health
341    }
342
343    /// Returns the underlying health registry mutably.
344    pub const fn health_registry_mut(&mut self) -> &mut HealthCheckRegistry {
345        &mut self.health
346    }
347
348    /// Runs health checks registered for `scope`.
349    pub async fn run_health(&mut self, scope: HealthCheckScope) -> SystemHealthReport {
350        self.health.run_scope(scope).await
351    }
352
353    /// Runs registered startup phases.
354    pub async fn startup(&mut self) -> StartupReport {
355        self.startup.run().await
356    }
357
358    /// Validates that the component dependency graph is resolvable.
359    pub fn validate(&self) -> Result<(), RuntimeComponentGraphError> {
360        let descriptor_by_name = self
361            .components
362            .iter()
363            .map(|component| (component.name, component))
364            .collect::<HashMap<_, _>>();
365
366        for component in &self.components {
367            for dependency in &component.dependencies {
368                if !descriptor_by_name.contains_key(dependency) {
369                    return Err(RuntimeComponentGraphError::MissingDependency {
370                        component: component.name,
371                        dependency,
372                    });
373                }
374            }
375        }
376
377        let mut visiting = Vec::new();
378        let mut visited = HashSet::new();
379        for component in &self.components {
380            self.validate_component_dependencies(
381                component.name,
382                &descriptor_by_name,
383                &mut visiting,
384                &mut visited,
385            )?;
386        }
387
388        Ok(())
389    }
390
391    fn validate_component_dependencies(
392        &self,
393        component_name: &'static str,
394        descriptor_by_name: &HashMap<&'static str, &RuntimeComponentDescriptor>,
395        visiting: &mut Vec<&'static str>,
396        visited: &mut HashSet<&'static str>,
397    ) -> Result<(), RuntimeComponentGraphError> {
398        if visited.contains(component_name) {
399            return Ok(());
400        }
401        if let Some(position) = visiting
402            .iter()
403            .position(|visiting_name| *visiting_name == component_name)
404        {
405            let mut cycle = visiting[position..].to_vec();
406            cycle.push(component_name);
407            return Err(RuntimeComponentGraphError::Cycle {
408                cycle: cycle.join(" -> "),
409            });
410        }
411
412        visiting.push(component_name);
413        if let Some(descriptor) = descriptor_by_name.get(component_name) {
414            for dependency in &descriptor.dependencies {
415                self.validate_component_dependencies(
416                    dependency,
417                    descriptor_by_name,
418                    visiting,
419                    visited,
420                )?;
421            }
422        }
423        visiting.pop();
424        visited.insert(component_name);
425        Ok(())
426    }
427
428    /// Runs registered shutdown phases in component dependency order.
429    ///
430    /// A component's dependencies run before that component when both sides
431    /// have shutdown phases. Multiple phases registered by one component run in
432    /// registration order. Dependencies without shutdown phases are kept as
433    /// descriptor metadata and do not block execution. Cycles are reported as
434    /// warnings and the registry still makes best-effort progress without
435    /// executing a phase more than once.
436    pub async fn shutdown(&mut self) -> ShutdownReport {
437        let mut reports = Vec::with_capacity(self.shutdown.len());
438        for index in self.shutdown_order() {
439            let registered = &mut self.shutdown[index];
440            tracing::info!(phase = registered.phase_name, "starting shutdown phase");
441            let started_at = std::time::Instant::now();
442            let future = (registered.phase)();
443            let status = match registered.timeout {
444                Some(timeout) => match tokio::time::timeout(timeout, future).await {
445                    Ok(Ok(())) => ShutdownPhaseStatus::Succeeded,
446                    Ok(Err(error)) => ShutdownPhaseStatus::Failed(error),
447                    Err(_) => ShutdownPhaseStatus::TimedOut,
448                },
449                None => match future.await {
450                    Ok(()) => ShutdownPhaseStatus::Succeeded,
451                    Err(error) => ShutdownPhaseStatus::Failed(error),
452                },
453            };
454            let duration = started_at.elapsed();
455            match &status {
456                ShutdownPhaseStatus::Succeeded => {
457                    tracing::info!(
458                        phase = registered.phase_name,
459                        ?duration,
460                        "shutdown phase completed"
461                    );
462                }
463                ShutdownPhaseStatus::Failed(error) => {
464                    tracing::error!(
465                        phase = registered.phase_name,
466                        ?duration,
467                        %error,
468                        "shutdown phase failed"
469                    );
470                }
471                ShutdownPhaseStatus::TimedOut => {
472                    tracing::error!(
473                        phase = registered.phase_name,
474                        ?duration,
475                        "shutdown phase timed out"
476                    );
477                }
478            }
479            reports.push(ShutdownPhaseReport {
480                name: registered.phase_name,
481                status,
482                duration,
483            });
484        }
485
486        ShutdownReport::new(reports)
487    }
488
489    fn shutdown_order(&self) -> Vec<usize> {
490        let mut phase_indices_by_component: HashMap<&'static str, Vec<usize>> = HashMap::new();
491        for (index, phase) in self.shutdown.iter().enumerate() {
492            phase_indices_by_component
493                .entry(phase.component_name)
494                .or_default()
495                .push(index);
496        }
497        let descriptor_by_name = self
498            .components
499            .iter()
500            .map(|component| (component.name, component))
501            .collect::<HashMap<_, _>>();
502        let mut visiting = HashSet::new();
503        let mut visited = HashSet::new();
504        let mut ordered = Vec::with_capacity(self.shutdown.len());
505
506        for phase in &self.shutdown {
507            self.push_shutdown_component_order(
508                phase.component_name,
509                &phase_indices_by_component,
510                &descriptor_by_name,
511                &mut visiting,
512                &mut visited,
513                &mut ordered,
514            );
515        }
516
517        ordered
518    }
519
520    fn push_shutdown_component_order(
521        &self,
522        component_name: &'static str,
523        phase_indices_by_component: &HashMap<&'static str, Vec<usize>>,
524        descriptor_by_name: &HashMap<&'static str, &RuntimeComponentDescriptor>,
525        visiting: &mut HashSet<&'static str>,
526        visited: &mut HashSet<&'static str>,
527        ordered: &mut Vec<usize>,
528    ) {
529        if visited.contains(component_name) {
530            return;
531        }
532        if !visiting.insert(component_name) {
533            tracing::warn!(
534                component = component_name,
535                "runtime component dependency cycle detected during shutdown ordering"
536            );
537            return;
538        }
539
540        if let Some(descriptor) = descriptor_by_name.get(component_name) {
541            for dependency in &descriptor.dependencies {
542                if phase_indices_by_component.contains_key(dependency) {
543                    self.push_shutdown_component_order(
544                        dependency,
545                        phase_indices_by_component,
546                        descriptor_by_name,
547                        visiting,
548                        visited,
549                        ordered,
550                    );
551                }
552            }
553        }
554
555        visiting.remove(component_name);
556        visited.insert(component_name);
557        // Dependencies are per-component, so the DFS stays per-component; when a
558        // component is emitted, every phase it registered runs in registration order.
559        if let Some(indices) = phase_indices_by_component.get(component_name) {
560            ordered.extend(indices);
561        }
562    }
563
564    /// Returns how many components are registered.
565    pub fn len(&self) -> usize {
566        self.components.len()
567    }
568
569    /// Returns whether no components are registered.
570    pub fn is_empty(&self) -> bool {
571        self.components.is_empty()
572    }
573}
574
575/// Builder for one runtime component registration.
576pub struct RuntimeComponentBuilder<'a> {
577    registry: &'a mut RuntimeComponentRegistry,
578    index: usize,
579}
580
581impl RuntimeComponentBuilder<'_> {
582    /// Sets the component category.
583    pub fn kind(&mut self, kind: RuntimeComponentKind) -> &mut Self {
584        self.descriptor_mut().kind = kind;
585        self
586    }
587
588    /// Adds one component dependency.
589    pub fn depends_on(&mut self, dependency: &'static str) -> &mut Self {
590        let descriptor = self.descriptor_mut();
591        if !descriptor.dependencies.contains(&dependency) {
592            descriptor.dependencies.push(dependency);
593        }
594        self
595    }
596
597    /// Adds component dependencies in order.
598    pub fn depends_on_all(&mut self, dependencies: &[&'static str]) -> &mut Self {
599        for dependency in dependencies {
600            self.depends_on(dependency);
601        }
602        self
603    }
604
605    /// Registers a component startup phase.
606    pub fn startup<F, Fut>(
607        &mut self,
608        phase_name: &'static str,
609        failure_policy: StartupPhaseFailurePolicy,
610        phase: F,
611    ) -> &mut Self
612    where
613        F: FnMut() -> Fut + Send + 'static,
614        Fut: Future<Output = Result<(), String>> + Send + 'static,
615    {
616        self.registry
617            .startup
618            .phase(phase_name, failure_policy, phase);
619        self.descriptor_mut()
620            .startup
621            .push(RuntimeStartupDescriptor {
622                phase_name,
623                failure_policy,
624            });
625        self
626    }
627
628    /// Registers a component-owned runtime task descriptor.
629    pub fn task(&mut self, task_name: &'static str, display_name: &'static str) -> &mut Self {
630        self.descriptor_mut().tasks.push(RuntimeTaskDescriptor {
631            task_name,
632            display_name,
633        });
634        self
635    }
636
637    /// Registers a component health check with explicit options.
638    pub fn health_with_options<F, Fut>(
639        &mut self,
640        check_name: &'static str,
641        options: HealthCheckOptions,
642        check: F,
643    ) -> &mut Self
644    where
645        F: Fn() -> Fut + Send + Sync + 'static,
646        Fut: Future<Output = HealthComponentReport> + Send + 'static,
647    {
648        self.registry
649            .health
650            .register_with_options(check_name, options, check);
651        self.descriptor_mut()
652            .health_checks
653            .push(HealthCheckDescriptor {
654                name: check_name,
655                requirement: options.requirement,
656                timeout: options.timeout,
657                scopes: options.scopes,
658            });
659        self
660    }
661
662    /// Registers a component shutdown phase.
663    ///
664    /// A component may register multiple shutdown phases; they run in registration
665    /// order, after the shutdown phases of the component's dependencies. Registering
666    /// the same phase name twice for one component is almost always a duplicated
667    /// registration accident, so it is reported with a warning, but every registered
668    /// phase still runs.
669    pub fn shutdown<F, Fut>(
670        &mut self,
671        phase_name: &'static str,
672        timeout: Option<Duration>,
673        mut phase: F,
674    ) -> &mut Self
675    where
676        F: FnMut() -> Fut + Send + 'static,
677        Fut: Future<Output = Result<(), String>> + Send + 'static,
678    {
679        let component_name = self.descriptor_mut().name;
680        if self.registry.shutdown.iter().any(|registered| {
681            registered.component_name == component_name && registered.phase_name == phase_name
682        }) {
683            tracing::warn!(
684                component = component_name,
685                phase = phase_name,
686                "duplicate shutdown phase registration"
687            );
688        }
689        self.registry.shutdown.push(RuntimeComponentShutdownPhase {
690            component_name,
691            phase_name,
692            timeout,
693            phase: Box::new(move || Box::pin(phase())),
694        });
695        self.descriptor_mut()
696            .shutdown
697            .push(RuntimeShutdownDescriptor {
698                phase_name,
699                timeout,
700            });
701        self
702    }
703
704    /// Registers a shutdown phase that consumes one owned value at most once.
705    ///
706    /// This is the common shape for database handles, background task sets,
707    /// and other shutdown-only resources. Re-running the registry after the
708    /// value has already been consumed becomes a no-op success.
709    pub fn shutdown_once<T, F, Fut>(
710        &mut self,
711        phase_name: &'static str,
712        timeout: Option<Duration>,
713        value: T,
714        phase: F,
715    ) -> &mut Self
716    where
717        T: Send + 'static,
718        F: FnOnce(T) -> Fut + Send + 'static,
719        Fut: Future<Output = Result<(), String>> + Send + 'static,
720    {
721        let mut value = Some(value);
722        let mut phase = Some(phase);
723        self.shutdown(phase_name, timeout, move || {
724            let value = value.take();
725            let phase = phase.take();
726            async move {
727                if let (Some(value), Some(phase)) = (value, phase) {
728                    phase(value).await?;
729                }
730                Ok(())
731            }
732        })
733    }
734
735    fn descriptor_mut(&mut self) -> &mut RuntimeComponentDescriptor {
736        &mut self.registry.components[self.index]
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use std::sync::{Arc, Mutex};
743    use std::time::Duration;
744
745    use super::{RuntimeComponentBundle, RuntimeComponentKind, RuntimeComponentRegistry};
746    use crate::{
747        HealthCheckOptions, HealthCheckScope, HealthCheckScopes, HealthComponentReport,
748        HealthStatus, ShutdownPhaseStatus, StartupPhaseFailurePolicy,
749    };
750
751    #[tokio::test]
752    async fn registry_runs_component_health_checks_by_scope() {
753        let mut registry = RuntimeComponentRegistry::new();
754        registry.component_health_with_options(
755            "database",
756            RuntimeComponentKind::Database,
757            "database",
758            HealthCheckOptions::required(Some(Duration::from_secs(1)))
759                .with_scopes(HealthCheckScopes::readiness_and_diagnostics()),
760            || async { HealthComponentReport::healthy("database", "ok") },
761        );
762        registry.component_health_with_options(
763            "cache",
764            RuntimeComponentKind::Cache,
765            "cache",
766            HealthCheckOptions::optional(None).with_scopes(HealthCheckScopes::diagnostics()),
767            || async { HealthComponentReport::degraded("cache", "fallback") },
768        );
769
770        let readiness = registry.run_health(HealthCheckScope::Readiness).await;
771        let diagnostics = registry.run_health(HealthCheckScope::Diagnostics).await;
772
773        assert_eq!(readiness.components.len(), 1);
774        assert_eq!(readiness.components[0].name, "database");
775        assert_eq!(diagnostics.components.len(), 2);
776        assert_eq!(diagnostics.status(), HealthStatus::Degraded);
777        assert_eq!(registry.descriptors()[0].health_checks[0].name, "database");
778        assert_eq!(registry.descriptors()[1].health_checks[0].name, "cache");
779    }
780
781    #[tokio::test]
782    async fn registry_runs_component_startup_and_records_task_descriptors() {
783        let startup_events = Arc::new(Mutex::new(Vec::new()));
784        let mut registry = RuntimeComponentRegistry::new();
785        registry
786            .component("mail")
787            .kind(RuntimeComponentKind::Mail)
788            .startup("mail_templates", StartupPhaseFailurePolicy::Required, {
789                let startup_events = Arc::clone(&startup_events);
790                move || {
791                    let startup_events = Arc::clone(&startup_events);
792                    async move {
793                        startup_events.lock().unwrap().push("mail_templates");
794                        Ok(())
795                    }
796                }
797            })
798            .task("mail-outbox-dispatch", "Mail outbox dispatch");
799
800        let report = registry.startup().await;
801
802        assert!(!report.aborted());
803        assert_eq!(
804            startup_events.lock().unwrap().as_slice(),
805            ["mail_templates"]
806        );
807        let descriptor = registry.descriptor("mail").expect("mail descriptor");
808        assert_eq!(descriptor.startup[0].phase_name, "mail_templates");
809        assert_eq!(
810            descriptor.startup[0].failure_policy,
811            StartupPhaseFailurePolicy::Required
812        );
813        assert_eq!(descriptor.tasks[0].task_name, "mail-outbox-dispatch");
814        assert_eq!(descriptor.tasks[0].display_name, "Mail outbox dispatch");
815    }
816
817    fn register_database_component(registry: &mut RuntimeComponentRegistry) {
818        registry.component_health_with_options(
819            "database",
820            RuntimeComponentKind::Database,
821            "database",
822            HealthCheckOptions::required(None),
823            || async { HealthComponentReport::healthy("database", "ok") },
824        );
825    }
826
827    fn register_cache_component(registry: &mut RuntimeComponentRegistry) {
828        registry
829            .component("cache")
830            .kind(RuntimeComponentKind::Cache)
831            .depends_on("database")
832            .health_with_options(
833                "cache",
834                HealthCheckOptions::optional(None).with_scopes(HealthCheckScopes::diagnostics()),
835                || async { HealthComponentReport::healthy("cache", "ok") },
836            );
837    }
838
839    #[tokio::test]
840    async fn registry_runs_shutdown_phases_in_dependency_order() {
841        let order = Arc::new(Mutex::new(Vec::new()));
842        let mut registry = RuntimeComponentRegistry::new();
843
844        registry
845            .component("database")
846            .kind(RuntimeComponentKind::Database)
847            .depends_on_all(&["tasks"])
848            .shutdown("database", None, {
849                let order = Arc::clone(&order);
850                move || {
851                    let order = Arc::clone(&order);
852                    async move {
853                        order.lock().unwrap().push("database");
854                        Err("close failed".to_string())
855                    }
856                }
857            });
858        registry.component_shutdown("tasks", RuntimeComponentKind::Tasks, "tasks", None, {
859            let order = Arc::clone(&order);
860            move || {
861                let order = Arc::clone(&order);
862                async move {
863                    order.lock().unwrap().push("tasks");
864                    Ok(())
865                }
866            }
867        });
868
869        let report = registry.shutdown().await;
870
871        assert_eq!(order.lock().unwrap().as_slice(), ["tasks", "database"]);
872        assert!(report.has_failures());
873        assert_eq!(report.phases[0].status, ShutdownPhaseStatus::Succeeded);
874        assert_eq!(
875            report.phases[1].status,
876            ShutdownPhaseStatus::Failed("close failed".to_string())
877        );
878        assert_eq!(
879            registry
880                .descriptor("database")
881                .expect("database component should exist")
882                .dependencies,
883            vec!["tasks"]
884        );
885    }
886
887    #[tokio::test]
888    async fn registry_runs_deep_shutdown_graph_before_dependents() {
889        let order = Arc::new(Mutex::new(Vec::new()));
890        let mut registry = RuntimeComponentRegistry::new();
891
892        for (component, kind, dependencies) in [
893            (
894                "database",
895                RuntimeComponentKind::Database,
896                &["audit_manager"][..],
897            ),
898            (
899                "audit_manager",
900                RuntimeComponentKind::Product,
901                &["audit_logs"][..],
902            ),
903            (
904                "audit_logs",
905                RuntimeComponentKind::Product,
906                &["mail_outbox"][..],
907            ),
908            (
909                "mail_outbox",
910                RuntimeComponentKind::Mail,
911                &["background_tasks"][..],
912            ),
913            ("background_tasks", RuntimeComponentKind::Tasks, &[][..]),
914        ] {
915            registry
916                .component(component)
917                .kind(kind)
918                .depends_on_all(dependencies)
919                .shutdown(component, None, {
920                    let order = Arc::clone(&order);
921                    move || {
922                        let order = Arc::clone(&order);
923                        async move {
924                            order.lock().unwrap().push(component);
925                            Ok(())
926                        }
927                    }
928                });
929        }
930
931        let report = registry.shutdown().await;
932
933        assert!(!report.has_failures());
934        assert_eq!(
935            order.lock().unwrap().as_slice(),
936            [
937                "background_tasks",
938                "mail_outbox",
939                "audit_logs",
940                "audit_manager",
941                "database"
942            ]
943        );
944        assert_eq!(
945            report
946                .phases
947                .iter()
948                .map(|phase| phase.name)
949                .collect::<Vec<_>>(),
950            vec![
951                "background_tasks",
952                "mail_outbox",
953                "audit_logs",
954                "audit_manager",
955                "database"
956            ]
957        );
958    }
959
960    #[tokio::test]
961    async fn registry_runs_all_shutdown_phases_of_one_component_in_registration_order() {
962        let order = Arc::new(Mutex::new(Vec::new()));
963        let mut registry = RuntimeComponentRegistry::new();
964
965        {
966            let mut component = registry.component("audit");
967            for phase_name in ["audit_stop", "audit_flush", "audit_close"] {
968                component.shutdown(phase_name, None, {
969                    let order = Arc::clone(&order);
970                    move || {
971                        let order = Arc::clone(&order);
972                        async move {
973                            order.lock().unwrap().push(phase_name);
974                            Ok(())
975                        }
976                    }
977                });
978            }
979        }
980
981        let report = registry.shutdown().await;
982
983        assert!(!report.has_failures());
984        assert_eq!(
985            order.lock().unwrap().as_slice(),
986            ["audit_stop", "audit_flush", "audit_close"]
987        );
988        assert_eq!(
989            report
990                .phases
991                .iter()
992                .map(|phase| phase.name)
993                .collect::<Vec<_>>(),
994            vec!["audit_stop", "audit_flush", "audit_close"]
995        );
996        assert_eq!(
997            registry
998                .descriptor("audit")
999                .expect("audit component should exist")
1000                .shutdown
1001                .iter()
1002                .map(|descriptor| descriptor.phase_name)
1003                .collect::<Vec<_>>(),
1004            vec!["audit_stop", "audit_flush", "audit_close"]
1005        );
1006    }
1007
1008    #[tokio::test]
1009    async fn registry_runs_dependency_phases_before_all_dependent_phases() {
1010        let order = Arc::new(Mutex::new(Vec::new()));
1011        let mut registry = RuntimeComponentRegistry::new();
1012
1013        {
1014            let mut database = registry.component("database");
1015            for phase_name in ["database_stop", "database_close"] {
1016                database.shutdown(phase_name, None, {
1017                    let order = Arc::clone(&order);
1018                    move || {
1019                        let order = Arc::clone(&order);
1020                        async move {
1021                            order.lock().unwrap().push(phase_name);
1022                            Ok(())
1023                        }
1024                    }
1025                });
1026            }
1027        }
1028        {
1029            let mut audit = registry.component("audit");
1030            audit.depends_on("database");
1031            for phase_name in ["audit_flush", "audit_close"] {
1032                audit.shutdown(phase_name, None, {
1033                    let order = Arc::clone(&order);
1034                    move || {
1035                        let order = Arc::clone(&order);
1036                        async move {
1037                            order.lock().unwrap().push(phase_name);
1038                            Ok(())
1039                        }
1040                    }
1041                });
1042            }
1043        }
1044
1045        let report = registry.shutdown().await;
1046
1047        assert!(!report.has_failures());
1048        assert_eq!(
1049            order.lock().unwrap().as_slice(),
1050            [
1051                "database_stop",
1052                "database_close",
1053                "audit_flush",
1054                "audit_close"
1055            ]
1056        );
1057    }
1058
1059    #[derive(Clone)]
1060    struct SharedLogBuffer(Arc<Mutex<Vec<u8>>>);
1061
1062    impl std::io::Write for SharedLogBuffer {
1063        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1064            self.0.lock().unwrap().extend_from_slice(buf);
1065            Ok(buf.len())
1066        }
1067
1068        fn flush(&mut self) -> std::io::Result<()> {
1069            Ok(())
1070        }
1071    }
1072
1073    #[tokio::test]
1074    async fn duplicate_shutdown_phase_registration_warns_and_still_runs_both() {
1075        let order = Arc::new(Mutex::new(Vec::new()));
1076        let buffer = SharedLogBuffer(Arc::new(Mutex::new(Vec::new())));
1077        let writer = buffer.clone();
1078        let subscriber = tracing_subscriber::fmt()
1079            .with_writer(move || writer.clone())
1080            .with_ansi(false)
1081            .without_time()
1082            .finish();
1083
1084        let mut registry = RuntimeComponentRegistry::new();
1085        tracing::subscriber::with_default(subscriber, || {
1086            let mut component = registry.component("audit");
1087            for run in [1_u8, 2] {
1088                component.shutdown("audit_flush", None, {
1089                    let order = Arc::clone(&order);
1090                    move || {
1091                        let order = Arc::clone(&order);
1092                        async move {
1093                            order.lock().unwrap().push(run);
1094                            Ok(())
1095                        }
1096                    }
1097                });
1098            }
1099        });
1100
1101        let logs = String::from_utf8(buffer.0.lock().unwrap().clone())
1102            .expect("log output should be valid UTF-8");
1103        assert!(
1104            logs.contains("duplicate shutdown phase registration"),
1105            "expected duplicate registration warning, got: {logs}"
1106        );
1107
1108        let report = registry.shutdown().await;
1109        assert!(!report.has_failures());
1110        assert_eq!(order.lock().unwrap().as_slice(), [1, 2]);
1111        assert_eq!(report.phases.len(), 2);
1112    }
1113
1114    #[tokio::test]
1115    async fn registry_ignores_shutdown_dependencies_without_shutdown_phase() {
1116        let order = Arc::new(Mutex::new(Vec::new()));
1117        let mut registry = RuntimeComponentRegistry::new();
1118
1119        registry
1120            .component("cache")
1121            .kind(RuntimeComponentKind::Cache);
1122        registry
1123            .component("database")
1124            .kind(RuntimeComponentKind::Database)
1125            .depends_on("cache")
1126            .shutdown("database", None, {
1127                let order = Arc::clone(&order);
1128                move || {
1129                    let order = Arc::clone(&order);
1130                    async move {
1131                        order.lock().unwrap().push("database");
1132                        Ok(())
1133                    }
1134                }
1135            });
1136
1137        let report = registry.shutdown().await;
1138
1139        assert!(!report.has_failures());
1140        assert_eq!(order.lock().unwrap().as_slice(), ["database"]);
1141        assert_eq!(report.phases.len(), 1);
1142        assert_eq!(report.phases[0].name, "database");
1143    }
1144
1145    #[test]
1146    fn registry_validate_rejects_missing_component_dependencies() {
1147        let mut registry = RuntimeComponentRegistry::new();
1148        registry
1149            .component("database")
1150            .kind(RuntimeComponentKind::Database)
1151            .depends_on("cache");
1152
1153        let error = registry
1154            .validate()
1155            .expect_err("missing dependency should fail validation");
1156
1157        assert_eq!(
1158            error,
1159            crate::RuntimeComponentGraphError::MissingDependency {
1160                component: "database",
1161                dependency: "cache"
1162            }
1163        );
1164    }
1165
1166    #[test]
1167    fn registry_validate_rejects_dependency_cycles() {
1168        let mut registry = RuntimeComponentRegistry::new();
1169        registry.component("database").depends_on("audit");
1170        registry.component("audit").depends_on("database");
1171
1172        let error = registry
1173            .validate()
1174            .expect_err("dependency cycle should fail validation");
1175
1176        assert_eq!(
1177            error,
1178            crate::RuntimeComponentGraphError::Cycle {
1179                cycle: "database -> audit -> database".to_string()
1180            }
1181        );
1182    }
1183
1184    #[tokio::test]
1185    async fn registry_shutdown_dependency_cycle_does_not_repeat_phases() {
1186        let order = Arc::new(Mutex::new(Vec::new()));
1187        let mut registry = RuntimeComponentRegistry::new();
1188
1189        for (component, dependency) in [("database", "audit"), ("audit", "database")] {
1190            registry
1191                .component(component)
1192                .kind(RuntimeComponentKind::Product)
1193                .depends_on(dependency)
1194                .shutdown(component, None, {
1195                    let order = Arc::clone(&order);
1196                    move || {
1197                        let order = Arc::clone(&order);
1198                        async move {
1199                            order.lock().unwrap().push(component);
1200                            Ok(())
1201                        }
1202                    }
1203                });
1204        }
1205
1206        let report = registry.shutdown().await;
1207        let order = order.lock().unwrap();
1208
1209        assert!(!report.has_failures());
1210        assert_eq!(order.len(), 2);
1211        assert!(order.contains(&"database"));
1212        assert!(order.contains(&"audit"));
1213        assert_eq!(report.phases.len(), 2);
1214    }
1215
1216    #[tokio::test]
1217    async fn registry_shutdown_once_consumes_owned_value_once() {
1218        let values = Arc::new(Mutex::new(Vec::new()));
1219        let mut registry = RuntimeComponentRegistry::new();
1220
1221        registry.component_shutdown_once(
1222            "database",
1223            RuntimeComponentKind::Database,
1224            "database",
1225            None,
1226            "writer",
1227            {
1228                let values = Arc::clone(&values);
1229                move |value| async move {
1230                    values.lock().unwrap().push(value);
1231                    Ok(())
1232                }
1233            },
1234        );
1235
1236        let first = registry.shutdown().await;
1237        let second = registry.shutdown().await;
1238
1239        assert!(!first.has_failures());
1240        assert!(!second.has_failures());
1241        assert_eq!(values.lock().unwrap().as_slice(), ["writer"]);
1242        assert_eq!(
1243            registry
1244                .descriptor("database")
1245                .map(|descriptor| descriptor.kind),
1246            Some(RuntimeComponentKind::Database)
1247        );
1248    }
1249
1250    struct TestShutdownBundle {
1251        values: Arc<Mutex<Vec<&'static str>>>,
1252    }
1253
1254    impl RuntimeComponentBundle for TestShutdownBundle {
1255        fn register(self, registry: &mut RuntimeComponentRegistry) {
1256            registry.component_shutdown("audit", RuntimeComponentKind::Product, "audit", None, {
1257                let values = Arc::clone(&self.values);
1258                move || {
1259                    let values = Arc::clone(&values);
1260                    async move {
1261                        values.lock().unwrap().push("audit");
1262                        Ok(())
1263                    }
1264                }
1265            });
1266            registry
1267                .component("database")
1268                .kind(RuntimeComponentKind::Database)
1269                .depends_on("audit")
1270                .shutdown_once("database", None, self.values, |values| async move {
1271                    values.lock().unwrap().push("database");
1272                    Ok(())
1273                });
1274        }
1275    }
1276
1277    #[tokio::test]
1278    async fn registry_accepts_owned_component_bundle() {
1279        let values = Arc::new(Mutex::new(Vec::new()));
1280        let mut registry = RuntimeComponentRegistry::new();
1281        registry.register_bundle(TestShutdownBundle {
1282            values: Arc::clone(&values),
1283        });
1284
1285        assert_eq!(registry.len(), 2);
1286        assert_eq!(
1287            registry
1288                .descriptor("database")
1289                .expect("database descriptor should exist")
1290                .dependencies,
1291            vec!["audit"]
1292        );
1293
1294        let report = registry.shutdown().await;
1295
1296        assert!(!report.has_failures());
1297        assert_eq!(values.lock().unwrap().as_slice(), ["audit", "database"]);
1298    }
1299
1300    #[test]
1301    fn registry_accepts_closure_component_bundle() {
1302        let mut registry = RuntimeComponentRegistry::new();
1303        registry.register_bundle(|registry: &mut RuntimeComponentRegistry| {
1304            register_database_component(registry);
1305        });
1306
1307        assert_eq!(registry.len(), 1);
1308        assert_eq!(registry.descriptors()[0].name, "database");
1309    }
1310
1311    #[test]
1312    fn registry_register_bundle_chains_multiple_component_bundles() {
1313        let mut registry = RuntimeComponentRegistry::new();
1314        registry
1315            .register_bundle(register_database_component)
1316            .register_bundle(register_cache_component);
1317
1318        assert_eq!(registry.len(), 2);
1319        assert_eq!(registry.descriptors()[0].name, "database");
1320        assert_eq!(registry.descriptors()[1].name, "cache");
1321    }
1322
1323    #[tokio::test]
1324    async fn registry_can_shutdown_registered_component_bundle() {
1325        let values = Arc::new(Mutex::new(Vec::new()));
1326
1327        let mut registry = RuntimeComponentRegistry::new();
1328        registry.register_bundle(TestShutdownBundle {
1329            values: Arc::clone(&values),
1330        });
1331        let report = registry.shutdown().await;
1332
1333        assert!(!report.has_failures());
1334        assert_eq!(values.lock().unwrap().as_slice(), ["audit", "database"]);
1335    }
1336}