Skip to main content

yo_kv/
counter.rs

1//! `INCREX`, which is a counter with a policy attached.
2//!
3//! Redis 8.8 put four separate ideas into one command: increment by an integer
4//! or a float, refuse or clamp a result that leaves a range, and set, keep,
5//! clear or conditionally set the key's deadline, all in the round trip that
6//! used to be `INCR` followed by `EXPIRE`. It is the first Redis primitive that
7//! implements a workload rather than a data structure, and it replaces a Lua
8//! script for rate limiting, quota counting and stock levels.
9//!
10//! The arithmetic is here rather than in `strings.rs` because it is the part
11//! with the edges. A rejected increment must not create the key and must not
12//! touch the deadline, a saturated one must create it, and the amount actually
13//! applied has to come back so the caller can tell the two apart without
14//! comparing against a value it did not have.
15
16use yo_common::{Code, Error, Result};
17
18/// An integer or a float, which is what `INCREX` counts in.
19///
20/// The two never mix inside one call. `BYINT` with a `UBOUND` that is not an
21/// integer is an error on a real server, and it is an error here.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum Num {
24    /// `BYINT`, and the default when neither is given.
25    Int(i64),
26    /// `BYFLOAT`.
27    Float(f64),
28}
29
30impl Num {
31    /// Whether this is the integer kind.
32    #[must_use]
33    pub const fn is_int(self) -> bool {
34        matches!(self, Num::Int(_))
35    }
36
37    /// Zero of the same kind, which is what a rejected increment applied.
38    #[must_use]
39    const fn zero_like(self) -> Num {
40        match self {
41            Num::Int(_) => Num::Int(0),
42            Num::Float(_) => Num::Float(0.0),
43        }
44    }
45
46    fn as_int(self, what: &str) -> Result<i64> {
47        match self {
48            Num::Int(n) => Ok(n),
49            Num::Float(_) => Err(Error::fmt(
50                Code::Invalid,
51                format_args!("{what} is not an integer or out of range"),
52            )),
53        }
54    }
55
56    fn as_float(self, what: &str) -> Result<f64> {
57        match self {
58            Num::Float(f) => Ok(f),
59            Num::Int(n) => {
60                // An integer bound on a float increment is not the error a
61                // float bound on an integer increment is, because every i64 is
62                // a sensible float bound. It is accepted and widened.
63                let _ = what;
64                Ok(n as f64)
65            }
66        }
67    }
68}
69
70/// What `INCREX` should do with the key's deadline.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum IncrExpire {
73    /// No expiration option, which leaves whatever deadline the key had.
74    #[default]
75    Keep,
76    /// `PERSIST`: drop the deadline.
77    Persist,
78    /// `EX`, `PX`, `EXAT` or `PXAT`, as an absolute unix millisecond.
79    At(u64),
80    /// The same, with `ENX`: set it only if the key has no deadline already.
81    AtIfNone(u64),
82}
83
84/// Everything `INCREX` can be asked to do beyond adding one.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct IncrEx {
87    /// `BYINT n` or `BYFLOAT f`. One by default.
88    pub by: Num,
89    /// `SATURATE`: clamp to the bound instead of refusing.
90    pub saturate: bool,
91    /// `LBOUND`. The type's own minimum when absent.
92    pub lower: Option<Num>,
93    /// `UBOUND`. The type's own maximum when absent.
94    pub upper: Option<Num>,
95    /// The expiration options.
96    pub expire: IncrExpire,
97}
98
99impl Default for IncrEx {
100    fn default() -> IncrEx {
101        IncrEx {
102            by: Num::Int(1),
103            saturate: false,
104            lower: None,
105            upper: None,
106            expire: IncrExpire::Keep,
107        }
108    }
109}
110
111impl IncrEx {
112    /// Plain `INCREX key`, which adds one and leaves the deadline alone.
113    pub const PLAIN: IncrEx = IncrEx {
114        by: Num::Int(1),
115        saturate: false,
116        lower: None,
117        upper: None,
118        expire: IncrExpire::Keep,
119    };
120
121    /// This, by a different amount.
122    #[must_use]
123    pub const fn by(mut self, by: Num) -> IncrEx {
124        self.by = by;
125        self
126    }
127
128    /// This, clamping instead of refusing.
129    #[must_use]
130    pub const fn saturating(mut self) -> IncrEx {
131        self.saturate = true;
132        self
133    }
134
135    /// This, held between two bounds.
136    #[must_use]
137    pub const fn between(mut self, lower: Option<Num>, upper: Option<Num>) -> IncrEx {
138        self.lower = lower;
139        self.upper = upper;
140        self
141    }
142
143    /// This, with something to say about the deadline.
144    #[must_use]
145    pub const fn expiring(mut self, expire: IncrExpire) -> IncrEx {
146        self.expire = expire;
147        self
148    }
149}
150
151/// What `INCREX` did.
152///
153/// Both halves reach the client: the reply is the value and then the amount
154/// applied, and an amount of zero is how a client tells a refused increment
155/// from one that happened to add nothing.
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct Counted {
158    /// The value now, which is the value before when the increment was refused.
159    pub value: Num,
160    /// How much was actually added, which is zero when nothing was.
161    pub applied: Num,
162    /// Whether anything was written. A refused increment does not create the
163    /// key and does not touch its deadline.
164    pub stored: bool,
165}
166
167/// `current + by`, held inside the bounds, in the kind `by` is.
168///
169/// An out of range result is refused unless `saturate`, in which case it lands
170/// on the bound it went past. Overflow counts as going past the bound in the
171/// direction of the increment, so `INCREX` on `i64::MAX` refuses rather than
172/// wrapping, and with `SATURATE` it stays where it is.
173pub fn apply(current: Num, opts: &IncrEx) -> Result<Counted> {
174    match opts.by {
175        Num::Int(by) => {
176            let now = current.as_int("value")?;
177            let lo = opts.lower.map_or(Ok(i64::MIN), |b| b.as_int("LBOUND"))?;
178            let hi = opts.upper.map_or(Ok(i64::MAX), |b| b.as_int("UBOUND"))?;
179            if lo > hi {
180                return Err(bounds_crossed());
181            }
182            let want = now.checked_add(by);
183            let out = match want {
184                Some(v) if v >= lo && v <= hi => Some(v),
185                _ if !opts.saturate => None,
186                // Which bound it landed on is decided by the direction of the
187                // increment and not by the arithmetic, because the arithmetic
188                // may have overflowed on the way there.
189                _ if by >= 0 => Some(hi),
190                _ => Some(lo),
191            };
192            Ok(match out {
193                Some(v) => Counted {
194                    value: Num::Int(v),
195                    // The result fits and the distance travelled to get there
196                    // may not, which only happens when `SATURATE` throws the
197                    // value across most of the range in one call: from near
198                    // `i64::MAX` down onto a bound near `i64::MIN`, or back.
199                    // Redis refuses that rather than reporting a wrapped
200                    // amount, and refusing means the key is not written either.
201                    applied: Num::Int(v.checked_sub(now).ok_or_else(applied_overflow)?),
202                    stored: true,
203                },
204                None => Counted {
205                    value: Num::Int(now),
206                    applied: Num::Int(0),
207                    stored: false,
208                },
209            })
210        }
211        Num::Float(by) => {
212            if by.is_nan() {
213                return Err(Error::new(Code::Invalid, "value is not a valid float"));
214            }
215            let now = match current {
216                Num::Float(f) => f,
217                Num::Int(n) => n as f64,
218            };
219            let lo = opts.lower.map_or(Ok(f64::MIN), |b| b.as_float("LBOUND"))?;
220            let hi = opts.upper.map_or(Ok(f64::MAX), |b| b.as_float("UBOUND"))?;
221            if lo > hi {
222                return Err(bounds_crossed());
223            }
224            let want = now + by;
225            let out = if want.is_finite() && want >= lo && want <= hi {
226                Some(want)
227            } else if !opts.saturate {
228                None
229            } else if by >= 0.0 {
230                Some(hi)
231            } else {
232                Some(lo)
233            };
234            Ok(match out {
235                Some(v) => Counted {
236                    value: Num::Float(v),
237                    applied: Num::Float(v - now),
238                    stored: true,
239                },
240                None => Counted {
241                    value: Num::Float(now),
242                    applied: opts.by.zero_like(),
243                    stored: false,
244                },
245            })
246        }
247    }
248}
249
250/// What a real 8.8 says when the range is empty.
251///
252/// It refuses rather than treating it as a range nothing fits in, which is the
253/// right call: a caller that has its bounds the wrong way round has a bug, and
254/// silently refusing every increment forever is a hard bug to find.
255fn bounds_crossed() -> Error {
256    Error::new(Code::Invalid, "LBOUND can't be greater than UBOUND")
257}
258
259/// What a real 8.10.1 says when the amount applied would not fit in an `i64`.
260///
261/// The reply reports both the new value and how much of the increment was
262/// really applied, and `SATURATE` can land on a bound so far from where the
263/// value was that the distance between them does not fit. Reporting a wrapped
264/// number there would be worse than refusing: a client that adds `applied` to
265/// what it thought the value was would get an answer that is not the value.
266fn applied_overflow() -> Error {
267    Error::new(Code::Invalid, "applied increment would overflow")
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn int(n: i64) -> Num {
275        Num::Int(n)
276    }
277
278    #[test]
279    fn the_plain_form_adds_one() {
280        let c = apply(int(5), &IncrEx::PLAIN).unwrap();
281        assert_eq!(c.value, int(6));
282        assert_eq!(c.applied, int(1));
283        assert!(c.stored);
284    }
285
286    #[test]
287    fn a_result_past_a_bound_is_refused_and_nothing_is_written() {
288        // What a real 8.8 replies to `INCREX b BYINT 10 UBOUND 5` on a key that
289        // is not there: the value it would have had, zero applied, and the key
290        // still not there afterwards.
291        let opts = IncrEx::PLAIN.by(int(10)).between(None, Some(int(5)));
292        let c = apply(int(0), &opts).unwrap();
293        assert_eq!(c.value, int(0));
294        assert_eq!(c.applied, int(0));
295        assert!(!c.stored);
296    }
297
298    #[test]
299    fn saturate_lands_on_the_bound_and_reports_what_it_managed() {
300        let opts = IncrEx::PLAIN
301            .by(int(10))
302            .between(None, Some(int(5)))
303            .saturating();
304        let c = apply(int(0), &opts).unwrap();
305        assert_eq!(c.value, int(5));
306        assert_eq!(c.applied, int(5));
307        assert!(c.stored);
308
309        // Downwards, from 5 to a floor of 0, which is minus five and not minus
310        // ten.
311        let down = IncrEx::PLAIN
312            .by(int(-10))
313            .between(Some(int(0)), None)
314            .saturating();
315        let c = apply(int(5), &down).unwrap();
316        assert_eq!(c.value, int(0));
317        assert_eq!(c.applied, int(-5));
318    }
319
320    #[test]
321    fn overflow_is_a_bound_and_not_a_wrap() {
322        let c = apply(int(i64::MAX), &IncrEx::PLAIN).unwrap();
323        assert_eq!(c.value, int(i64::MAX));
324        assert_eq!(c.applied, int(0));
325        assert!(!c.stored);
326
327        let sat = apply(int(i64::MAX), &IncrEx::PLAIN.saturating()).unwrap();
328        assert_eq!(sat.value, int(i64::MAX));
329        assert_eq!(sat.applied, int(0));
330
331        let down = apply(int(i64::MIN), &IncrEx::PLAIN.by(int(-1)).saturating()).unwrap();
332        assert_eq!(down.value, int(i64::MIN));
333        assert_eq!(down.applied, int(0));
334    }
335
336    #[test]
337    fn an_amount_applied_that_does_not_fit_is_refused_rather_than_wrapped() {
338        // Found by Redis's own unit/type/increx. The result lands on UBOUND,
339        // which fits, and the distance from where the value was to that bound
340        // is more than an i64 holds. A real 8.10.1 refuses and leaves the key
341        // alone, where we used to report a wrapped amount.
342        let opts = IncrEx::PLAIN
343            .by(int(1))
344            .between(None, Some(int(i64::MIN)))
345            .saturating();
346        let e = apply(int(i64::MAX - 7), &opts).unwrap_err();
347        assert_eq!(e.message(), "applied increment would overflow");
348
349        // The same distance the other way.
350        let up = IncrEx::PLAIN
351            .by(int(-1))
352            .between(Some(int(i64::MAX)), None)
353            .saturating();
354        assert!(apply(int(i64::MIN + 7), &up).is_err());
355
356        // A saturation that lands far away but still inside an i64 is fine, so
357        // this is about the amount and not about the distance being large.
358        let ok = IncrEx::PLAIN
359            .by(int(1))
360            .between(None, Some(int(i64::MIN + 8)))
361            .saturating();
362        let c = apply(int(-3), &ok).unwrap();
363        assert_eq!(c.value, int(i64::MIN + 8));
364        assert_eq!(c.applied, int(i64::MIN + 11));
365        assert!(c.stored);
366    }
367
368    #[test]
369    fn bounds_the_wrong_way_round_are_refused_rather_than_obeyed() {
370        // `INCREX c UBOUND 5 LBOUND 10` on a real 8.8.
371        let opts = IncrEx::PLAIN.between(Some(int(10)), Some(int(5)));
372        let e = apply(int(0), &opts).unwrap_err();
373        assert_eq!(e.message(), "LBOUND can't be greater than UBOUND");
374
375        let f = IncrEx::PLAIN
376            .by(Num::Float(1.0))
377            .between(Some(Num::Float(10.0)), Some(Num::Float(5.0)));
378        assert!(apply(Num::Float(0.0), &f).is_err());
379    }
380
381    #[test]
382    fn a_float_bound_on_an_integer_increment_is_an_error() {
383        // `INCREX q BYINT 1 UBOUND 5.5` on a real 8.8 is
384        // `ERR UBOUND is not an integer or out of range`.
385        let opts = IncrEx::PLAIN.between(None, Some(Num::Float(5.5)));
386        let e = apply(int(1), &opts).unwrap_err();
387        assert!(e.message().contains("UBOUND"), "{e}");
388    }
389
390    #[test]
391    fn a_float_increment_counts_in_floats() {
392        let c = apply(Num::Float(1.0), &IncrEx::PLAIN.by(Num::Float(0.5))).unwrap();
393        assert_eq!(c.value, Num::Float(1.5));
394        assert_eq!(c.applied, Num::Float(0.5));
395
396        // An integer bound is fine on a float increment, since every i64 is a
397        // sensible float bound.
398        let bounded = IncrEx::PLAIN
399            .by(Num::Float(10.0))
400            .between(None, Some(int(5)))
401            .saturating();
402        let c = apply(Num::Float(0.0), &bounded).unwrap();
403        assert_eq!(c.value, Num::Float(5.0));
404    }
405
406    #[test]
407    fn a_float_that_overflows_to_infinity_is_out_of_range() {
408        let opts = IncrEx::PLAIN.by(Num::Float(f64::MAX));
409        let c = apply(Num::Float(f64::MAX), &opts).unwrap();
410        assert!(!c.stored);
411        assert_eq!(c.value, Num::Float(f64::MAX));
412
413        let sat = apply(Num::Float(f64::MAX), &opts.saturating()).unwrap();
414        assert_eq!(sat.value, Num::Float(f64::MAX));
415        assert_eq!(sat.applied, Num::Float(0.0));
416    }
417}