aster_forge_db/
component.rs

1//! Runtime component integration for database handles.
2//!
3//! Product crates still own database configuration, migrations, repositories,
4//! and health-check semantics. Forge owns the repeated lifecycle mechanics for
5//! already prepared handles: registering the `database` runtime component,
6//! applying product-declared shutdown dependencies, and closing the handles
7//! exactly once during dependency-aware shutdown.
8
9use aster_forge_runtime::{
10    HealthCheckOptions, HealthCheckScopes, HealthComponentReport, RuntimeComponentBundle,
11    RuntimeComponentBundleRegistration, RuntimeComponentKind, RuntimeComponentRegistry,
12    runtime_component,
13};
14use sea_orm::DatabaseConnection;
15use std::time::Duration;
16
17use crate::DbHandles;
18
19/// Stable component name used for database handles.
20pub const DATABASE_COMPONENT: &str = "database";
21/// Stable shutdown phase name for database handle closing.
22pub const DATABASE_CONNECTIONS_SHUTDOWN_PHASE: &str = "database_connections";
23/// Stable health check name used for database ping checks.
24pub const DATABASE_HEALTH_CHECK: &str = "database";
25/// Default timeout for database readiness and diagnostics health checks.
26pub const DATABASE_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
27
28/// Runtime component that closes database handles during graceful shutdown.
29pub struct DatabaseRuntimeComponent {
30    db_handles: DbHandles,
31    dependencies: &'static [&'static str],
32}
33
34impl DatabaseRuntimeComponent {
35    /// Creates a database runtime component from prepared handles.
36    #[must_use]
37    pub const fn new(db_handles: DbHandles) -> Self {
38        Self {
39            db_handles,
40            dependencies: &[],
41        }
42    }
43
44    /// Declares components that must shut down before database handles close.
45    #[must_use]
46    pub const fn depends_on_all(mut self, dependencies: &'static [&'static str]) -> Self {
47        self.dependencies = dependencies;
48        self
49    }
50}
51
52impl RuntimeComponentBundle for DatabaseRuntimeComponent {
53    fn register(self, registry: &mut RuntimeComponentRegistry) {
54        register_database_health_check(registry, self.db_handles.reader().clone());
55        register_database_shutdown(registry, self.db_handles, self.dependencies);
56    }
57}
58
59/// Runtime component that registers the standard database health check only.
60pub struct DatabaseHealthComponent {
61    db: DatabaseConnection,
62}
63
64impl DatabaseHealthComponent {
65    /// Creates a database health component from a prepared connection.
66    #[must_use]
67    pub const fn new(db: DatabaseConnection) -> Self {
68        Self { db }
69    }
70}
71
72impl RuntimeComponentBundle for DatabaseHealthComponent {
73    fn register(self, registry: &mut RuntimeComponentRegistry) {
74        register_database_health_check(registry, self.db);
75    }
76}
77
78/// Creates the database runtime component used by product entrypoints.
79#[must_use]
80pub fn database_component(
81    db_handles: DbHandles,
82) -> RuntimeComponentBundleRegistration<DatabaseRuntimeComponent> {
83    runtime_component(DatabaseRuntimeComponent::new(db_handles))
84}
85
86/// Creates the database runtime component with shutdown dependencies.
87#[must_use]
88pub fn database_component_after(
89    db_handles: DbHandles,
90    dependencies: &'static [&'static str],
91) -> RuntimeComponentBundleRegistration<DatabaseRuntimeComponent> {
92    runtime_component(DatabaseRuntimeComponent::new(db_handles).depends_on_all(dependencies))
93}
94
95/// Creates the standard database health component.
96#[must_use]
97pub fn database_health_component(
98    db: DatabaseConnection,
99) -> RuntimeComponentBundleRegistration<DatabaseHealthComponent> {
100    runtime_component(DatabaseHealthComponent::new(db))
101}
102
103/// Registers database shutdown after product-declared dependency components.
104fn register_database_shutdown(
105    registry: &mut RuntimeComponentRegistry,
106    db_handles: DbHandles,
107    dependencies: &'static [&'static str],
108) {
109    registry
110        .component(DATABASE_COMPONENT)
111        .kind(RuntimeComponentKind::Database)
112        .depends_on_all(dependencies)
113        .shutdown_once(
114            DATABASE_CONNECTIONS_SHUTDOWN_PHASE,
115            None,
116            db_handles,
117            |db_handles| async move {
118                db_handles
119                    .close()
120                    .await
121                    .map_err(|error| error.to_string())?;
122                Ok(())
123            },
124        );
125}
126
127/// Registers a database readiness and diagnostics health check.
128fn register_database_health_check(registry: &mut RuntimeComponentRegistry, db: DatabaseConnection) {
129    registry.component_health_with_options(
130        DATABASE_COMPONENT,
131        RuntimeComponentKind::Database,
132        DATABASE_HEALTH_CHECK,
133        database_health_options(),
134        move || {
135            let db = db.clone();
136            async move { check_database_component(&db).await }
137        },
138    );
139}
140
141/// Returns the standard database health check options.
142#[must_use]
143pub fn database_health_options() -> HealthCheckOptions {
144    HealthCheckOptions::required(Some(DATABASE_HEALTH_CHECK_TIMEOUT))
145        .with_scopes(HealthCheckScopes::readiness_and_diagnostics())
146}
147
148/// Runs the standard database ping health check.
149pub async fn check_database_component(db: &DatabaseConnection) -> HealthComponentReport {
150    match ping_database(db).await {
151        Ok(()) => {
152            tracing::debug!("database health check succeeded");
153            HealthComponentReport::healthy(DATABASE_HEALTH_CHECK, "database ping succeeded")
154        }
155        Err(error) => {
156            tracing::debug!(error = %error, "database health check failed");
157            HealthComponentReport::unhealthy(
158                DATABASE_HEALTH_CHECK,
159                format!("database ping failed: {error}"),
160            )
161        }
162    }
163}
164
165/// Pings the database connection used by the standard health check.
166///
167/// # Errors
168///
169/// Returns an error when the database operation fails.
170pub async fn ping_database(db: &DatabaseConnection) -> crate::Result<()> {
171    tracing::debug!("pinging database health check");
172    db.ping().await.map_err(crate::DbError::from)
173}
174
175#[cfg(test)]
176mod tests {
177    use aster_forge_runtime::{RuntimeComponentBundle, RuntimeComponentKind};
178
179    use super::{
180        DATABASE_COMPONENT, DATABASE_CONNECTIONS_SHUTDOWN_PHASE, DATABASE_HEALTH_CHECK,
181        check_database_component, database_component_after, database_health_component,
182    };
183    use aster_forge_runtime::{HealthCheckScope, HealthStatus};
184
185    #[tokio::test]
186    async fn database_component_registers_dependencies_and_shutdown() {
187        let db = sea_orm::Database::connect("sqlite::memory:")
188            .await
189            .expect("database runtime component test database should connect");
190        let db_handles = crate::DbHandles::single(db);
191
192        let registry = aster_forge_runtime::RuntimeComponentRegistry::configured(|registry| {
193            database_component_after(db_handles, &["background_tasks", "mail_outbox"])
194                .register(registry);
195        });
196
197        let descriptor = registry
198            .descriptor(DATABASE_COMPONENT)
199            .expect("database component should be registered");
200        assert_eq!(descriptor.kind, RuntimeComponentKind::Database);
201        assert_eq!(
202            descriptor.dependencies,
203            vec!["background_tasks", "mail_outbox"]
204        );
205        assert_eq!(
206            descriptor
207                .shutdown
208                .first()
209                .expect("database shutdown should be registered")
210                .phase_name,
211            DATABASE_CONNECTIONS_SHUTDOWN_PHASE
212        );
213        assert_eq!(descriptor.health_checks.len(), 1);
214    }
215
216    #[tokio::test]
217    async fn database_component_reports_ping_success_and_failure() {
218        let db = sea_orm::Database::connect("sqlite::memory:")
219            .await
220            .expect("database health test database should connect");
221
222        let healthy = check_database_component(&db).await;
223        assert_eq!(healthy.status, HealthStatus::Healthy);
224        assert_eq!(healthy.message, "database ping succeeded");
225
226        db.close_by_ref()
227            .await
228            .expect("database health test database should close");
229        let unhealthy = check_database_component(&db).await;
230        assert_eq!(unhealthy.status, HealthStatus::Unhealthy);
231        assert!(unhealthy.message.contains("database ping failed"));
232    }
233
234    #[tokio::test]
235    async fn database_health_component_registers_readiness_component() {
236        let db = sea_orm::Database::connect("sqlite::memory:")
237            .await
238            .expect("database readiness test database should connect");
239        let mut registry = aster_forge_runtime::RuntimeComponentRegistry::new();
240
241        database_health_component(db).register(&mut registry);
242
243        assert_eq!(registry.len(), 1);
244        let report = registry.run_health(HealthCheckScope::Readiness).await;
245        let component_names = report
246            .components
247            .iter()
248            .map(|component| component.name)
249            .collect::<Vec<_>>();
250        assert_eq!(component_names, vec![DATABASE_HEALTH_CHECK]);
251        assert_eq!(report.status(), HealthStatus::Healthy);
252    }
253
254    #[tokio::test]
255    async fn database_health_component_reports_healthy_status() {
256        let db = sea_orm::Database::connect("sqlite::memory:")
257            .await
258            .expect("database health component test database should connect");
259
260        let mut registry = aster_forge_runtime::RuntimeComponentRegistry::configured(|registry| {
261            database_health_component(db).register(registry);
262        });
263
264        let descriptor = registry
265            .descriptor(DATABASE_COMPONENT)
266            .expect("database component should be registered");
267        assert_eq!(descriptor.health_checks.len(), 1);
268        let report = registry.run_health(HealthCheckScope::Readiness).await;
269        assert_eq!(report.status(), HealthStatus::Healthy);
270    }
271}