Skip to main content

siderite_backends/redis/
scripts.rs

1//! Server-side scripts for the route cache: counter, admission, rotation.
2//!
3//! Scripts run through [`redis::Script`], which sends `EVALSHA` and falls
4//! back to loading the script when the server does not know it yet, so the
5//! script text crosses the wire once per server rather than per call.
6
7use super::{MAX_TTL_MS, RedisError, RedisStore, required_ttl};
8use redis::{Script, Value};
9use std::sync::LazyLock;
10use std::time::Duration;
11
12/// `INCRBY` plus an expiry for a key that has none.
13static INCR_EXPIRE: LazyLock<Script> = LazyLock::new(|| {
14    Script::new(
15        "local n = redis.call('INCRBY', KEYS[1], ARGV[1]) \
16         if redis.call('PTTL', KEYS[1]) < 0 then \
17         redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return n",
18    )
19});
20
21/// Marker admission.
22///
23/// KEYS: marker, epoch. ARGV: token, expected epoch, marker ttl (ms),
24/// budget, window (ms), counter key prefix, counter ttl (ms).
25/// Replies `{0, 0}` stale epoch, `{1, value}` admitted (existing or new
26/// marker), `{2, ms}` budget spent with `ms` left in the window.
27/// The window index comes from the server clock (`TIME`), the same clock
28/// that expires the counter, so processes with skewed clocks agree.
29static ADMIT: LazyLock<Script> = LazyLock::new(|| {
30    Script::new(
31        "if redis.replicate_commands then redis.replicate_commands() end \
32         if redis.call('GET', KEYS[2]) ~= ARGV[2] then return {0, 0} end \
33         local live = redis.call('GET', KEYS[1]) \
34         if live then return {1, live} end \
35         local t = redis.call('TIME') \
36         local now = t[1] * 1000 + math.floor(t[2] / 1000) \
37         local window = tonumber(ARGV[5]) \
38         local index = math.floor(now / window) \
39         local counter = ARGV[6] .. string.format('%d', index) \
40         local used = tonumber(redis.call('GET', counter) or '0') \
41         if used >= tonumber(ARGV[4]) then \
42         return {2, (index + 1) * window - now} end \
43         redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) \
44         redis.call('INCR', counter) \
45         if redis.call('PTTL', counter) < 0 then \
46         redis.call('PEXPIRE', counter, ARGV[7]) end \
47         return {1, ARGV[1]}",
48    )
49});
50
51/// What [`RedisStore::admit_marker`] decided.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum MarkerReply {
54    /// The marker's stored value: the caller's token when it created the
55    /// marker, otherwise the live marker's.
56    Admitted(String),
57    /// The epoch differs from the expected one, or is missing.
58    Stale,
59    /// The window's budget is spent; the window ends after this long.
60    Exhausted(Duration),
61}
62
63/// Arguments of [`RedisStore::admit_marker`]. Keys are logical (the store
64/// prefix is applied); values are the strings stored as-is.
65#[derive(Debug, Clone, Copy)]
66pub struct MarkerAdmission<'a> {
67    /// Marker key to create when absent.
68    pub marker: &'a str,
69    /// Value stored when this call creates the marker.
70    pub token: &'a str,
71    /// Epoch key read first.
72    pub epoch_key: &'a str,
73    /// Epoch value the caller observed before its origin read.
74    pub epoch: &'a str,
75    /// Counter key prefix; the script appends the server window index.
76    pub budget_prefix: &'a str,
77    /// New markers admitted per window.
78    pub max_new: u64,
79    /// Window length, also the marker lifetime.
80    pub window: Duration,
81}
82
83impl RedisStore {
84    /// `INCRBY` plus an expiry for a key that has none, as one atomic
85    /// server-side script.
86    ///
87    /// A missing key starts at `0`. The key receives `ttl` only when it has
88    /// no expiry afterwards (it was created by this call, or was persistent);
89    /// an existing deadline is kept. The two steps cannot be separated by an
90    /// expiry or eviction, so the key is never left without a TTL.
91    ///
92    /// # Errors
93    /// [`RedisError::Invalid`] when `ttl` is below 1ms or above
94    /// `i64::MAX / 2` milliseconds, before any command is sent. Otherwise
95    /// connection or command errors (the value is not an integer, or it
96    /// would overflow).
97    pub async fn incr_by_expire(
98        &self,
99        key: &str,
100        by: i64,
101        ttl: Duration,
102    ) -> Result<i64, RedisError> {
103        let ms = required_ttl(ttl)?;
104        let mut invocation = INCR_EXPIRE.key(self.key(key));
105        let call = invocation.arg(by).arg(ms);
106        let mut conn = self.manager.clone();
107        call.invoke_async(&mut conn).await.map_err(RedisError::from)
108    }
109
110    /// Admit a marker in one script: check the epoch, find or create the
111    /// marker, and count the creation against the window's budget.
112    ///
113    /// The window is `floor(server TIME / window)`, so every process agrees
114    /// on it whatever its own clock says; the counter expires after twice
115    /// the window on the same server clock. Nothing is created when the
116    /// epoch differs or the budget is spent. The counter key is built inside
117    /// the script, so this requires a single (non-cluster) deployment. The
118    /// script enables effects replication itself where Redis needs it
119    /// (before 7), so `TIME` followed by writes is accepted.
120    ///
121    /// # Errors
122    /// [`RedisError::Invalid`] when `window` is below 1ms or above
123    /// `i64::MAX / 4` milliseconds, before any command is sent. Otherwise
124    /// connection or command errors.
125    pub async fn admit_marker(&self, args: MarkerAdmission<'_>) -> Result<MarkerReply, RedisError> {
126        let window = required_ttl(args.window)?;
127        let counter_ttl = window
128            .checked_mul(2)
129            .filter(|ttl| *ttl <= MAX_TTL_MS)
130            .ok_or_else(|| ttl_error("window"))?;
131        let mut invocation = ADMIT.key(self.key(args.marker));
132        let call = invocation
133            .key(self.key(args.epoch_key))
134            .arg(args.token)
135            .arg(args.epoch)
136            .arg(window)
137            .arg(args.max_new)
138            .arg(window)
139            .arg(self.key(args.budget_prefix))
140            .arg(counter_ttl);
141        let mut conn = self.manager.clone();
142        let (code, value): (i64, Value) = call
143            .invoke_async(&mut conn)
144            .await
145            .map_err(RedisError::from)?;
146        match (code, value) {
147            (0, _) => Ok(MarkerReply::Stale),
148            (1, Value::BulkString(bytes)) => String::from_utf8(bytes)
149                .map(MarkerReply::Admitted)
150                .map_err(|_| RedisError::Command("marker is not UTF-8".into())),
151            (2, Value::Int(ms)) => {
152                let ms = u64::try_from(ms).unwrap_or(0);
153                Ok(MarkerReply::Exhausted(Duration::from_millis(ms)))
154            }
155            _ => Err(RedisError::Command(
156                "unexpected reply from the admission script".into(),
157            )),
158        }
159    }
160
161    /// Set `epoch_key` to `epoch` with a lifetime and delete `marker`, as
162    /// one `MULTI`/`EXEC` round trip.
163    ///
164    /// # Errors
165    /// [`RedisError::Invalid`] when `ttl` is below 1ms or above
166    /// `i64::MAX / 2` milliseconds. Otherwise connection or command errors.
167    pub async fn rotate_marker(
168        &self,
169        marker: &str,
170        epoch_key: &str,
171        epoch: &str,
172        ttl: Duration,
173    ) -> Result<(), RedisError> {
174        let ms = required_ttl(ttl)?;
175        let mut pipe = redis::pipe();
176        pipe.atomic()
177            .cmd("SET")
178            .arg(self.key(epoch_key))
179            .arg(epoch)
180            .arg("PX")
181            .arg(ms)
182            .ignore()
183            .cmd("DEL")
184            .arg(self.key(marker))
185            .ignore();
186        let mut conn = self.manager.clone();
187        pipe.query_async::<()>(&mut conn)
188            .await
189            .map_err(RedisError::from)
190    }
191}
192
193fn ttl_error(what: &str) -> RedisError {
194    RedisError::Invalid(format!("redis {what} must be at most i64::MAX / 4 ms"))
195}