Skip to main content

siderite_cache/
cache.rs

1//! [`Cache`] and the [`CacheExt`] JSON helpers.
2
3use crate::CacheError;
4use crate::admission::{Admission, AdmitMarker, admit_generic};
5use async_trait::async_trait;
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8use std::future::Future;
9use std::sync::Arc;
10use std::time::Duration;
11
12/// Byte-oriented cache with optional per-entry time-to-live.
13///
14/// Keys are UTF-8 strings. Values are opaque bytes; JSON helpers live on
15/// [`CacheExt`]. [`increment`](Cache::increment) follows Redis `INCRBY`: a
16/// missing key is treated as `0`, and a stored value that is not a decimal
17/// integer is an error.
18///
19/// # Contract for backends and external adapters
20///
21/// Only `get`, `set`, `delete`, `increment` and `clear` are required. The
22/// atomic creation methods ([`set_if_absent`](Cache::set_if_absent) and
23/// [`set_if_absent_with_ttl`](Cache::set_if_absent_with_ttl)) are provided
24/// methods, so adding them never breaks an existing implementation. The
25/// legacy `set_if_absent` default returns [`CacheError::Backend`] ("atomic
26/// insertion is unsupported"); the default of `set_if_absent_with_ttl`
27/// returns [`CacheError::Unsupported`]. Callers that need atomic
28/// creation (RouteCache generation markers) treat that error as "bypass":
29/// they serve the origin and store nothing. They never emulate creation
30/// with `get` followed by `set`, which would let two creators both win and
31/// let a delayed writer overwrite a newer value.
32///
33/// An atomic creation method must make the liveness check, the insertion and
34/// the expiry deadline one indivisible step: exactly one of any set of
35/// concurrent creators of a missing or expired key observes `Ok(true)`, and a
36/// cancelled call either created the entry with its full TTL or created
37/// nothing. See `docs/CACHE.md` for the generation-expiry contract built on
38/// this primitive.
39#[async_trait]
40pub trait Cache: Send + Sync + 'static {
41    /// Fetch `key`. `Ok(None)` when the key is missing or has expired.
42    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
43
44    /// Store `value` at `key`. `ttl` of `None` means the entry does not expire.
45    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>)
46    -> Result<(), CacheError>;
47
48    /// Atomically create a nonexpiring entry only when no live entry exists.
49    ///
50    /// Args:
51    ///     key: Entry identity.
52    ///     value: Opaque value to store on successful creation.
53    ///
54    /// Returns:
55    ///     Whether this call created the entry. Backends without atomic
56    ///     insertion report a capability error; RouteCache then bypasses.
57    async fn set_if_absent(&self, _key: &str, _value: Vec<u8>) -> Result<bool, CacheError> {
58        Err(CacheError::Backend(
59            "atomic insertion is unsupported".into(),
60        ))
61    }
62
63    /// Atomically create an expiring entry only when no live entry exists.
64    ///
65    /// The liveness check, insertion and expiry deadline are one atomic
66    /// step. An existing entry that has already expired counts as absent and
67    /// is replaced. A losing creator leaves the winner's value and deadline
68    /// untouched.
69    ///
70    /// TTL granularity is one millisecond. A `ttl` that is not a whole
71    /// number of milliseconds is rounded up, so a sub-millisecond TTL lives
72    /// for one millisecond and an entry never expires earlier than
73    /// requested. A zero `ttl` is rejected rather than treated as "already
74    /// expired", because an entry that cannot be observed is never useful as
75    /// a marker and several stores treat zero as "no expiry".
76    ///
77    /// Args:
78    ///     key: Entry identity.
79    ///     value: Opaque value to store on successful creation.
80    ///     ttl: Positive lifetime, rounded up to whole milliseconds.
81    ///
82    /// Returns:
83    ///     Whether this call created the entry.
84    ///
85    /// # Errors
86    /// [`CacheError::InvalidTtl`] for a zero `ttl` or one above the portable
87    /// limit (`i64::MAX / 2` milliseconds), never clamped,
88    /// checked before the key is examined. [`CacheError::SizeLimit`] when
89    /// the entry is refused by byte admission. [`CacheError::Unsupported`]
90    /// from the default implementation: backends that cannot create and
91    /// expire atomically must keep it, not emulate it.
92    async fn set_if_absent_with_ttl(
93        &self,
94        _key: &str,
95        _value: Vec<u8>,
96        _ttl: Duration,
97    ) -> Result<bool, CacheError> {
98        Err(CacheError::Unsupported {
99            operation: "set_if_absent_with_ttl",
100        })
101    }
102
103    /// Remove `key`. `Ok(true)` when a live entry was removed.
104    async fn delete(&self, key: &str) -> Result<bool, CacheError>;
105
106    /// Add `by` to the integer stored at `key` (Redis `INCRBY`).
107    ///
108    /// A missing key starts at `0`. `by` may be negative. The stored form is
109    /// the decimal representation of the new value, with no expiry when the
110    /// key is created by this call; an existing key keeps its expiry.
111    async fn increment(&self, key: &str, by: i64) -> Result<i64, CacheError>;
112
113    /// Remove every entry this cache is responsible for.
114    async fn clear(&self) -> Result<(), CacheError>;
115
116    /// Atomically add `by` to a counter and give it an expiry when it has
117    /// none.
118    ///
119    /// A missing or expired key starts at `0`. The increment and the
120    /// expiry are one indivisible step: a key that exists without a
121    /// deadline (including one created by this call) receives `ttl`, while
122    /// a key that already has a deadline keeps it. Separate `increment` and
123    /// expiry calls are not equivalent: if the key expires or is evicted
124    /// between them, `INCRBY` recreates it without a TTL, a permanent
125    /// counter. `ttl` follows the rules of
126    /// [`set_if_absent_with_ttl`](Cache::set_if_absent_with_ttl): rounded
127    /// up to whole milliseconds, zero and values above `i64::MAX / 2`
128    /// milliseconds rejected.
129    ///
130    /// Returns:
131    ///     The counter value after the increment.
132    ///
133    /// # Errors
134    /// [`CacheError::InvalidTtl`], [`CacheError::NotInteger`], and
135    /// [`CacheError::Unsupported`] from the default implementation:
136    /// backends that cannot do this atomically must keep it. RouteCache
137    /// uses it for its admission budget and, when unsupported, bypasses.
138    async fn increment_with_expiry(
139        &self,
140        _key: &str,
141        _by: i64,
142        _ttl: Duration,
143    ) -> Result<i64, CacheError> {
144        Err(CacheError::Unsupported {
145            operation: "increment_with_expiry",
146        })
147    }
148
149    /// Admit a route-cache generation marker (provided; overriding is an
150    /// optimisation, never a requirement).
151    ///
152    /// The default runs the generic sequence from the primitives above:
153    /// create the marker with [`set_if_absent_with_ttl`], count it with
154    /// [`increment_with_expiry`] only when this call created it, delete it
155    /// again when over budget, then compare the epoch. An adapter lacking
156    /// either primitive therefore fails with [`CacheError::Unsupported`]
157    /// before any budget is spent and without leaving a marker behind.
158    /// Backends that can do the whole thing atomically (the bundled ones)
159    /// override it: one lock for [`MemoryCache`](crate::MemoryCache), one
160    /// server-side script for Redis, whose window follows the server
161    /// clock. See `docs/CACHE.md`.
162    ///
163    /// # Errors
164    /// Backend failures, and [`CacheError::Unsupported`] from the defaults
165    /// of the primitives.
166    ///
167    /// [`set_if_absent_with_ttl`]: Cache::set_if_absent_with_ttl
168    /// [`increment_with_expiry`]: Cache::increment_with_expiry
169    async fn admit_marker(&self, req: &AdmitMarker<'_>) -> Result<Admission, CacheError> {
170        admit_generic(self, req).await
171    }
172
173    /// Replace the invalidation epoch and delete a marker (provided).
174    ///
175    /// The default does `set` with `ttl`, then `delete`. Backends override
176    /// it to make the pair one atomic backend call (the bundled ones do).
177    ///
178    /// # Errors
179    /// Backend failures.
180    async fn invalidate_marker(
181        &self,
182        marker: &str,
183        epoch_key: &str,
184        epoch: &[u8],
185        ttl: Duration,
186    ) -> Result<(), CacheError> {
187        self.set(epoch_key, epoch.to_vec(), Some(ttl)).await?;
188        self.delete(marker).await.map(drop)
189    }
190}
191
192#[async_trait]
193impl<T: Cache + ?Sized> Cache for Arc<T> {
194    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
195        (**self).get(key).await
196    }
197
198    async fn set(
199        &self,
200        key: &str,
201        value: Vec<u8>,
202        ttl: Option<Duration>,
203    ) -> Result<(), CacheError> {
204        (**self).set(key, value, ttl).await
205    }
206
207    async fn set_if_absent(&self, key: &str, value: Vec<u8>) -> Result<bool, CacheError> {
208        (**self).set_if_absent(key, value).await
209    }
210
211    async fn set_if_absent_with_ttl(
212        &self,
213        key: &str,
214        value: Vec<u8>,
215        ttl: Duration,
216    ) -> Result<bool, CacheError> {
217        (**self).set_if_absent_with_ttl(key, value, ttl).await
218    }
219
220    async fn delete(&self, key: &str) -> Result<bool, CacheError> {
221        (**self).delete(key).await
222    }
223
224    async fn increment(&self, key: &str, by: i64) -> Result<i64, CacheError> {
225        (**self).increment(key, by).await
226    }
227
228    async fn clear(&self) -> Result<(), CacheError> {
229        (**self).clear().await
230    }
231
232    async fn increment_with_expiry(
233        &self,
234        key: &str,
235        by: i64,
236        ttl: Duration,
237    ) -> Result<i64, CacheError> {
238        (**self).increment_with_expiry(key, by, ttl).await
239    }
240
241    async fn admit_marker(&self, req: &AdmitMarker<'_>) -> Result<Admission, CacheError> {
242        (**self).admit_marker(req).await
243    }
244
245    async fn invalidate_marker(
246        &self,
247        marker: &str,
248        epoch_key: &str,
249        epoch: &[u8],
250        ttl: Duration,
251    ) -> Result<(), CacheError> {
252        (**self)
253            .invalidate_marker(marker, epoch_key, epoch, ttl)
254            .await
255    }
256}
257
258/// Largest TTL, in milliseconds, accepted by `set_if_absent_with_ttl`.
259///
260/// Half of `i64::MAX`: Redis rejects `PX` values whose deadline (now plus
261/// the TTL) overflows a signed 64-bit millisecond clock, so the portable
262/// limit leaves room for any realistic clock value. Larger TTLs are
263/// rejected, never clamped.
264pub(crate) const MAX_TTL_MILLIS: u64 = (i64::MAX / 2) as u64;
265
266/// Round `ttl` up to whole milliseconds, the portable TTL granularity.
267///
268/// # Errors
269/// [`CacheError::InvalidTtl`] for zero or when the millisecond count
270/// exceeds [`MAX_TTL_MILLIS`].
271pub(crate) fn ttl_millis(ttl: Duration) -> Result<u64, CacheError> {
272    if ttl.is_zero() {
273        return Err(CacheError::InvalidTtl);
274    }
275    let whole = u64::try_from(ttl.as_millis()).map_err(|_| CacheError::InvalidTtl)?;
276    let ms = if ttl.subsec_nanos().is_multiple_of(1_000_000) {
277        whole
278    } else {
279        whole.checked_add(1).ok_or(CacheError::InvalidTtl)?
280    };
281    if ms > MAX_TTL_MILLIS {
282        return Err(CacheError::InvalidTtl);
283    }
284    Ok(ms)
285}
286
287/// JSON helpers implemented for every [`Cache`].
288pub trait CacheExt: Cache {
289    /// Fetch `key` and deserialize it as JSON of type `T`.
290    ///
291    /// # Errors
292    /// [`CacheError::Deserialize`] when the stored bytes are not JSON of type
293    /// `T`. Backend failures from [`Cache::get`].
294    fn get_json<T>(&self, key: &str) -> impl Future<Output = Result<Option<T>, CacheError>> + Send
295    where
296        T: DeserializeOwned,
297    {
298        async move {
299            match self.get(key).await? {
300                None => Ok(None),
301                Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|err| {
302                    CacheError::Deserialize {
303                        key: key.to_owned(),
304                        reason: err.to_string(),
305                    }
306                }),
307            }
308        }
309    }
310
311    /// Serialize `value` as compact JSON and store it at `key`.
312    ///
313    /// # Errors
314    /// [`CacheError::Serialize`] when `value` cannot be encoded. Backend
315    /// failures from [`Cache::set`].
316    fn set_json<T>(
317        &self,
318        key: &str,
319        value: &T,
320        ttl: Option<Duration>,
321    ) -> impl Future<Output = Result<(), CacheError>> + Send
322    where
323        T: Serialize,
324    {
325        let encoded = serde_json::to_vec(value).map_err(|err| CacheError::Serialize {
326            key: key.to_owned(),
327            reason: err.to_string(),
328        });
329        async move { self.set(key, encoded?, ttl).await }
330    }
331
332    /// Return the JSON value at `key`, computing and storing it on a miss.
333    ///
334    /// A stored value that cannot be deserialized is treated as a miss so a
335    /// corrupt entry can be replaced. Concurrent misses may both run `f`.
336    ///
337    /// # Errors
338    /// Errors from `f`, JSON encoding of the computed value, or the backend.
339    fn get_or_set<T, F, Fut>(
340        &self,
341        key: &str,
342        ttl: Option<Duration>,
343        f: F,
344    ) -> impl Future<Output = Result<T, CacheError>> + Send
345    where
346        T: DeserializeOwned + Serialize + Send,
347        F: FnOnce() -> Fut + Send,
348        Fut: Future<Output = Result<T, CacheError>> + Send,
349    {
350        async move {
351            if let Some(bytes) = self.get(key).await?
352                && let Ok(value) = serde_json::from_slice::<T>(&bytes)
353            {
354                return Ok(value);
355            }
356            let value = f().await?;
357            self.set_json(key, &value, ttl).await?;
358            Ok(value)
359        }
360    }
361}
362
363impl<C: Cache> CacheExt for C {}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::MemoryCache;
369    use serde::{Deserialize, Serialize};
370
371    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372    struct Widget {
373        id: u32,
374        name: String,
375    }
376
377    fn widget() -> Widget {
378        Widget {
379            id: 7,
380            name: "ada".into(),
381        }
382    }
383
384    #[tokio::test]
385    async fn json_roundtrip_and_missing() {
386        let cache = MemoryCache::new(8);
387        assert_eq!(cache.get_json::<Widget>("w").await.unwrap(), None);
388        cache.set_json("w", &widget(), None).await.unwrap();
389        assert_eq!(cache.get_json::<Widget>("w").await.unwrap(), Some(widget()));
390    }
391
392    #[tokio::test]
393    async fn get_json_rejects_invalid_payload() {
394        let cache = MemoryCache::new(8);
395        cache.set("w", b"not-json".to_vec(), None).await.unwrap();
396        let err = cache.get_json::<Widget>("w").await.unwrap_err();
397        assert!(matches!(err, CacheError::Deserialize { ref key, .. } if key == "w"));
398    }
399
400    #[tokio::test]
401    async fn get_or_set_computes_once() {
402        let cache = MemoryCache::new(8);
403        let calls = std::sync::atomic::AtomicU32::new(0);
404        let first = cache
405            .get_or_set("w", None, || {
406                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
407                async { Ok(widget()) }
408            })
409            .await
410            .unwrap();
411        let second = cache
412            .get_or_set("w", None, || {
413                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
414                async {
415                    Ok(Widget {
416                        id: 0,
417                        name: "x".into(),
418                    })
419                }
420            })
421            .await
422            .unwrap();
423        assert_eq!(first, widget());
424        assert_eq!(second, widget());
425        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
426    }
427
428    #[tokio::test]
429    async fn get_or_set_does_not_store_factory_errors() {
430        let cache = MemoryCache::new(8);
431        let err = cache
432            .get_or_set::<Widget, _, _>("w", None, || async {
433                Err(CacheError::Backend("boom".into()))
434            })
435            .await
436            .unwrap_err();
437        assert!(matches!(err, CacheError::Backend(ref m) if m == "boom"));
438        assert_eq!(cache.get("w").await.unwrap(), None);
439    }
440
441    #[tokio::test]
442    async fn get_or_set_replaces_corrupt_entry() {
443        let cache = MemoryCache::new(8);
444        cache.set("w", b"nope".to_vec(), None).await.unwrap();
445        let value = cache
446            .get_or_set("w", None, || async { Ok(widget()) })
447            .await
448            .unwrap();
449        assert_eq!(value, widget());
450        assert_eq!(cache.get_json::<Widget>("w").await.unwrap(), Some(widget()));
451    }
452
453    /// An adapter written before the expiring creation method existed.
454    struct LegacyAdapter(MemoryCache);
455
456    #[async_trait]
457    impl Cache for LegacyAdapter {
458        async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
459            self.0.get(key).await
460        }
461        async fn set(
462            &self,
463            key: &str,
464            value: Vec<u8>,
465            ttl: Option<Duration>,
466        ) -> Result<(), CacheError> {
467            self.0.set(key, value, ttl).await
468        }
469        async fn delete(&self, key: &str) -> Result<bool, CacheError> {
470            self.0.delete(key).await
471        }
472        async fn increment(&self, key: &str, by: i64) -> Result<i64, CacheError> {
473            self.0.increment(key, by).await
474        }
475        async fn clear(&self) -> Result<(), CacheError> {
476            self.0.clear().await
477        }
478    }
479
480    #[tokio::test]
481    async fn legacy_adapters_compile_and_report_unsupported() {
482        let ttl = Duration::from_secs(1);
483        let adapter = LegacyAdapter(MemoryCache::new(4));
484        assert!(matches!(
485            adapter.set_if_absent_with_ttl("k", vec![1], ttl).await,
486            Err(CacheError::Unsupported { operation })
487                if operation == "set_if_absent_with_ttl"
488        ));
489        assert!(adapter.set_if_absent("k", vec![1]).await.is_err());
490        let shared: Arc<dyn Cache> = Arc::new(adapter);
491        assert!(matches!(
492            shared.set_if_absent_with_ttl("k", vec![1], ttl).await,
493            Err(CacheError::Unsupported { .. })
494        ));
495        // Nothing was stored by the failed call.
496        assert_eq!(shared.get("k").await.unwrap(), None);
497    }
498
499    #[tokio::test]
500    async fn arc_forwards_expiring_creation() {
501        let cache: Arc<dyn Cache> = Arc::new(MemoryCache::new(4));
502        let ttl = Duration::from_secs(5);
503        assert!(
504            cache
505                .set_if_absent_with_ttl("k", vec![1], ttl)
506                .await
507                .unwrap()
508        );
509        assert!(
510            !cache
511                .set_if_absent_with_ttl("k", vec![2], ttl)
512                .await
513                .unwrap()
514        );
515        assert_eq!(cache.get("k").await.unwrap(), Some(vec![1]));
516    }
517
518    #[test]
519    fn ttl_millis_rounds_up_and_rejects_zero() {
520        assert!(matches!(
521            ttl_millis(Duration::ZERO),
522            Err(CacheError::InvalidTtl)
523        ));
524        assert_eq!(ttl_millis(Duration::from_nanos(1)).unwrap(), 1);
525        assert_eq!(ttl_millis(Duration::from_micros(999)).unwrap(), 1);
526        assert_eq!(ttl_millis(Duration::from_millis(1)).unwrap(), 1);
527        assert_eq!(ttl_millis(Duration::from_micros(1001)).unwrap(), 2);
528        assert_eq!(ttl_millis(Duration::new(2, 1)).unwrap(), 2001);
529        assert!(ttl_millis(Duration::MAX).is_err());
530        let max = Duration::from_millis(MAX_TTL_MILLIS);
531        assert_eq!(ttl_millis(max).unwrap(), MAX_TTL_MILLIS);
532        assert!(ttl_millis(max + Duration::from_millis(1)).is_err());
533        assert!(ttl_millis(max + Duration::from_nanos(1)).is_err());
534    }
535
536    #[tokio::test]
537    async fn arc_dyn_cache_is_usable() {
538        let cache: Arc<dyn Cache> = Arc::new(MemoryCache::new(4));
539        cache.set("k", b"v".to_vec(), None).await.unwrap();
540        assert_eq!(cache.get("k").await.unwrap().as_deref(), Some(&b"v"[..]));
541        assert_eq!(cache.get_json::<u32>("missing").await.unwrap(), None);
542    }
543}