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")]
320pub async fn create_cache_with_policy(
321 config: &CacheConfig,
322 failure_policy: CacheBackendFailurePolicy,
323) -> Result<Arc<dyn CacheBackend>> {
324 match config.normalized_backend().as_ref() {
325 #[cfg(feature = "redis")]
326 "redis" => {
327 match redis_cache::RedisCache::from_endpoint(&config.endpoint, config.default_ttl).await
328 {
329 Ok(cache) => {
330 tracing::info!(
331 target = %redis_backend_target(&config.endpoint),
332 "cache backend: redis"
333 );
334 Ok(Arc::new(cache))
335 }
336 Err(error) => handle_cache_backend_failure(config, failure_policy, error),
337 }
338 }
339 "memory" => {
340 tracing::info!("cache backend: memory (ttl={}s)", config.default_ttl);
341 Ok(create_memory_cache(config.default_ttl))
342 }
343 backend => handle_cache_backend_failure(
344 config,
345 failure_policy,
346 CacheError::InvalidConfiguration(format!("unsupported cache backend '{backend}'")),
347 ),
348 }
349}
350
351#[cfg(feature = "memory")]
352fn handle_cache_backend_failure(
353 config: &CacheConfig,
354 failure_policy: CacheBackendFailurePolicy,
355 error: CacheError,
356) -> Result<Arc<dyn CacheBackend>> {
357 match failure_policy {
358 CacheBackendFailurePolicy::ReturnError => Err(error),
359 CacheBackendFailurePolicy::FallbackToMemory => {
360 tracing::warn!(
361 error = %error,
362 "cache backend construction failed; falling back to memory cache"
363 );
364 Ok(create_memory_cache(config.default_ttl))
365 }
366 }
367}
368
369#[cfg(feature = "memory")]
374pub async fn create_cache(config: &CacheConfig) -> Arc<dyn CacheBackend> {
375 match create_cache_with_policy(config, CacheBackendFailurePolicy::FallbackToMemory).await {
376 Ok(cache) => cache,
377 Err(error) => {
378 tracing::warn!(
379 error = %error,
380 "cache compatibility constructor encountered an unexpected error; using memory cache"
381 );
382 create_memory_cache(config.default_ttl)
383 }
384 }
385}
386
387#[cfg(feature = "memory")]
388fn create_memory_cache(default_ttl: u64) -> Arc<dyn CacheBackend> {
389 Arc::new(memory::MemoryCache::new(default_ttl))
390}
391
392#[cfg(test)]
393mod tests {
394 use super::{CacheBackendFailurePolicy, CacheConfig, CacheEndpoint, CacheError};
395
396 #[test]
397 fn cache_config_default_uses_memory_backend() {
398 let config = CacheConfig::default();
399
400 assert_eq!(config.backend, "memory");
401 assert_eq!(config.endpoint, CacheEndpoint::default());
402 assert_eq!(config.default_ttl, 3600);
403 }
404
405 #[test]
406 fn cache_config_deserializes_missing_fields_with_defaults() {
407 let config: CacheConfig =
408 serde_json::from_str("{}").expect("empty cache config should use field defaults");
409
410 assert_eq!(config, CacheConfig::default());
411 }
412
413 #[test]
414 fn cache_config_deserializes_endpoint_field() {
415 let config: CacheConfig = serde_json::from_str(
416 r#"{"backend":"redis","endpoint":"redis://127.0.0.1/","default_ttl":30}"#,
417 )
418 .expect("cache config should accept the endpoint field");
419
420 assert_eq!(config.backend, "redis");
421 assert_eq!(config.endpoint, CacheEndpoint::url("redis://127.0.0.1/"));
422 assert_eq!(config.default_ttl, 30);
423 }
424
425 #[test]
426 fn cache_config_accepts_legacy_redis_url_alias() {
427 let config: CacheConfig = serde_json::from_str(
428 r#"{"backend":"redis","redis_url":"redis://127.0.0.1/","default_ttl":30}"#,
429 )
430 .expect("cache config should accept legacy redis_url config files");
431
432 assert_eq!(config.backend, "redis");
433 assert_eq!(config.endpoint, CacheEndpoint::url("redis://127.0.0.1/"));
434 assert_eq!(config.default_ttl, 30);
435 }
436
437 #[test]
438 fn cache_backend_normalization_trims_and_folds_ascii_case() {
439 for (backend, expected) in [
440 (" memory ", "memory"),
441 (" ReDiS ", "redis"),
442 (" CUSTOM-BACKEND ", "custom-backend"),
443 (" \n\t", ""),
444 ] {
445 let config = CacheConfig {
446 backend: backend.to_string(),
447 ..CacheConfig::default()
448 };
449 assert_eq!(config.normalized_backend(), expected);
450 }
451 }
452
453 #[cfg(feature = "memory")]
454 #[tokio::test]
455 async fn create_cache_uses_normalized_memory_backend() {
456 let cache = super::create_cache(&CacheConfig {
457 backend: " MeMoRy ".to_string(),
458 ..CacheConfig::default()
459 })
460 .await;
461
462 assert_eq!(cache.backend_name(), "memory");
463 }
464
465 #[cfg(feature = "memory")]
466 #[tokio::test]
467 async fn create_cache_uses_memory_for_unknown_backend() {
468 let cache = super::create_cache(&CacheConfig {
469 backend: "unknown".to_string(),
470 endpoint: "redis://127.0.0.1/".into(),
471 default_ttl: 5,
472 })
473 .await;
474
475 assert_eq!(cache.backend_name(), "memory");
476 cache.health_check().await.expect("memory cache is healthy");
477 }
478
479 #[cfg(feature = "memory")]
480 #[tokio::test]
481 async fn explicit_error_policy_rejects_unknown_backend() {
482 let Err(error) = super::create_cache_with_policy(
483 &CacheConfig {
484 backend: "unknown".to_string(),
485 ..CacheConfig::default()
486 },
487 CacheBackendFailurePolicy::ReturnError,
488 )
489 .await
490 else {
491 panic!("unknown backend should be returned to explicit callers");
492 };
493
494 assert!(
495 error
496 .to_string()
497 .contains("unsupported cache backend 'unknown'")
498 );
499 }
500
501 #[cfg(feature = "redis")]
502 #[tokio::test]
503 async fn explicit_error_policy_returns_redis_construction_error() {
504 let Err(error) = super::create_cache_with_policy(
505 &CacheConfig {
506 backend: "redis".to_string(),
507 endpoint: "not a redis url".into(),
508 default_ttl: 60,
509 },
510 CacheBackendFailurePolicy::ReturnError,
511 )
512 .await
513 else {
514 panic!("invalid Redis endpoint should be returned to explicit callers");
515 };
516
517 assert!(error.to_string().contains("redis cache connection"));
518 }
519
520 #[cfg(feature = "redis")]
521 #[test]
522 fn redis_backend_target_strips_credentials() {
523 assert_eq!(
524 super::redis_backend_target(&CacheEndpoint::url(
525 "redis://user:secret@example.com:6379/0",
526 )),
527 "redis://example.com:6379"
528 );
529 }
530
531 #[cfg(feature = "redis")]
532 #[test]
533 fn redis_backend_target_keeps_host_without_credentials() {
534 assert_eq!(
535 super::redis_backend_target(&CacheEndpoint::url("rediss://cache.internal:6380/1",)),
536 "rediss://cache.internal:6380"
537 );
538 }
539
540 #[cfg(feature = "redis")]
541 #[test]
542 fn redis_backend_target_handles_malformed_or_empty_hosts() {
543 assert_eq!(
544 super::redis_backend_target(&CacheEndpoint::url("not-a-url")),
545 "configured"
546 );
547 assert_eq!(
548 super::redis_backend_target(&CacheEndpoint::url("redis:///0")),
549 "redis://configured"
550 );
551 }
552
553 #[test]
554 fn cache_config_deserializes_structured_credentials_and_redacts_debug() {
555 let raw_username = "cache-user@example.com";
556 let raw_password = "cache#[]{}^+=*@:/?%secret";
557 let config: CacheConfig = serde_json::from_str(&format!(
558 r#"{{"backend":"redis","endpoint":{{"base_url":"redis://cache.example/0","username":"{raw_username}","password":"{raw_password}"}}}}"#,
559 ))
560 .unwrap();
561
562 assert_eq!(
563 config.endpoint,
564 CacheEndpoint::credentials(
565 "redis://cache.example/0",
566 Some(raw_username.to_string()),
567 Some(raw_password.to_string()),
568 )
569 );
570 let debug = format!("{config:?}");
571 assert!(!debug.contains(raw_username));
572 assert!(!debug.contains(raw_password));
573 assert!(debug.contains("CacheEndpoint::Credentials(<redacted>)"));
574
575 let serialized = serde_json::to_string(&config).unwrap();
576 assert!(serialized.contains("redis://cache.example/0"));
577 assert!(!serialized.contains(raw_username));
578 assert!(!serialized.contains(raw_password));
579 assert!(!serialized.contains("cache%23%5B%5D"));
580 }
581
582 #[test]
583 fn cache_error_display_messages_are_stable() {
584 assert_eq!(
585 CacheError::InvalidConfiguration("bad backend".to_string()).to_string(),
586 "invalid cache configuration: bad backend"
587 );
588 assert_eq!(
589 CacheError::RedisConnection("refused".to_string()).to_string(),
590 "redis cache connection: refused"
591 );
592 assert_eq!(
593 CacheError::RedisFallbackMode { remaining_ms: 25 }.to_string(),
594 "redis cache is in fallback mode for another 25ms"
595 );
596 assert_eq!(
597 CacheError::RedisHealthCheck("PONG missing".to_string()).to_string(),
598 "redis cache health check: PONG missing"
599 );
600 assert_eq!(
601 CacheError::RedisHealthCheckTimeout { timeout_ms: 250 }.to_string(),
602 "redis cache health check timed out after 250ms"
603 );
604 }
605}