Skip to main content

aster_forge_cache/
lib.rs

1//! Shared cache abstractions and backend constructors for Aster services.
2//!
3//! The public API is byte-oriented so cache backends can remain object-safe and easy to wrap in
4//! `Arc<dyn CacheBackend>`. JSON convenience methods are provided as an extension trait for common
5//! application values, while concrete memory and Redis implementations live behind feature-gated
6//! modules.
7#![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
45/// Result type returned by cache operations.
46pub type Result<T> = std::result::Result<T, CacheError>;
47
48/// Errors returned by cache construction and health checks.
49#[derive(Debug, thiserror::Error)]
50pub enum CacheError {
51    /// Redis could not be reached or initialized.
52    #[error("redis cache connection: {0}")]
53    RedisConnection(String),
54    /// Redis is temporarily unavailable and the local fallback circuit is open.
55    #[error("redis cache is in fallback mode for another {remaining_ms}ms")]
56    RedisFallbackMode {
57        /// Remaining fallback-circuit duration in milliseconds.
58        remaining_ms: u128,
59    },
60    /// Redis health check returned an error.
61    #[error("redis cache health check: {0}")]
62    RedisHealthCheck(String),
63    /// Redis health check timed out.
64    #[error("redis cache health check timed out after {timeout_ms}ms")]
65    RedisHealthCheckTimeout {
66        /// Health-check timeout in milliseconds.
67        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/// Configuration used to construct a cache backend.
82#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
83pub struct CacheConfig {
84    /// Backend name. Currently `memory` and `redis` are recognized.
85    #[serde(default = "CacheConfig::default_backend")]
86    pub backend: String,
87    /// Backend endpoint. Redis uses a Redis connection URL.
88    #[serde(default, alias = "redis_url")]
89    pub endpoint: String,
90    /// Default time-to-live, in seconds, for entries that do not specify an explicit TTL.
91    #[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/// Object-safe cache backend trait that exposes a common byte-oriented API.
131#[async_trait]
132pub trait CacheBackend: Send + Sync {
133    /// Returns the stable backend name.
134    fn backend_name(&self) -> &'static str;
135    /// Performs a lightweight backend health check.
136    async fn health_check(&self) -> Result<()>;
137    /// Reads a raw byte value by key.
138    async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>;
139    /// Atomically reads and removes a raw byte value by key when supported.
140    async fn take_bytes(&self, key: &str) -> Option<Vec<u8>>;
141    /// Writes a raw byte value by key with an optional TTL in seconds.
142    ///
143    /// `None` uses the backend default TTL. A TTL of `0` expires immediately, so the
144    /// observable result matches deleting the key (Redis rejects `SETEX 0`; backends
145    /// normalize the contract instead of issuing an invalid command).
146    async fn set_bytes(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>);
147    /// Writes a raw byte value only when the key is absent.
148    ///
149    /// With a TTL of `0` the value expires immediately, so the call reports whether a
150    /// live value existed and retains nothing either way.
151    async fn set_bytes_if_absent(&self, key: &str, value: Vec<u8>, ttl_secs: Option<u64>) -> bool;
152    /// Removes a key from the cache.
153    async fn delete(&self, key: &str);
154    /// Removes multiple keys from the cache.
155    async fn delete_many(&self, keys: &[String]) {
156        for key in keys {
157            self.delete(key).await;
158        }
159    }
160    /// Invalidates every key with the given prefix.
161    async fn invalidate_prefix(&self, prefix: &str);
162}
163
164/// Convenience methods for JSON serialization and deserialization.
165pub trait CacheExt {
166    /// Reads and deserializes a JSON value from the cache.
167    fn get<T: DeserializeOwned + Send>(
168        &self,
169        key: &str,
170    ) -> impl std::future::Future<Output = Option<T>> + Send;
171
172    /// Serializes and writes a JSON value to the cache.
173    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    /// Atomically reads, removes, and deserializes a JSON value from the cache.
181    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/// Creates a cache backend from configuration.
206#[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}