Skip to main content

rullst_core/
cache.rs

1//! # Rullst Cache System (`rullst::cache`)
2//!
3//! Provides a unified caching API with pluggable drivers.
4//!
5//! ## Drivers
6//! - **In-Memory** (default): `DashMap`-based concurrent store with TTL support. Zero config.
7//! - **Redis** (optional): Requires the `cache-redis` feature flag.
8//!
9//! ## Quick Start
10//! ```rust,no_run
11//! use rullst_core::cache::{Cache, CacheError};
12//!
13//! async fn cache_example() -> Result<(), CacheError> {
14//!     let cache = Cache::memory();
15//!
16//!     cache.put("user:42:name", "Alice", Some(60)).await?;
17//!     let name = cache.get("user:42:name").await?;
18//!     assert_eq!(name.as_deref().map(String::as_str), Some("Alice"));
19//!
20//!     let value = cache.remember("expensive_key", 300, || async {
21//!         Ok("computed_value".to_string())
22//!     }).await?;
23//!     assert_eq!(value.as_str(), "computed_value");
24//!
25//!     Ok(())
26//! }
27//! ```
28
29use async_trait::async_trait;
30use dashmap::DashMap;
31use std::sync::{
32    Arc,
33    atomic::{AtomicUsize, Ordering},
34};
35use std::time::Instant;
36
37mod tenant;
38pub use tenant::TenantCache;
39
40// ─── Error Types ────────────────────────────────────────────────────────────
41
42/// Errors that can occur during cache operations.
43#[derive(Debug)]
44#[non_exhaustive]
45pub enum CacheError {
46    /// The underlying driver encountered an error.
47    Driver(String),
48    /// Serialization or deserialization failed.
49    Serialization(String),
50    /// A tenant-scoped cache key was empty, oversized, or contained unsafe bytes.
51    InvalidKey(String),
52    /// The selected backend does not expose metadata inspection.
53    InspectionUnsupported,
54    /// A cache inspection requested zero or more than the fixed maximum.
55    InvalidInspectionLimit,
56}
57
58impl std::fmt::Display for CacheError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            CacheError::Driver(msg) => write!(f, "Cache driver error: {}", msg),
62            CacheError::Serialization(msg) => write!(f, "Cache serialization error: {}", msg),
63            CacheError::InvalidKey(msg) => write!(f, "Invalid cache key: {}", msg),
64            CacheError::InspectionUnsupported => {
65                write!(f, "Cache metadata inspection is unsupported by this driver")
66            }
67            CacheError::InvalidInspectionLimit => write!(
68                f,
69                "Cache inspection limit must be between 1 and {MAX_CACHE_INSPECTION_ENTRIES}"
70            ),
71        }
72    }
73}
74
75impl std::error::Error for CacheError {}
76
77// ─── Cache Driver Trait ─────────────────────────────────────────────────────
78
79/// Abstraction over cache storage backends.
80///
81/// Implement this trait to add support for new cache backends.
82/// The framework ships with `MemoryDriver` and (optionally) `RedisDriver`.
83#[async_trait]
84pub trait CacheDriver: Send + Sync {
85    /// Retrieve a value by key. Returns `None` if the key doesn't exist or has expired.
86    async fn get(&self, key: &str) -> Result<Option<Arc<String>>, CacheError>;
87    /// Store a value with an optional TTL in seconds.
88    async fn put(&self, key: &str, value: &str, ttl_secs: Option<u64>) -> Result<(), CacheError>;
89    /// Remove a key from the cache.
90    async fn forget(&self, key: &str) -> Result<(), CacheError>;
91    /// Remove all keys from the cache.
92    async fn flush(&self) -> Result<(), CacheError>;
93    /// Check if a key exists and is not expired.
94    async fn has(&self, key: &str) -> Result<bool, CacheError>;
95    /// Returns bounded metadata without values when the driver supports it.
96    async fn inspect(&self, _limit: usize) -> Result<CacheInspection, CacheError> {
97        Err(CacheError::InspectionUnsupported)
98    }
99}
100
101#[path = "cache/inspection.rs"]
102mod inspection;
103pub use inspection::{CacheEntryMetadata, CacheInspection, MAX_CACHE_INSPECTION_ENTRIES};
104
105// ─── In-Memory Driver ───────────────────────────────────────────────────────
106
107/// Cache entry holding the value and optional expiration time.
108#[derive(Clone)]
109struct CacheEntry {
110    value: Arc<String>,
111    expires_at: Option<Instant>,
112}
113
114/// In-memory cache driver using `DashMap` for lock-free concurrent access.
115///
116/// Supports TTL-based expiration. Expired entries are lazily cleaned on access;
117/// a read removes an expired key only while it still holds that expired value,
118/// so a concurrent refill of the same key is kept.
119/// Perfect for single-instance deployments and development.
120pub struct MemoryDriver {
121    store: DashMap<String, CacheEntry>,
122    operations_since_cleanup: AtomicUsize,
123}
124
125impl MemoryDriver {
126    /// Create a new in-memory cache driver.
127    pub fn new() -> Self {
128        Self {
129            store: DashMap::new(),
130            operations_since_cleanup: AtomicUsize::new(0),
131        }
132    }
133
134    /// Reclaims expired entries opportunistically without spawning a task that
135    /// could outlive the cache. Entries are also removed immediately when read.
136    fn cleanup_if_due(&self) {
137        const CLEANUP_INTERVAL_OPERATIONS: usize = 256;
138        let previous = self
139            .operations_since_cleanup
140            .fetch_add(1, Ordering::Relaxed);
141        if previous > 0 && previous.is_multiple_of(CLEANUP_INTERVAL_OPERATIONS) {
142            let now = Instant::now();
143            self.store
144                .retain(|_, entry| entry.expires_at.is_none_or(|expires_at| now < expires_at));
145        }
146    }
147
148    fn get_sync(&self, key: &str) -> Option<Arc<String>> {
149        self.cleanup_if_due();
150        let entry = self.store.get(key)?;
151        if entry
152            .expires_at
153            .is_some_and(|expires_at| Instant::now() >= expires_at)
154        {
155            let expired = Arc::clone(&entry.value);
156            drop(entry);
157            self.remove_expired(key, &expired);
158            return None;
159        }
160        Some(Arc::clone(&entry.value))
161    }
162
163    /// Removes `key` only while it still holds the expired value observed by
164    /// the caller. Every `put` allocates a new `Arc`, so a fresh value written
165    /// after the read guard was released is never deleted here.
166    fn remove_expired(&self, key: &str, expired: &Arc<String>) {
167        self.store
168            .remove_if(key, |_, current| Arc::ptr_eq(&current.value, expired));
169    }
170
171    /// Stores a value. A TTL too large for the monotonic clock to represent
172    /// (for example `u64::MAX`) is treated as "never expires" instead of
173    /// overflowing `Instant`.
174    fn put_sync(&self, key: &str, value: &str, ttl_secs: Option<u64>) {
175        self.cleanup_if_due();
176        let expires_at = ttl_secs
177            .and_then(|secs| Instant::now().checked_add(std::time::Duration::from_secs(secs)));
178        self.store.insert(
179            key.to_string(),
180            CacheEntry {
181                value: Arc::new(value.to_string()),
182                expires_at,
183            },
184        );
185    }
186}
187
188impl Default for MemoryDriver {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194#[async_trait]
195impl CacheDriver for MemoryDriver {
196    #[cfg_attr(mutants, mutants::skip)]
197    async fn get(&self, key: &str) -> Result<Option<Arc<String>>, CacheError> {
198        Ok(self.get_sync(key))
199    }
200
201    async fn put(&self, key: &str, value: &str, ttl_secs: Option<u64>) -> Result<(), CacheError> {
202        self.put_sync(key, value, ttl_secs);
203        Ok(())
204    }
205
206    async fn forget(&self, key: &str) -> Result<(), CacheError> {
207        self.store.remove(key);
208        Ok(())
209    }
210
211    async fn flush(&self) -> Result<(), CacheError> {
212        self.store.clear();
213        Ok(())
214    }
215
216    async fn has(&self, key: &str) -> Result<bool, CacheError> {
217        Ok(self.get(key).await?.is_some())
218    }
219
220    async fn inspect(&self, limit: usize) -> Result<CacheInspection, CacheError> {
221        inspection::validate_inspection_limit(limit)?;
222        self.cleanup_if_due();
223        let now = Instant::now();
224        let mut entries: Vec<_> = self
225            .store
226            .iter()
227            .filter(|entry| entry.expires_at.is_none_or(|expires_at| expires_at > now))
228            .map(|entry| {
229                CacheEntryMetadata::new(
230                    entry.key().clone(),
231                    entry.value.len(),
232                    entry
233                        .expires_at
234                        .map(|expires_at| expires_at.saturating_duration_since(now).as_millis())
235                        .and_then(|ttl| u64::try_from(ttl).ok()),
236                )
237            })
238            .collect();
239        entries.sort_by(|left, right| left.logical_key.cmp(&right.logical_key));
240        let truncated = entries.len() > limit;
241        entries.truncate(limit);
242        Ok(CacheInspection::new(entries, truncated))
243    }
244}
245
246// ─── Global Memoize Cache ───────────────────────────────────────────────────
247
248/// Global memory cache functions used by the `#[memoize]` macro.
249///
250/// The process-wide store is bounded: at most 4,096 entries and 32 MiB of key
251/// plus value bytes, each entry at most 256 KiB and one hour old. Oversized
252/// entries are not cached, and the oldest entries are evicted when a bound
253/// would be exceeded.
254pub mod memory;
255
256// ─── Redis Driver (behind feature flag) ─────────────────────────────────────
257
258#[cfg(feature = "cache-redis")]
259#[path = "cache/redis_driver.rs"]
260pub mod redis_driver;
261
262// ─── Cache Facade ───────────────────────────────────────────────────────────
263
264/// The main cache facade for storing and retrieving cached values.
265///
266/// Provides a driver-agnostic API. Create with `Cache::memory()` or `Cache::redis()`.
267///
268/// # Thread Safety
269/// The `Cache` is `Send + Sync` and can be safely shared across async tasks
270/// and Axum handlers via `Arc` or Axum's `State`.
271#[derive(Clone)]
272pub struct Cache {
273    driver: Arc<Box<dyn CacheDriver>>,
274}
275
276impl Cache {
277    /// Create a cache backed by an in-memory `DashMap`. Zero configuration.
278    ///
279    /// Data is lost on process restart. Perfect for development and single-instance apps.
280    pub fn memory() -> Self {
281        Self {
282            driver: Arc::new(Box::new(MemoryDriver::new())),
283        }
284    }
285
286    /// Create a cache backed by Redis. Requires the `cache-redis` feature.
287    ///
288    /// Data persists across restarts and is shared between instances.
289    #[cfg(feature = "cache-redis")]
290    #[cfg_attr(mutants, mutants::skip)]
291    pub fn redis(redis_url: impl Into<String>) -> Result<Self, CacheError> {
292        let driver = redis_driver::RedisDriver::new(redis_url)?;
293        Ok(Self {
294            driver: Arc::new(Box::new(driver)),
295        })
296    }
297
298    /// Create a cache from any custom driver implementing `CacheDriver`.
299    pub fn custom(driver: Box<dyn CacheDriver>) -> Self {
300        Self {
301            driver: Arc::new(driver),
302        }
303    }
304
305    /// Retrieve a value by key.
306    pub async fn get(&self, key: &str) -> Result<Option<Arc<String>>, CacheError> {
307        self.driver.get(key).await
308    }
309
310    /// Store a value with an optional TTL in seconds.
311    ///
312    /// Pass `None` for TTL to store indefinitely. The in-memory driver also
313    /// stores a TTL too large for the monotonic clock (such as `u64::MAX`)
314    /// without expiry.
315    pub async fn put(
316        &self,
317        key: &str,
318        value: &str,
319        ttl_secs: Option<u64>,
320    ) -> Result<(), CacheError> {
321        self.driver.put(key, value, ttl_secs).await
322    }
323
324    /// Remove a key from the cache.
325    pub async fn forget(&self, key: &str) -> Result<(), CacheError> {
326        self.driver.forget(key).await
327    }
328
329    /// Remove all keys from the cache.
330    pub async fn flush(&self) -> Result<(), CacheError> {
331        self.driver.flush().await
332    }
333
334    /// Check if a key exists and has not expired.
335    pub async fn has(&self, key: &str) -> Result<bool, CacheError> {
336        self.driver.has(key).await
337    }
338
339    /// Returns a bounded metadata-only snapshot when supported by the driver.
340    pub async fn inspect(&self, limit: usize) -> Result<CacheInspection, CacheError> {
341        self.driver.inspect(limit).await
342    }
343
344    /// Retrieve a cached value, or compute it with the provided closure and cache the result.
345    ///
346    /// This is the **cache-aside** (or "remember") pattern — the most common caching strategy.
347    ///
348    /// # Example
349    /// ```rust,no_run
350    /// use std::sync::Arc;
351    /// use rullst_core::cache::{Cache, CacheError};
352    ///
353    /// async fn load_bio(cache: &Cache) -> Result<Arc<String>, CacheError> {
354    ///     cache.remember("user:42:bio", 300, || async {
355    ///         Ok("Example biography".to_string())
356    ///     }).await
357    /// }
358    /// ```
359    pub async fn remember<F, Fut>(
360        &self,
361        key: &str,
362        ttl_secs: u64,
363        f: F,
364    ) -> Result<Arc<String>, CacheError>
365    where
366        F: FnOnce() -> Fut,
367        Fut: std::future::Future<Output = Result<String, CacheError>>,
368    {
369        // Try the cache first
370        if let Some(cached) = self.get(key).await? {
371            return Ok(cached);
372        }
373        // Cache miss — compute the value
374        let value = f().await?;
375        // Store in cache
376        self.put(key, &value, Some(ttl_secs)).await?;
377        Ok(Arc::new(value))
378    }
379}
380
381#[cfg(test)]
382mod tests;
383
384#[cfg(test)]
385#[path = "cache_contract_tests.rs"]
386mod contract_tests;