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}