aster_forge_cache/
health.rs

1//! Runtime health checks for cache backends.
2//!
3//! Cache construction can intentionally fall back to memory when a configured
4//! remote backend is unavailable. The shared health check reports both states:
5//! degraded when the active backend differs from static configuration, and
6//! unhealthy when the active backend's own lightweight probe fails.
7
8use std::sync::Arc;
9use std::time::Duration;
10
11use aster_forge_runtime::{
12    HealthCheckOptions, HealthCheckScopes, HealthComponentReport, RuntimeComponentBundle,
13    RuntimeComponentBundleRegistration, RuntimeComponentKind, RuntimeComponentRegistry,
14    runtime_component,
15};
16
17use crate::{CacheBackend, CacheConfig};
18
19/// Stable component name used for cache diagnostics.
20pub const CACHE_COMPONENT: &str = "cache";
21/// Stable health check name used for cache diagnostics.
22pub const CACHE_HEALTH_CHECK: &str = "cache";
23/// Default timeout for cache diagnostics checks.
24pub const CACHE_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
25
26/// Runtime component that registers the standard cache diagnostics check.
27pub struct CacheHealthComponent {
28    config: CacheConfig,
29    cache: Arc<dyn CacheBackend>,
30}
31
32impl CacheHealthComponent {
33    /// Creates a cache health component from static configuration and active backend.
34    pub fn new(config: CacheConfig, cache: Arc<dyn CacheBackend>) -> Self {
35        Self { config, cache }
36    }
37}
38
39impl RuntimeComponentBundle for CacheHealthComponent {
40    fn register(self, registry: &mut RuntimeComponentRegistry) {
41        register_cache_health_check(registry, self.config, self.cache);
42    }
43}
44
45/// Creates the standard cache diagnostics health component.
46pub fn cache_health_component(
47    config: CacheConfig,
48    cache: Arc<dyn CacheBackend>,
49) -> RuntimeComponentBundleRegistration<CacheHealthComponent> {
50    runtime_component(CacheHealthComponent::new(config, cache))
51}
52
53/// Registers cache diagnostics health checks.
54fn register_cache_health_check(
55    registry: &mut RuntimeComponentRegistry,
56    config: CacheConfig,
57    cache: Arc<dyn CacheBackend>,
58) {
59    registry.component_health_with_options(
60        CACHE_COMPONENT,
61        RuntimeComponentKind::Cache,
62        CACHE_HEALTH_CHECK,
63        cache_health_options(),
64        move || {
65            let config = config.clone();
66            let cache = cache.clone();
67            async move { check_cache_component(&config, cache.as_ref()).await }
68        },
69    );
70}
71
72/// Returns the standard cache health check options.
73#[must_use]
74pub fn cache_health_options() -> HealthCheckOptions {
75    HealthCheckOptions::optional(Some(CACHE_HEALTH_CHECK_TIMEOUT))
76        .with_scopes(HealthCheckScopes::diagnostics())
77}
78
79/// Runs the standard cache backend health check.
80pub async fn check_cache_component(
81    config: &CacheConfig,
82    cache: &dyn CacheBackend,
83) -> HealthComponentReport {
84    let configured_backend = config.normalized_backend();
85    if configured_backend != cache.backend_name() {
86        tracing::debug!(
87            configured_backend = %configured_backend,
88            active_backend = cache.backend_name(),
89            "cache backend is using fallback"
90        );
91        return HealthComponentReport::degraded(
92            CACHE_HEALTH_CHECK,
93            format!(
94                "configured cache backend '{}' is using active backend '{}'",
95                configured_backend,
96                cache.backend_name()
97            ),
98        )
99        .with_detail("configured_backend", configured_backend.into_owned())
100        .with_detail("active_backend", cache.backend_name());
101    }
102
103    match cache.health_check().await {
104        Ok(()) => {
105            tracing::debug!(
106                backend = cache.backend_name(),
107                "cache health check succeeded"
108            );
109            HealthComponentReport::healthy(CACHE_HEALTH_CHECK, "cache health check succeeded")
110                .with_detail("active_backend", cache.backend_name())
111        }
112        Err(error) => {
113            tracing::debug!(backend = cache.backend_name(), error = %error, "cache health check failed");
114            HealthComponentReport::unhealthy(
115                CACHE_HEALTH_CHECK,
116                format!(
117                    "cache backend '{}' health check failed: {error}",
118                    cache.backend_name()
119                ),
120            )
121            .with_detail("active_backend", cache.backend_name())
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use std::sync::Arc;
129
130    use aster_forge_runtime::{HealthComponentDetailValue, HealthStatus};
131    use async_trait::async_trait;
132
133    use super::{cache_health_component, check_cache_component};
134    use crate::{CacheBackend, CacheConfig};
135
136    struct FakeCache {
137        backend_name: &'static str,
138        healthy: bool,
139    }
140
141    impl FakeCache {
142        const fn new(backend_name: &'static str) -> Self {
143            Self {
144                backend_name,
145                healthy: true,
146            }
147        }
148
149        const fn unhealthy(backend_name: &'static str) -> Self {
150            Self {
151                backend_name,
152                healthy: false,
153            }
154        }
155    }
156
157    #[async_trait]
158    impl CacheBackend for FakeCache {
159        fn backend_name(&self) -> &'static str {
160            self.backend_name
161        }
162
163        async fn health_check(&self) -> crate::Result<()> {
164            if self.healthy {
165                Ok(())
166            } else {
167                Err(crate::CacheError::RedisHealthCheck(
168                    "cache probe failed".to_string(),
169                ))
170            }
171        }
172
173        async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
174            None
175        }
176
177        async fn take_bytes(&self, _key: &str) -> Option<Vec<u8>> {
178            None
179        }
180
181        async fn set_bytes(&self, _key: &str, _value: Vec<u8>, _ttl_secs: Option<u64>) {}
182
183        async fn set_bytes_if_absent(
184            &self,
185            _key: &str,
186            _value: Vec<u8>,
187            _ttl_secs: Option<u64>,
188        ) -> bool {
189            false
190        }
191
192        async fn delete(&self, _key: &str) {}
193
194        async fn invalidate_prefix(&self, _prefix: &str) {}
195    }
196
197    #[tokio::test]
198    async fn cache_component_reports_configured_backend_fallback() {
199        let config = CacheConfig {
200            backend: "redis".to_string(),
201            endpoint: "redis://example.com:6379/0".into(),
202            default_ttl: 60,
203        };
204        let cache = FakeCache::new("memory");
205
206        let report = check_cache_component(&config, &cache).await;
207
208        assert_eq!(report.name, "cache");
209        assert_eq!(report.status, HealthStatus::Degraded);
210        assert_eq!(
211            report.message,
212            "configured cache backend 'redis' is using active backend 'memory'"
213        );
214        assert_eq!(
215            report
216                .detail("configured_backend")
217                .and_then(HealthComponentDetailValue::as_text),
218            Some("redis")
219        );
220        assert_eq!(
221            report
222                .detail("active_backend")
223                .and_then(HealthComponentDetailValue::as_text),
224            Some("memory")
225        );
226    }
227
228    #[tokio::test]
229    async fn cache_component_reports_active_backend_probe_result() {
230        let config = CacheConfig {
231            backend: "redis".to_string(),
232            endpoint: "redis://example.com:6379/0".into(),
233            default_ttl: 60,
234        };
235
236        let healthy = check_cache_component(&config, &FakeCache::new("redis")).await;
237        assert_eq!(healthy.status, HealthStatus::Healthy);
238        assert_eq!(healthy.message, "cache health check succeeded");
239        assert_eq!(
240            healthy
241                .detail("active_backend")
242                .and_then(HealthComponentDetailValue::as_text),
243            Some("redis")
244        );
245
246        let degraded = check_cache_component(&config, &FakeCache::unhealthy("redis")).await;
247        assert_eq!(degraded.status, HealthStatus::Unhealthy);
248        assert!(
249            degraded
250                .message
251                .contains("cache backend 'redis' health check failed")
252        );
253        assert_eq!(
254            degraded
255                .detail("active_backend")
256                .and_then(HealthComponentDetailValue::as_text),
257            Some("redis")
258        );
259    }
260
261    #[tokio::test]
262    async fn cache_component_uses_the_same_normalized_backend_as_factory() {
263        let config = CacheConfig {
264            backend: " ReDiS ".to_string(),
265            endpoint: "redis://example.com:6379/0".into(),
266            default_ttl: 60,
267        };
268
269        let report = check_cache_component(&config, &FakeCache::new("redis")).await;
270
271        assert_eq!(report.status, HealthStatus::Healthy);
272    }
273
274    #[tokio::test]
275    async fn cache_health_component_registers_diagnostics_component() {
276        let config = CacheConfig::default();
277        let cache = Arc::new(FakeCache::new("memory")) as Arc<dyn CacheBackend>;
278
279        let mut registry = aster_forge_runtime::RuntimeComponentRegistry::configured(|registry| {
280            aster_forge_runtime::RuntimeComponentBundle::register(
281                cache_health_component(config, cache),
282                registry,
283            );
284        });
285
286        let descriptor = registry
287            .descriptor(super::CACHE_COMPONENT)
288            .expect("cache component should be registered");
289        assert_eq!(descriptor.health_checks.len(), 1);
290
291        let report = registry
292            .run_health(aster_forge_runtime::HealthCheckScope::Diagnostics)
293            .await;
294        assert_eq!(report.status(), HealthStatus::Healthy);
295    }
296}