siderite_cache/admission.rs
1//! Marker admission for route caches: one provided [`Cache`] method.
2//!
3//! [`Cache::admit_marker`] is the only operation a route cache needs on its
4//! store path beyond `get`/`set`. Backends that can do it in one atomic step
5//! (the bundled ones do) override it; every other [`Cache`] runs the generic
6//! sequence here from the primitives it already has, so external adapters
7//! keep compiling and keep working.
8
9use crate::{Cache, CacheError};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12/// What [`Cache::admit_marker`] must decide.
13///
14/// Built by the route cache; adapters only forward it.
15#[derive(Debug, Clone, Copy)]
16#[non_exhaustive]
17pub struct AdmitMarker<'a> {
18 /// Key of the target's generation marker.
19 pub marker: &'a str,
20 /// Value to store if this call creates the marker.
21 pub token: &'a [u8],
22 /// Key of the invalidation epoch the caller observed.
23 pub epoch_key: &'a str,
24 /// The epoch value the caller observed before its origin read.
25 pub epoch: &'a [u8],
26 /// Prefix of the budget counter key; the window index is appended.
27 pub budget_prefix: &'a str,
28 /// New markers admitted per window.
29 pub max_new: u64,
30 /// Window length, also the marker lifetime.
31 pub window: Duration,
32}
33
34impl<'a> AdmitMarker<'a> {
35 /// A request with no effective budget (unlimited, one-hour window).
36 #[must_use]
37 pub fn new(marker: &'a str, token: &'a [u8], epoch_key: &'a str, epoch: &'a [u8]) -> Self {
38 Self {
39 marker,
40 token,
41 epoch_key,
42 epoch,
43 budget_prefix: "",
44 max_new: u64::MAX,
45 window: Duration::from_secs(3600),
46 }
47 }
48
49 /// Count creations in `prefix` + window index, at most `max_new` per
50 /// `window`; `window` is also the marker lifetime.
51 #[must_use]
52 pub fn budget(mut self, prefix: &'a str, max_new: u64, window: Duration) -> Self {
53 self.budget_prefix = prefix;
54 self.max_new = max_new;
55 self.window = window;
56 self
57 }
58}
59
60/// Outcome of [`Cache::admit_marker`].
61#[derive(Debug, Clone, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum Admission {
64 /// The marker's value: the caller's token when it created the marker,
65 /// otherwise the token already stored.
66 Marker(Vec<u8>),
67 /// The epoch differs from the one the caller observed, or no longer
68 /// exists: a write may have intervened, so the fill must not be stored.
69 Stale,
70 /// The window's budget is spent. The window ends after `resets_in`.
71 Exhausted {
72 /// Time left in the current window.
73 resets_in: Duration,
74 },
75}
76
77/// Generic admission from existing primitives.
78///
79/// Order: create the marker, count it only if this call created it, delete
80/// it again when over budget, then compare the epoch (a marker this call
81/// created is deleted again when the epoch differs). A write that lands
82/// after the marker exists deletes it; one that landed earlier changed the
83/// epoch. The window comes from this process's clock, so clocks that
84/// disagree by more than a fraction of the window let processes count
85/// against different counters; the bundled backends avoid that by reading
86/// their own clock. Between creating and deleting an over-budget marker the
87/// marker is briefly visible, which only a non-atomic adapter can show.
88pub(crate) async fn admit_generic<C>(
89 cache: &C,
90 req: &AdmitMarker<'_>,
91) -> Result<Admission, CacheError>
92where
93 C: Cache + ?Sized,
94{
95 let created = cache
96 .set_if_absent_with_ttl(req.marker, req.token.to_vec(), req.window)
97 .await?;
98 let token = if created {
99 let (used, resets_in) = count_creation(cache, req).await?;
100 if used > req.max_new {
101 cache.delete(req.marker).await?;
102 return Ok(Admission::Exhausted { resets_in });
103 }
104 req.token.to_vec()
105 } else {
106 let live = cache.get(req.marker).await?;
107 live.ok_or_else(|| CacheError::Backend("generation was evicted".into()))?
108 };
109 match cache.get(req.epoch_key).await? {
110 Some(current) if current == req.epoch => Ok(Admission::Marker(token)),
111 _ => {
112 if created {
113 // The fill will not be stored, so the marker this call
114 // made would only occupy a slot. Deleting it after the
115 // epoch read keeps the safety order: a write between the
116 // two reads deletes the marker itself.
117 let _ = cache.delete(req.marker).await;
118 }
119 Ok(Admission::Stale)
120 }
121 }
122}
123
124/// Increment the window counter for a marker this call created. Returns the
125/// new count and the time left in the window.
126async fn count_creation<C>(cache: &C, req: &AdmitMarker<'_>) -> Result<(u64, Duration), CacheError>
127where
128 C: Cache + ?Sized,
129{
130 let (index, resets_in) = clock_window(req.window);
131 let key = format!("{}{index}", req.budget_prefix);
132 let ttl = req.window.checked_mul(2).ok_or(CacheError::InvalidTtl);
133 let used = match ttl {
134 Ok(ttl) => cache.increment_with_expiry(&key, 1, ttl).await,
135 Err(error) => Err(error),
136 };
137 match used {
138 Ok(used) => Ok((u64::try_from(used).unwrap_or(u64::MAX), resets_in)),
139 Err(error) => {
140 // Nothing was spent: undo the creation before failing so an
141 // adapter missing the increment leaves no uncounted marker.
142 let _ = cache.delete(req.marker).await;
143 Err(error)
144 }
145 }
146}
147
148/// Window index and time left, from this process's wall clock.
149fn clock_window(window: Duration) -> (u64, Duration) {
150 let now = SystemTime::now().duration_since(UNIX_EPOCH);
151 let now = now.unwrap_or_default().as_millis();
152 let ms = window.as_millis().max(1);
153 let index = u64::try_from(now / ms).unwrap_or(u64::MAX);
154 let left = u64::try_from(ms - now % ms).unwrap_or(u64::MAX);
155 (index, Duration::from_millis(left))
156}