Skip to main content

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.
73pub fn cache_health_options() -> HealthCheckOptions {
74    HealthCheckOptions::optional(Some(CACHE_HEALTH_CHECK_TIMEOUT))
75        .with_scopes(HealthCheckScopes::diagnostics())
76}
77
78/// Runs the standard cache backend health check.
79pub async fn check_cache_component(
80    config: &CacheConfig,
81    cache: &dyn CacheBackend,
82) -> HealthComponentReport {
83    if config.backend != cache.backend_name() {
84        tracing::debug!(
85            configured_backend = %config.backend,
86            active_backend = cache.backend_name(),
87            "cache backend is using fallback"
88        );
89        return HealthComponentReport::degraded(
90            CACHE_HEALTH_CHECK,
91            format!(
92                "configured cache backend '{}' is using active backend '{}'",
93                config.backend,
94                cache.backend_name()
95            ),
96        )
97        .with_detail("configured_backend", config.backend.clone())
98        .with_detail("active_backend", cache.backend_name());
99    }
100
101    match cache.health_check().await {
102        Ok(()) => {
103            tracing::debug!(
104                backend = cache.backend_name(),
105                "cache health check succeeded"
106            );
107            HealthComponentReport::healthy(CACHE_HEALTH_CHECK, "cache health check succeeded")
108                .with_detail("active_backend", cache.backend_name())
109        }
110        Err(error) => {
111            tracing::debug!(backend = cache.backend_name(), error = %error, "cache health check failed");
112            HealthComponentReport::unhealthy(
113                CACHE_HEALTH_CHECK,
114                format!(
115                    "cache backend '{}' health check failed: {error}",
116                    cache.backend_name()
117                ),
118            )
119            .with_detail("active_backend", cache.backend_name())
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::sync::Arc;
127
128    use aster_forge_runtime::{HealthComponentDetailValue, HealthStatus};
129    use async_trait::async_trait;
130
131    use super::{cache_health_component, check_cache_component};
132    use crate::{CacheBackend, CacheConfig};
133
134    struct FakeCache {
135        backend_name: &'static str,
136        healthy: bool,
137    }
138
139    impl FakeCache {
140        const fn new(backend_name: &'static str) -> Self {
141            Self {
142                backend_name,
143                healthy: true,
144            }
145        }
146
147        const fn unhealthy(backend_name: &'static str) -> Self {
148            Self {
149                backend_name,
150                healthy: false,
151            }
152        }
153    }
154
155    #[async_trait]
156    impl CacheBackend for FakeCache {
157        fn backend_name(&self) -> &'static str {
158            self.backend_name
159        }
160
161        async fn health_check(&self) -> crate::Result<()> {
162            if self.healthy {
163                Ok(())
164            } else {
165                Err(crate::CacheError::RedisHealthCheck(
166                    "cache probe failed".to_string(),
167                ))
168            }
169        }
170
171        async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
172            None
173        }
174
175        async fn take_bytes(&self, _key: &str) -> Option<Vec<u8>> {
176            None
177        }
178
179        async fn set_bytes(&self, _key: &str, _value: Vec<u8>, _ttl_secs: Option<u64>) {}
180
181        async fn set_bytes_if_absent(
182            &self,
183            _key: &str,
184            _value: Vec<u8>,
185            _ttl_secs: Option<u64>,
186        ) -> bool {
187            false
188        }
189
190        async fn delete(&self, _key: &str) {}
191
192        async fn invalidate_prefix(&self, _prefix: &str) {}
193    }
194
195    #[tokio::test]
196    async fn cache_component_reports_configured_backend_fallback() {
197        let config = CacheConfig {
198            backend: "redis".to_string(),
199            endpoint: "redis://example.com:6379/0".to_string(),
200            default_ttl: 60,
201        };
202        let cache = FakeCache::new("memory");
203
204        let report = check_cache_component(&config, &cache).await;
205
206        assert_eq!(report.name, "cache");
207        assert_eq!(report.status, HealthStatus::Degraded);
208        assert_eq!(
209            report.message,
210            "configured cache backend 'redis' is using active backend 'memory'"
211        );
212        assert_eq!(
213            report
214                .detail("configured_backend")
215                .and_then(HealthComponentDetailValue::as_text),
216            Some("redis")
217        );
218        assert_eq!(
219            report
220                .detail("active_backend")
221                .and_then(HealthComponentDetailValue::as_text),
222            Some("memory")
223        );
224    }
225
226    #[tokio::test]
227    async fn cache_component_reports_active_backend_probe_result() {
228        let config = CacheConfig {
229            backend: "redis".to_string(),
230            endpoint: "redis://example.com:6379/0".to_string(),
231            default_ttl: 60,
232        };
233
234        let healthy = check_cache_component(&config, &FakeCache::new("redis")).await;
235        assert_eq!(healthy.status, HealthStatus::Healthy);
236        assert_eq!(healthy.message, "cache health check succeeded");
237        assert_eq!(
238            healthy
239                .detail("active_backend")
240                .and_then(HealthComponentDetailValue::as_text),
241            Some("redis")
242        );
243
244        let degraded = check_cache_component(&config, &FakeCache::unhealthy("redis")).await;
245        assert_eq!(degraded.status, HealthStatus::Unhealthy);
246        assert!(
247            degraded
248                .message
249                .contains("cache backend 'redis' health check failed")
250        );
251        assert_eq!(
252            degraded
253                .detail("active_backend")
254                .and_then(HealthComponentDetailValue::as_text),
255            Some("redis")
256        );
257    }
258
259    #[tokio::test]
260    async fn cache_health_component_registers_diagnostics_component() {
261        let config = CacheConfig::default();
262        let cache = Arc::new(FakeCache::new("memory")) as Arc<dyn CacheBackend>;
263
264        let mut registry = aster_forge_runtime::RuntimeComponentRegistry::configured(|registry| {
265            aster_forge_runtime::RuntimeComponentBundle::register(
266                cache_health_component(config, cache),
267                registry,
268            );
269        });
270
271        let descriptor = registry
272            .descriptor(super::CACHE_COMPONENT)
273            .expect("cache component should be registered");
274        assert_eq!(descriptor.health_checks.len(), 1);
275
276        let report = registry
277            .run_health(aster_forge_runtime::HealthCheckScope::Diagnostics)
278            .await;
279        assert_eq!(report.status(), HealthStatus::Healthy);
280    }
281}