1#![cfg_attr(
8 not(test),
9 deny(
10 clippy::unwrap_used,
11 clippy::unreachable,
12 clippy::expect_used,
13 clippy::panic,
14 clippy::unimplemented,
15 clippy::todo
16 )
17)]
18
19#[cfg(feature = "bloom")]
20pub mod bloom;
21#[cfg(feature = "runtime-component")]
22mod health;
23#[cfg(feature = "memory")]
24mod memory;
25#[cfg(feature = "redis")]
26mod redis_cache;
27#[cfg(feature = "memory")]
28mod reservation;
29
30use async_trait::async_trait;
31use serde::{Serialize, de::DeserializeOwned};
32use std::borrow::Cow;
33#[cfg(feature = "memory")]
34use std::sync::Arc;
35
36#[cfg(feature = "runtime-component")]
37pub use health::{
38 CACHE_COMPONENT, CACHE_HEALTH_CHECK, CACHE_HEALTH_CHECK_TIMEOUT, CacheHealthComponent,
39 cache_health_component, cache_health_options, check_cache_component,
40};
41#[cfg(feature = "memory")]
42pub use memory::MemoryCache;
43#[cfg(feature = "redis")]
44pub use redis_cache::RedisCache;
45
46pub type Result<T> = std::result::Result<T, CacheError>;
48
49#[derive(Debug, thiserror::Error)]
51pub enum CacheError {
52 #[error("invalid cache configuration: {0}")]
54 InvalidConfiguration(String),
55 #[error("redis cache connection: {0}")]
57 RedisConnection(String),
58 #[error("redis cache is in fallback mode for another {remaining_ms}ms")]
60 RedisFallbackMode {
61 remaining_ms: u128,
63 },
64 #[error("redis cache health check: {0}")]
66 RedisHealthCheck(String),
67 #[error("redis cache health check timed out after {timeout_ms}ms")]
69 RedisHealthCheckTimeout {
70 timeout_ms: u128,
72 },
73}
74
75#[cfg(feature = "redis")]
76impl From<redis::RedisError> for CacheError {
77 fn from(value: redis::RedisError) -> Self {
78 Self::RedisConnection(value.to_string())
79 }
80}
81
82const DEFAULT_CACHE_BACKEND: &str = "memory";
83const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
84
85#[derive(Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
90#[serde(untagged)]
91pub enum CacheEndpoint {
92 Url(String),
94 Credentials {
96 base_url: String,
98 #[serde(default, skip_serializing)]
100 username: Option<String>,
101 #[serde(default, skip_serializing)]
103 password: Option<String>,
104 },
105}
106
107impl CacheEndpoint {
108 pub fn url(url: impl Into<String>) -> Self {
110 Self::Url(url.into())
111 }
112
113 pub fn credentials(
115 base_url: impl Into<String>,
116 username: Option<String>,
117 password: Option<String>,
118 ) -> Self {
119 Self::Credentials {
120 base_url: base_url.into(),
121 username,
122 password,
123 }
124 }
125}
126
127impl Default for CacheEndpoint {
128 fn default() -> Self {
129 Self::Url(String::new())
130 }
131}
132
133impl From<String> for CacheEndpoint {
134 fn from(url: String) -> Self {
135 Self::Url(url)
136 }
137}
138
139impl From<&str> for CacheEndpoint {
140 fn from(url: &str) -> Self {
141 Self::Url(url.to_string())
142 }
143}
144
145impl std::fmt::Debug for CacheEndpoint {
146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 match self {
148 Self::Url(_) => formatter.write_str("CacheEndpoint::Url(<redacted>)"),
149 Self::Credentials { .. } => {
150 formatter.write_str("CacheEndpoint::Credentials(<redacted>)")
151 }
152 }
153 }
154}
155
156#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
158pub struct CacheConfig {
159 #[serde(default = "CacheConfig::default_backend")]
161 pub backend: String,
162 #[serde(default, alias = "redis_url")]
164 pub endpoint: CacheEndpoint,
165 #[serde(default = "CacheConfig::default_ttl")]
167 pub default_ttl: u64,
168}
169
170impl Default for CacheConfig {
171 fn default() -> Self {
172 Self {
173 backend: Self::default_backend(),
174 endpoint: CacheEndpoint::default(),
175 default_ttl: Self::default_ttl(),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub enum CacheBackendFailurePolicy {
183 ReturnError,
185 #[default]
187 FallbackToMemory,
188}
189
190impl CacheConfig {
191 fn default_backend() -> String {
192 DEFAULT_CACHE_BACKEND.to_string()
193 }
194
195 const fn default_ttl() -> u64 {
196 DEFAULT_CACHE_TTL_SECS
197 }
198
199 #[must_use]
201 pub fn normalized_backend(&self) -> Cow<'_, str> {
202 let backend = self.backend.trim();
203 if backend.eq_ignore_ascii_case("memory") {
204 Cow::Borrowed("memory")
205 } else if backend.eq_ignore_ascii_case("redis") {
206 Cow::Borrowed("redis")
207 } else if backend.bytes().all(|byte| !byte.is_ascii_uppercase()) {
208 Cow::Borrowed(backend)
209 } else {
210 Cow::Owned(backend.to_ascii_lowercase())
211 }
212 }
213}
214
215#[cfg(feature = "redis")]
216fn redis_backend_target_url(endpoint: &str) -> String {
217 let Some((scheme, rest)) = endpoint.split_once("://") else {
218 return "configured".to_string();
219 };
220
221 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
222 let host = authority.rsplit('@').next().unwrap_or(authority);
223 if host.is_empty() {
224 format!("{scheme}://configured")
225 } else {
226 format!("{scheme}://{host}")
227 }
228}
229
230#[cfg(feature = "redis")]
231fn redis_backend_target(endpoint: &CacheEndpoint) -> String {
232 match endpoint {
233 CacheEndpoint::Url(url) => redis_backend_target_url(url),
234 CacheEndpoint::Credentials { base_url, .. } => redis_backend_target_url(base_url),
235 }
236}
237
238#[async_trait]
240pub trait CacheBackend: Send + Sync {
241 fn backend_name(&self) -> &'static str;
243 async fn health_check(&self) -> Result<()>;
245 async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>;
247 async fn take_bytes(&self, key: &str) -> Option<Vec<u8>>;
249 async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>);
255 async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool;
260 async fn delete(&self, key: &str);
262 async fn delete_many(&self, keys: &[String]) {
264 for key in keys {
265 self.delete(key).await;
266 }
267 }
268 async fn invalidate_prefix(&self, prefix: &str);
270}
271
272pub trait CacheExt {
274 fn get<T: DeserializeOwned + Send>(
276 &self,
277 key: &str,
278 ) -> impl std::future::Future<Output = Option<T>> + Send;
279
280 fn set<T: Serialize + Send + Sync>(
282 &self,
283 key: &str,
284 value: &T,
285 ttl_secs: Option<u64>,
286 ) -> impl std::future::Future<Output = ()> + Send;
287
288 fn take<T: DeserializeOwned + Send>(
290 &self,
291 key: &str,
292 ) -> impl std::future::Future<Output = Option<T>> + Send;
293}
294
295impl CacheExt for dyn CacheBackend {
296 async fn get<T: DeserializeOwned + Send>(&self, key: &str) -> Option<T> {
297 let bytes = self.get_bytes(key).await?;
298 serde_json::from_slice(&bytes).ok()
299 }
300
301 async fn set<T: Serialize + Send + Sync>(&self, key: &str, value: &T, ttl_secs: Option<u64>) {
302 if let Ok(bytes) = serde_json::to_vec(value) {
303 self.set_bytes(key, bytes, ttl_secs).await;
304 }
305 }
306
307 async fn take<T: DeserializeOwned + Send>(&self, key: &str) -> Option<T> {
308 let bytes = self.take_bytes(key).await?;
309 serde_json::from_slice(&bytes).ok()
310 }
311}
312
313#[cfg(feature = "memory")]
320#[cfg_attr(
321 not(feature = "redis"),
322 expect(
323 clippy::unused_async,
324 reason = "The Redis feature awaits connection setup; keep one factory API across feature sets."
325 )
326)]
327pub async fn create_cache_with_policy(
328 config: &CacheConfig,
329 failure_policy: CacheBackendFailurePolicy,
330) -> Result<Arc<dyn CacheBackend>> {
331 match config.normalized_backend().as_ref() {
332 #[cfg(feature = "redis")]
333 "redis" => {
334 match redis_cache::RedisCache::from_endpoint(&config.endpoint, config.default_ttl).await
335 {
336 Ok(cache) => {
337 tracing::info!(
338 target = %redis_backend_target(&config.endpoint),
339 "cache backend: redis"
340 );
341 Ok(Arc::new(cache))
342 }
343 Err(error) => handle_cache_backend_failure(config, failure_policy, error),
344 }
345 }
346 "memory" => {
347 tracing::info!("cache backend: memory (ttl={}s)", config.default_ttl);
348 Ok(create_memory_cache(config.default_ttl))
349 }
350 backend => handle_cache_backend_failure(
351 config,
352 failure_policy,
353 CacheError::InvalidConfiguration(format!("unsupported cache backend '{backend}'")),
354 ),
355 }
356}
357
358#[cfg(feature = "memory")]
359fn handle_cache_backend_failure(
360 config: &CacheConfig,
361 failure_policy: CacheBackendFailurePolicy,
362 error: CacheError,
363) -> Result<Arc<dyn CacheBackend>> {
364 match failure_policy {
365 CacheBackendFailurePolicy::ReturnError => Err(error),
366 CacheBackendFailurePolicy::FallbackToMemory => {
367 tracing::warn!(
368 error = %error,
369 "cache backend construction failed; falling back to memory cache"
370 );
371 Ok(create_memory_cache(config.default_ttl))
372 }
373 }
374}
375
376#[cfg(feature = "memory")]
381pub async fn create_cache(config: &CacheConfig) -> Arc<dyn CacheBackend> {
382 match create_cache_with_policy(config, CacheBackendFailurePolicy::FallbackToMemory).await {
383 Ok(cache) => cache,
384 Err(error) => {
385 tracing::warn!(
386 error = %error,
387 "cache compatibility constructor encountered an unexpected error; using memory cache"
388 );
389 create_memory_cache(config.default_ttl)
390 }
391 }
392}
393
394#[cfg(feature = "memory")]
395fn create_memory_cache(default_ttl: u64) -> Arc<dyn CacheBackend> {
396 Arc::new(memory::MemoryCache::new(default_ttl))
397}
398
399#[cfg(test)]
400mod tests {
401 use super::{CacheBackendFailurePolicy, CacheConfig, CacheEndpoint, CacheError};
402
403 #[test]
404 fn cache_config_default_uses_memory_backend() {
405 let config = CacheConfig::default();
406
407 assert_eq!(config.backend, "memory");
408 assert_eq!(config.endpoint, CacheEndpoint::default());
409 assert_eq!(config.default_ttl, 3600);
410 }
411
412 #[test]
413 fn cache_config_deserializes_missing_fields_with_defaults() {
414 let config: CacheConfig =
415 serde_json::from_str("{}").expect("empty cache config should use field defaults");
416
417 assert_eq!(config, CacheConfig::default());
418 }
419
420 #[test]
421 fn cache_config_deserializes_endpoint_field() {
422 let config: CacheConfig = serde_json::from_str(
423 r#"{"backend":"redis","endpoint":"redis://127.0.0.1/","default_ttl":30}"#,
424 )
425 .expect("cache config should accept the endpoint field");
426
427 assert_eq!(config.backend, "redis");
428 assert_eq!(config.endpoint, CacheEndpoint::url("redis://127.0.0.1/"));
429 assert_eq!(config.default_ttl, 30);
430 }
431
432 #[test]
433 fn cache_config_accepts_legacy_redis_url_alias() {
434 let config: CacheConfig = serde_json::from_str(
435 r#"{"backend":"redis","redis_url":"redis://127.0.0.1/","default_ttl":30}"#,
436 )
437 .expect("cache config should accept legacy redis_url config files");
438
439 assert_eq!(config.backend, "redis");
440 assert_eq!(config.endpoint, CacheEndpoint::url("redis://127.0.0.1/"));
441 assert_eq!(config.default_ttl, 30);
442 }
443
444 #[test]
445 fn cache_backend_normalization_trims_and_folds_ascii_case() {
446 for (backend, expected) in [
447 (" memory ", "memory"),
448 (" ReDiS ", "redis"),
449 (" CUSTOM-BACKEND ", "custom-backend"),
450 (" \n\t", ""),
451 ] {
452 let config = CacheConfig {
453 backend: backend.to_string(),
454 ..CacheConfig::default()
455 };
456 assert_eq!(config.normalized_backend(), expected);
457 }
458 }
459
460 #[cfg(feature = "memory")]
461 #[tokio::test]
462 async fn create_cache_uses_normalized_memory_backend() {
463 let cache = super::create_cache(&CacheConfig {
464 backend: " MeMoRy ".to_string(),
465 ..CacheConfig::default()
466 })
467 .await;
468
469 assert_eq!(cache.backend_name(), "memory");
470 }
471
472 #[cfg(feature = "memory")]
473 #[tokio::test]
474 async fn create_cache_uses_memory_for_unknown_backend() {
475 let cache = super::create_cache(&CacheConfig {
476 backend: "unknown".to_string(),
477 endpoint: "redis://127.0.0.1/".into(),
478 default_ttl: 5,
479 })
480 .await;
481
482 assert_eq!(cache.backend_name(), "memory");
483 cache.health_check().await.expect("memory cache is healthy");
484 }
485
486 #[cfg(feature = "memory")]
487 #[tokio::test]
488 async fn explicit_error_policy_rejects_unknown_backend() {
489 let Err(error) = super::create_cache_with_policy(
490 &CacheConfig {
491 backend: "unknown".to_string(),
492 ..CacheConfig::default()
493 },
494 CacheBackendFailurePolicy::ReturnError,
495 )
496 .await
497 else {
498 panic!("unknown backend should be returned to explicit callers");
499 };
500
501 assert!(
502 error
503 .to_string()
504 .contains("unsupported cache backend 'unknown'")
505 );
506 }
507
508 #[cfg(feature = "redis")]
509 #[tokio::test]
510 async fn explicit_error_policy_returns_redis_construction_error() {
511 let Err(error) = super::create_cache_with_policy(
512 &CacheConfig {
513 backend: "redis".to_string(),
514 endpoint: "not a redis url".into(),
515 default_ttl: 60,
516 },
517 CacheBackendFailurePolicy::ReturnError,
518 )
519 .await
520 else {
521 panic!("invalid Redis endpoint should be returned to explicit callers");
522 };
523
524 assert!(error.to_string().contains("redis cache connection"));
525 }
526
527 #[cfg(feature = "redis")]
528 #[test]
529 fn redis_backend_target_strips_credentials() {
530 assert_eq!(
531 super::redis_backend_target(&CacheEndpoint::url(
532 "redis://user:secret@example.com:6379/0",
533 )),
534 "redis://example.com:6379"
535 );
536 }
537
538 #[cfg(feature = "redis")]
539 #[test]
540 fn redis_backend_target_keeps_host_without_credentials() {
541 assert_eq!(
542 super::redis_backend_target(&CacheEndpoint::url("rediss://cache.internal:6380/1",)),
543 "rediss://cache.internal:6380"
544 );
545 }
546
547 #[cfg(feature = "redis")]
548 #[test]
549 fn redis_backend_target_handles_malformed_or_empty_hosts() {
550 assert_eq!(
551 super::redis_backend_target(&CacheEndpoint::url("not-a-url")),
552 "configured"
553 );
554 assert_eq!(
555 super::redis_backend_target(&CacheEndpoint::url("redis:///0")),
556 "redis://configured"
557 );
558 }
559
560 #[test]
561 fn cache_config_deserializes_structured_credentials_and_redacts_debug() {
562 let raw_username = "cache-user@example.com";
563 let raw_password = "cache#[]{}^+=*@:/?%secret";
564 let config: CacheConfig = serde_json::from_str(&format!(
565 r#"{{"backend":"redis","endpoint":{{"base_url":"redis://cache.example/0","username":"{raw_username}","password":"{raw_password}"}}}}"#,
566 ))
567 .unwrap();
568
569 assert_eq!(
570 config.endpoint,
571 CacheEndpoint::credentials(
572 "redis://cache.example/0",
573 Some(raw_username.to_string()),
574 Some(raw_password.to_string()),
575 )
576 );
577 let debug = format!("{config:?}");
578 assert!(!debug.contains(raw_username));
579 assert!(!debug.contains(raw_password));
580 assert!(debug.contains("CacheEndpoint::Credentials(<redacted>)"));
581
582 let serialized = serde_json::to_string(&config).unwrap();
583 assert!(serialized.contains("redis://cache.example/0"));
584 assert!(!serialized.contains(raw_username));
585 assert!(!serialized.contains(raw_password));
586 assert!(!serialized.contains("cache%23%5B%5D"));
587 }
588
589 #[test]
590 fn cache_error_display_messages_are_stable() {
591 assert_eq!(
592 CacheError::InvalidConfiguration("bad backend".to_string()).to_string(),
593 "invalid cache configuration: bad backend"
594 );
595 assert_eq!(
596 CacheError::RedisConnection("refused".to_string()).to_string(),
597 "redis cache connection: refused"
598 );
599 assert_eq!(
600 CacheError::RedisFallbackMode { remaining_ms: 25 }.to_string(),
601 "redis cache is in fallback mode for another 25ms"
602 );
603 assert_eq!(
604 CacheError::RedisHealthCheck("PONG missing".to_string()).to_string(),
605 "redis cache health check: PONG missing"
606 );
607 assert_eq!(
608 CacheError::RedisHealthCheckTimeout { timeout_ms: 250 }.to_string(),
609 "redis cache health check timed out after 250ms"
610 );
611 }
612}