Skip to main content

autumn_web/cache/
mod.rs

1//! Caching infrastructure for the Autumn framework.
2//!
3//! This module provides:
4//!
5//! - [`Cache`] — a trait abstracting over cache backends (moka by default,
6//!   swap in Redis, memcached, etc.)
7//! - [`MokaCache`] — the default, lock-free, in-process cache powered by
8//!   [moka](https://docs.rs/moka) (behind the `cache-moka` feature)
9//! - [`CacheResponseLayer`] — a Tower middleware that caches HTTP GET
10//!   responses, usable via `#[intercept(CacheResponseLayer::new(...))]`
11//! - [`CacheableResult`] — helper trait used by `#[cached(result)]` to
12//!   only cache `Ok` values
13//!
14//! The `#[cached]` proc macro generates a per-function static `MokaCache`
15//! for function-level memoization. The `CacheResponseLayer` operates at
16//! the HTTP level using a shared `Arc<dyn Cache>`.
17//!
18//! # Swapping backends
19//!
20//! Implement the [`Cache`] trait for your backend:
21//!
22//! ```rust,ignore
23//! use autumn_web::cache::Cache;
24//!
25//! #[derive(Clone)]
26//! struct RedisCache { /* ... */ }
27//!
28//! impl Cache for RedisCache {
29//!     fn get_value(&self, key: &str) -> Option<Box<dyn std::any::Any + Send + Sync>> { /* ... */ }
30//!     fn insert_value(&self, key: &str, value: Box<dyn std::any::Any + Send + Sync>) { /* ... */ }
31//!     fn invalidate(&self, key: &str) { /* ... */ }
32//!     fn clear(&self) { /* ... */ }
33//! }
34//! ```
35
36#[cfg(feature = "maud")]
37mod fragment;
38mod layer;
39#[cfg(feature = "cache-moka")]
40mod moka_impl;
41mod read_through;
42
43#[cfg(feature = "maud")]
44pub use fragment::{cache_fragment, cache_fragment_global};
45pub use layer::{CacheResponseLayer, CacheResponseService};
46#[cfg(feature = "cache-moka")]
47pub use moka_impl::MokaCache;
48pub use read_through::{
49    CacheFillError, GetOrComputeOptions, ReadThroughMetrics, ReadThroughMetricsSnapshot,
50    get_or_compute, get_or_compute_with, jittered_ttl, read_through_metrics,
51};
52
53use std::any::Any;
54use std::hash::{DefaultHasher, Hash, Hasher};
55use std::sync::{Arc, RwLock};
56use std::time::Duration;
57
58// ── Global cache registry ────────────────────────────────────────────
59
60/// Process-level shared cache backend.
61///
62/// Set once at startup by [`set_global_cache`]; read by every
63/// `#[cached]`-annotated function to decide which store to use.
64static GLOBAL_CACHE: RwLock<Option<Arc<dyn Cache>>> = RwLock::new(None);
65
66/// Register (or replace) the process-level shared cache.
67///
68/// Called automatically by [`crate::app::AppBuilder`] when
69/// `.with_cache_backend(...)` has been used. Also called by
70/// [`crate::state::AppState::set_cache`] when a plugin installs a backend
71/// during the startup-hook phase.
72///
73/// # Panics
74///
75/// Panics if the internal `RwLock` is poisoned.
76pub fn set_global_cache(cache: Arc<dyn Cache>) {
77    *GLOBAL_CACHE.write().expect("global cache lock poisoned") = Some(cache);
78}
79
80/// Return a clone of the process-level shared cache, if one is registered.
81///
82/// `None` means no global backend has been set and `#[cached]` functions
83/// fall back to their per-function Moka stores.
84///
85/// # Panics
86///
87/// Panics if the internal `RwLock` is poisoned.
88#[must_use]
89pub fn global_cache() -> Option<Arc<dyn Cache>> {
90    GLOBAL_CACHE
91        .read()
92        .expect("global cache lock poisoned")
93        .clone()
94}
95
96/// Remove the process-level shared cache.
97///
98/// Primarily useful in tests that need per-test isolation.
99///
100/// # Panics
101///
102/// Panics if the internal `RwLock` is poisoned.
103pub fn clear_global_cache() {
104    *GLOBAL_CACHE.write().expect("global cache lock poisoned") = None;
105}
106
107// ── Cache trait ──────────────────────────────────────────────────────
108
109/// Raw JSON bytes stored by serializing cache backends (e.g. Redis).
110///
111/// Backends that cannot store `Arc<dyn Any>` directly (because values must
112/// survive across process boundaries) return this from [`Cache::get_value`]
113/// instead. [`get_cached`] and [`insert_cached`] transparently deserialize it
114/// back into the concrete type `V` using `serde_json`.
115#[derive(Clone)]
116pub struct RawCacheBytes(pub Vec<u8>);
117
118/// A type-erased, thread-safe cache store.
119///
120/// Implementations must be `Send + Sync` so they can be shared across
121/// handlers and tasks. Values are stored as `Arc<dyn Any>` for type
122/// erasure, allowing a single cache instance to store heterogeneous
123/// types from different `#[cached]` functions.
124///
125/// Use the free functions [`get`] / [`insert`] for in-process-only values,
126/// or [`get_cached`] / [`insert_cached`] for types that also implement
127/// `serde`, which is required for cross-replica backends like Redis.
128/// [`CacheResponseLayer`] uses the serde-aware path so HTTP response caching
129/// works with both in-process and raw-byte backends.
130pub trait Cache: Send + Sync + 'static {
131    /// Retrieve a type-erased value by key. Returns `None` on miss.
132    ///
133    /// Backends that store serialized data (e.g. Redis) may return
134    /// <code>Arc<[RawCacheBytes]></code> here; [`get_cached`] handles the
135    /// JSON deserialization transparently.
136    fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>>;
137
138    /// Store a type-erased value by key.
139    fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>);
140
141    /// Remove a specific key.
142    fn invalidate(&self, key: &str);
143
144    /// Remove all entries.
145    fn clear(&self);
146
147    /// Store pre-serialized JSON bytes for backends that persist data across
148    /// process boundaries (e.g. Redis). The default is a no-op; in-process
149    /// backends store values via [`insert_value`] instead.
150    ///
151    /// `ttl` carries the same time-to-live that was declared on the
152    /// `#[cached(ttl = "…")]` attribute so backends can apply native expiry
153    /// (e.g. Redis `SET EX`). `None` means no expiry.
154    ///
155    /// [`insert_value`]: Cache::insert_value
156    fn insert_raw_bytes(&self, _key: &str, _bytes: Vec<u8>, _ttl: Option<std::time::Duration>) {}
157
158    /// Try to acquire a cross-replica fill lock for `key`, used by
159    /// [`get_or_compute_with`] to ensure at most one replica refills a hot
160    /// key at a time.
161    ///
162    /// `token` identifies the caller so [`release_fill_lock`] can safely
163    /// release only a lock it still owns. `ttl` bounds how long the lock is
164    /// held if the caller crashes before releasing it.
165    ///
166    /// The default implementation reports [`FillLockStatus::Unsupported`],
167    /// which degrades callers to in-process-only single-flight protection —
168    /// safe for backends (like the in-process Moka cache) that have no
169    /// cross-replica visibility.
170    ///
171    /// [`release_fill_lock`]: Cache::release_fill_lock
172    fn try_acquire_fill_lock(&self, _key: &str, _token: &str, _ttl: Duration) -> FillLockStatus {
173        FillLockStatus::Unsupported
174    }
175
176    /// Release the fill lock previously acquired with `token`, if this
177    /// caller still owns it. The default is a no-op, matching the default
178    /// [`try_acquire_fill_lock`] returning [`FillLockStatus::Unsupported`].
179    ///
180    /// [`try_acquire_fill_lock`]: Cache::try_acquire_fill_lock
181    fn release_fill_lock(&self, _key: &str, _token: &str) {}
182}
183
184/// Outcome of [`Cache::try_acquire_fill_lock`].
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum FillLockStatus {
187    /// The caller now holds the lock and must call
188    /// [`Cache::release_fill_lock`] when the fill completes (success or
189    /// failure).
190    Acquired,
191    /// Another replica currently holds the lock.
192    Held,
193    /// This backend has no cross-replica fill lock; callers fall back to
194    /// in-process-only single-flight protection.
195    Unsupported,
196}
197
198// ── Typed convenience functions ──────────────────────────────────────
199
200/// Typed get: retrieve and downcast a cached value.
201///
202/// Returns `None` if the key is absent or the stored type doesn't
203/// match `V`. Works with any `Cache` implementation.
204///
205/// For cross-replica backends (Redis) use [`get_cached`] instead, which
206/// also handles JSON deserialization of [`RawCacheBytes`].
207pub fn get<V: Clone + Send + Sync + 'static>(cache: &dyn Cache, key: &str) -> Option<V> {
208    cache
209        .get_value(key)
210        .and_then(|arc| arc.downcast_ref::<V>().cloned())
211}
212
213/// Typed insert: wrap the value in an `Arc` and store it.
214///
215/// Works with any `Cache` implementation.
216///
217/// For cross-replica backends (Redis) use [`insert_cached`] instead,
218/// which also serializes the value for storage across process boundaries.
219pub fn insert<V: Clone + Send + Sync + 'static>(cache: &dyn Cache, key: &str, value: V) {
220    cache.insert_value(key, Arc::new(value));
221}
222
223/// Serde-aware get: retrieve a cached value, deserializing from JSON if needed.
224///
225/// First tries a direct in-memory downcast (fast path for `MokaCache`). If
226/// that fails — because the backend stored [`RawCacheBytes`] (e.g. Redis) —
227/// the bytes are deserialized with `serde_json`. This is what the `#[cached]`
228/// macro uses so that values survive across replicas when a shared backend
229/// is configured.
230pub fn get_cached<V>(cache: &dyn Cache, key: &str) -> Option<V>
231where
232    V: Clone + serde::de::DeserializeOwned + Send + Sync + 'static,
233{
234    let arc = cache.get_value(key)?;
235    // Fast path: in-memory backend stored the concrete type directly.
236    if let Some(v) = arc.downcast_ref::<V>() {
237        return Some(v.clone());
238    }
239    // Slow path: serializing backend (e.g. Redis) stored RawCacheBytes.
240    arc.downcast_ref::<RawCacheBytes>()
241        .and_then(|raw| serde_json::from_slice::<V>(&raw.0).ok())
242}
243
244/// Serde-aware insert: store the value both in-memory and as JSON bytes.
245///
246/// Calls [`Cache::insert_value`] (for in-process backends like Moka) **and**
247/// [`Cache::insert_raw_bytes`] (for cross-replica backends like Redis). This
248/// is what the `#[cached]` macro uses so that the stored value is accessible
249/// both within the same process and on other replicas.
250///
251/// `ttl` is forwarded verbatim to [`Cache::insert_raw_bytes`] so backends
252/// like Redis can apply a native entry expiry (e.g. `SET EX`). In-process
253/// backends (Moka) manage TTL via the per-function static cache instance
254/// and ignore this parameter.
255pub fn insert_cached<V>(cache: &dyn Cache, key: &str, value: V, ttl: Option<std::time::Duration>)
256where
257    V: Clone + serde::Serialize + Send + Sync + 'static,
258{
259    // In-memory path (MokaCache, CountingCache in tests, …)
260    cache.insert_value(key, Arc::new(value.clone()));
261    // Serialized path (RedisCache, any cross-replica backend)
262    if let Ok(bytes) = serde_json::to_vec(&value) {
263        cache.insert_raw_bytes(key, bytes, ttl);
264    }
265}
266
267// ── CacheableResult trait ────────────────────────────────────────────
268
269/// Helper trait used by `#[cached(result)]` to extract the `Ok` type
270/// from a `Result<T, E>` return type at the type level.
271///
272/// This avoids the need for the proc macro to syntactically parse
273/// generic arguments out of the return type.
274pub trait CacheableResult {
275    /// The success type to cache.
276    type Ok: Clone;
277    /// The error type (passed through uncached).
278    type Err;
279
280    /// Convert into a standard `Result` for pattern matching.
281    ///
282    /// # Errors
283    ///
284    /// Returns `Err` if the original result was an error.
285    fn into_result(self) -> Result<Self::Ok, Self::Err>;
286    /// Wrap a cached `Ok` value back into the original result type.
287    fn from_ok(ok: Self::Ok) -> Self;
288}
289
290impl<T: Clone, E> CacheableResult for Result<T, E> {
291    type Ok = T;
292    type Err = E;
293
294    fn into_result(self) -> Self {
295        self
296    }
297
298    fn from_ok(ok: T) -> Self {
299        Ok(ok)
300    }
301}
302
303// ── Cache key helper ─────────────────────────────────────────────────
304
305/// Build a cache key from a function name and its hashable arguments.
306///
307/// Used by `#[cached]` macro-generated code. The key is
308/// `"{fn_name}:{hash_hex}"` where the hash is a 64-bit `DefaultHasher`
309/// digest of the argument tuple.
310#[must_use]
311pub fn make_cache_key<K: Hash>(fn_name: &str, args: &K) -> String {
312    let mut hasher = DefaultHasher::new();
313    args.hash(&mut hasher);
314    format!("{}:{:x}", fn_name, hasher.finish())
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn cache_key_deterministic() {
323        let k1 = make_cache_key("get_user", &(42_i64,));
324        let k2 = make_cache_key("get_user", &(42_i64,));
325        assert_eq!(k1, k2);
326    }
327
328    #[test]
329    fn cache_key_differs_by_fn_name() {
330        let k1 = make_cache_key("get_user", &(42_i64,));
331        let k2 = make_cache_key("find_user", &(42_i64,));
332        assert_ne!(k1, k2);
333    }
334
335    #[test]
336    fn cache_key_differs_by_args() {
337        let k1 = make_cache_key("get_user", &(1_i64,));
338        let k2 = make_cache_key("get_user", &(2_i64,));
339        assert_ne!(k1, k2);
340    }
341
342    #[test]
343    fn cache_key_no_args() {
344        let k = make_cache_key("get_config", &());
345        assert!(k.starts_with("get_config:"));
346    }
347
348    #[cfg(feature = "cache-moka")]
349    #[test]
350    fn insert_cached_and_get_cached_round_trip() {
351        let cache = MokaCache::new(10, None);
352        insert_cached(&cache, "key", "hello".to_string(), None);
353        let val: Option<String> = get_cached(&cache, "key");
354        assert_eq!(val.as_deref(), Some("hello"));
355    }
356
357    #[cfg(feature = "cache-moka")]
358    #[test]
359    fn get_cached_raw_bytes_slow_path() {
360        // Simulate a cross-replica backend: store RawCacheBytes directly, then
361        // verify get_cached deserializes it back to the concrete type.
362        let cache = MokaCache::new(10, None);
363        let bytes = serde_json::to_vec(&42_i32).unwrap();
364        cache.insert_value("k", Arc::new(RawCacheBytes(bytes)));
365        let val: Option<i32> = get_cached(&cache, "k");
366        assert_eq!(val, Some(42));
367    }
368
369    #[cfg(feature = "cache-moka")]
370    #[test]
371    fn get_cached_miss_returns_none() {
372        let cache = MokaCache::new(10, None);
373        let val: Option<String> = get_cached(&cache, "missing");
374        assert!(val.is_none());
375    }
376
377    #[test]
378    fn cacheable_result_ok_round_trips() {
379        let r: Result<i32, &str> = Result::from_ok(42);
380        assert_eq!(r, Ok(42));
381        assert_eq!(r.into_result(), Ok(42));
382    }
383
384    #[test]
385    fn cacheable_result_err_passes_through() {
386        let r: Result<i32, &str> = Err("oops");
387        assert_eq!(r.into_result(), Err("oops"));
388    }
389}