Skip to main content

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}