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};
32#[cfg(feature = "memory")]
33use std::sync::Arc;
34
35#[cfg(feature = "runtime-component")]
36pub use health::{
37 CACHE_COMPONENT, CACHE_HEALTH_CHECK, CACHE_HEALTH_CHECK_TIMEOUT, CacheHealthComponent,
38 cache_health_component, cache_health_options, check_cache_component,
39};
40#[cfg(feature = "memory")]
41pub use memory::MemoryCache;
42#[cfg(feature = "redis")]
43pub use redis_cache::RedisCache;
44
45pub type Result<T> = std::result::Result<T, CacheError>;
47
48#[derive(Debug, thiserror::Error)]
50pub enum CacheError {
51 #[error("redis cache connection: {0}")]
53 RedisConnection(String),
54 #[error("redis cache is in fallback mode for another {remaining_ms}ms")]
56 RedisFallbackMode {
57 remaining_ms: u128,
59 },
60 #[error("redis cache health check: {0}")]
62 RedisHealthCheck(String),
63 #[error("redis cache health check timed out after {timeout_ms}ms")]
65 RedisHealthCheckTimeout {
66 timeout_ms: u128,
68 },
69}
70
71#[cfg(feature = "redis")]
72impl From<redis::RedisError> for CacheError {
73 fn from(value: redis::RedisError) -> Self {
74 Self::RedisConnection(value.to_string())
75 }
76}
77
78const DEFAULT_CACHE_BACKEND: &str = "memory";
79const DEFAULT_CACHE_TTL_SECS: u64 = 3600;
80
81#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
83pub struct CacheConfig {
84 #[serde(default = "CacheConfig::default_backend")]
86 pub backend: String,
87 #[serde(default, alias = "redis_url")]
89 pub endpoint: String,
90 #[serde(default = "CacheConfig::default_ttl")]
92 pub default_ttl: u64,
93}
94
95impl Default for CacheConfig {
96 fn default() -> Self {
97 Self {
98 backend: Self::default_backend(),
99 endpoint: String::new(),
100 default_ttl: Self::default_ttl(),
101 }
102 }
103}
104
105impl CacheConfig {
106 fn default_backend() -> String {
107 DEFAULT_CACHE_BACKEND.to_string()
108 }
109
110 const fn default_ttl() -> u64 {
111 DEFAULT_CACHE_TTL_SECS
112 }
113}
114
115#[cfg(feature = "redis")]
116fn redis_backend_target(endpoint: &str) -> String {
117 let Some((scheme, rest)) = endpoint.split_once("://") else {
118 return "configured".to_string();
119 };
120
121 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
122 let host = authority.rsplit('@').next().unwrap_or(authority);
123 if host.is_empty() {
124 format!("{scheme}://configured")
125 } else {
126 format!("{scheme}://{host}")
127 }
128}
129
130#[async_trait]
132pub trait CacheBackend: Send + Sync {
133 fn backend_name(&self) -> &'static str;
135 async fn health_check(&self) -> Result<()>;
137 async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>;
139 async fn take_bytes(&self, key: &str) -> Option<Vec<u8>>;
141 async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>);
147 async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool;
152 async fn delete(&self, key: &str);
154 async fn delete_many(&self, keys: &[String]) {
156 for key in keys {
157 self.delete(key).await;
158 }
159 }
160 async fn invalidate_prefix(&self, prefix: &str);
162}
163
164pub trait CacheExt {
166 fn get<T: DeserializeOwned + Send>(
168 &self,
169 key: &str,
170 ) -> impl std::future::Future<Output = Option<T>> + Send;
171
172 fn set<T: Serialize + Send + Sync>(
174 &self,
175 key: &str,
176 value: &T,
177 ttl_secs: Option<u64>,
178 ) -> impl std::future::Future<Output = ()> + Send;
179
180 fn take<T: DeserializeOwned + Send>(
182 &self,
183 key: &str,
184 ) -> impl std::future::Future<Output = Option<T>> + Send;
185}
186
187impl CacheExt for dyn CacheBackend {
188 async fn get<T: DeserializeOwned + Send>(&self, key: &str) -> Option<T> {
189 let bytes = self.get_bytes(key).await?;
190 serde_json::from_slice(&bytes).ok()
191 }
192
193 async fn set<T: Serialize + Send + Sync>(&self, key: &str, value: &T, ttl_secs: Option<u64>) {
194 if let Ok(bytes) = serde_json::to_vec(value) {
195 self.set_bytes(key, bytes, ttl_secs).await;
196 }
197 }
198
199 async fn take<T: DeserializeOwned + Send>(&self, key: &str) -> Option<T> {
200 let bytes = self.take_bytes(key).await?;
201 serde_json::from_slice(&bytes).ok()
202 }
203}
204
205#[cfg(feature = "memory")]
207pub async fn create_cache(config: &CacheConfig) -> Arc<dyn CacheBackend> {
208 match config.backend.as_str() {
209 #[cfg(feature = "redis")]
210 "redis" => match redis_cache::RedisCache::new(&config.endpoint, config.default_ttl).await {
211 Ok(cache) => {
212 tracing::info!(
213 target = %redis_backend_target(&config.endpoint),
214 "cache backend: redis"
215 );
216 Arc::new(cache)
217 }
218 Err(e) => {
219 tracing::warn!("redis connection failed: {e}, falling back to memory cache");
220 create_memory_cache(config.default_ttl)
221 }
222 },
223 _ => {
224 tracing::info!("cache backend: memory (ttl={}s)", config.default_ttl);
225 create_memory_cache(config.default_ttl)
226 }
227 }
228}
229
230#[cfg(feature = "memory")]
231fn create_memory_cache(default_ttl: u64) -> Arc<dyn CacheBackend> {
232 Arc::new(memory::MemoryCache::new(default_ttl))
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{CacheConfig, CacheError};
238
239 #[test]
240 fn cache_config_default_uses_memory_backend() {
241 let config = CacheConfig::default();
242
243 assert_eq!(config.backend, "memory");
244 assert_eq!(config.endpoint, "");
245 assert_eq!(config.default_ttl, 3600);
246 }
247
248 #[test]
249 fn cache_config_deserializes_missing_fields_with_defaults() {
250 let config: CacheConfig =
251 serde_json::from_str("{}").expect("empty cache config should use field defaults");
252
253 assert_eq!(config, CacheConfig::default());
254 }
255
256 #[test]
257 fn cache_config_deserializes_endpoint_field() {
258 let config: CacheConfig = serde_json::from_str(
259 r#"{"backend":"redis","endpoint":"redis://127.0.0.1/","default_ttl":30}"#,
260 )
261 .expect("cache config should accept the endpoint field");
262
263 assert_eq!(config.backend, "redis");
264 assert_eq!(config.endpoint, "redis://127.0.0.1/");
265 assert_eq!(config.default_ttl, 30);
266 }
267
268 #[test]
269 fn cache_config_accepts_legacy_redis_url_alias() {
270 let config: CacheConfig = serde_json::from_str(
271 r#"{"backend":"redis","redis_url":"redis://127.0.0.1/","default_ttl":30}"#,
272 )
273 .expect("cache config should accept legacy redis_url config files");
274
275 assert_eq!(config.backend, "redis");
276 assert_eq!(config.endpoint, "redis://127.0.0.1/");
277 assert_eq!(config.default_ttl, 30);
278 }
279
280 #[cfg(feature = "memory")]
281 #[tokio::test]
282 async fn create_cache_uses_memory_for_unknown_backend() {
283 let cache = super::create_cache(&CacheConfig {
284 backend: "unknown".to_string(),
285 endpoint: "redis://127.0.0.1/".to_string(),
286 default_ttl: 5,
287 })
288 .await;
289
290 assert_eq!(cache.backend_name(), "memory");
291 cache.health_check().await.expect("memory cache is healthy");
292 }
293
294 #[cfg(feature = "redis")]
295 #[test]
296 fn redis_backend_target_strips_credentials() {
297 assert_eq!(
298 super::redis_backend_target("redis://user:secret@example.com:6379/0"),
299 "redis://example.com:6379"
300 );
301 }
302
303 #[cfg(feature = "redis")]
304 #[test]
305 fn redis_backend_target_keeps_host_without_credentials() {
306 assert_eq!(
307 super::redis_backend_target("rediss://cache.internal:6380/1"),
308 "rediss://cache.internal:6380"
309 );
310 }
311
312 #[cfg(feature = "redis")]
313 #[test]
314 fn redis_backend_target_handles_malformed_or_empty_hosts() {
315 assert_eq!(super::redis_backend_target("not-a-url"), "configured");
316 assert_eq!(
317 super::redis_backend_target("redis:///0"),
318 "redis://configured"
319 );
320 }
321
322 #[test]
323 fn cache_error_display_messages_are_stable() {
324 assert_eq!(
325 CacheError::RedisConnection("refused".to_string()).to_string(),
326 "redis cache connection: refused"
327 );
328 assert_eq!(
329 CacheError::RedisFallbackMode { remaining_ms: 25 }.to_string(),
330 "redis cache is in fallback mode for another 25ms"
331 );
332 assert_eq!(
333 CacheError::RedisHealthCheck("PONG missing".to_string()).to_string(),
334 "redis cache health check: PONG missing"
335 );
336 assert_eq!(
337 CacheError::RedisHealthCheckTimeout { timeout_ms: 250 }.to_string(),
338 "redis cache health check timed out after 250ms"
339 );
340 }
341}