aster_forge_db/
component.rs1use 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
19pub const DATABASE_COMPONENT: &str = "database";
21pub const DATABASE_CONNECTIONS_SHUTDOWN_PHASE: &str = "database_connections";
23pub const DATABASE_HEALTH_CHECK: &str = "database";
25pub const DATABASE_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
27
28pub struct DatabaseRuntimeComponent {
30 db_handles: DbHandles,
31 dependencies: &'static [&'static str],
32}
33
34impl DatabaseRuntimeComponent {
35 #[must_use]
37 pub const fn new(db_handles: DbHandles) -> Self {
38 Self {
39 db_handles,
40 dependencies: &[],
41 }
42 }
43
44 #[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
59pub struct DatabaseHealthComponent {
61 db: DatabaseConnection,
62}
63
64impl DatabaseHealthComponent {
65 #[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#[must_use]
80pub fn database_component(
81 db_handles: DbHandles,
82) -> RuntimeComponentBundleRegistration<DatabaseRuntimeComponent> {
83 runtime_component(DatabaseRuntimeComponent::new(db_handles))
84}
85
86#[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#[must_use]
97pub fn database_health_component(
98 db: DatabaseConnection,
99) -> RuntimeComponentBundleRegistration<DatabaseHealthComponent> {
100 runtime_component(DatabaseHealthComponent::new(db))
101}
102
103fn 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
127fn 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#[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
148pub 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
165pub 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}