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
72#[must_use]
74pub fn cache_health_options() -> HealthCheckOptions {
75 HealthCheckOptions::optional(Some(CACHE_HEALTH_CHECK_TIMEOUT))
76 .with_scopes(HealthCheckScopes::diagnostics())
77}
78
79pub 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}