1use 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
19pub const CACHE_COMPONENT: &str = "cache";
21pub const CACHE_HEALTH_CHECK: &str = "cache";
23pub const CACHE_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
25
26pub struct CacheHealthComponent {
28 config: CacheConfig,
29 cache: Arc<dyn CacheBackend>,
30}
31
32impl CacheHealthComponent {
33 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
45pub fn cache_health_component(
47 config: CacheConfig,
48 cache: Arc<dyn CacheBackend>,
49) -> RuntimeComponentBundleRegistration<CacheHealthComponent> {
50 runtime_component(CacheHealthComponent::new(config, cache))
51}
52
53fn 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
72pub fn cache_health_options() -> HealthCheckOptions {
74 HealthCheckOptions::optional(Some(CACHE_HEALTH_CHECK_TIMEOUT))
75 .with_scopes(HealthCheckScopes::diagnostics())
76}
77
78pub 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}