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}