Skip to main content

kevy_client/
hash_ttl.rs

1//! Hash field-TTL: `HEXPIRE` / `HPEXPIRE` / `HPERSIST` / `HTTL`
2//! (v1.14.0, server v2.4 / Redis 7.4 shape).
3//!
4//! Per-field replies come back in request order. The `HEXPIRE` /
5//! `HPEXPIRE` result codes ([`HExpireCode`], re-exported from
6//! kevy-embedded): `-2` key or field missing, `0` condition not met,
7//! `1` deadline set, `2` field deleted (deadline already due).
8
9use std::io;
10
11use kevy_embedded::{HExpireCode, HExpireCond};
12use kevy_resp::Reply;
13use kevy_resp_client::RespClient;
14
15use crate::{Connection, string, unexpected};
16
17impl Connection {
18    /// `HEXPIRE key seconds [NX|XX|GT|LT] FIELDS n field…` — set
19    /// per-field TTLs, **whole-second** precision (`ttl` is truncated
20    /// to seconds on both backends, matching the verb; use
21    /// [`Self::hpexpire`] for milliseconds). One code per field.
22    pub fn hexpire(
23        &mut self,
24        key: &[u8],
25        fields: &[&[u8]],
26        ttl: std::time::Duration,
27        cond: HExpireCond,
28    ) -> io::Result<Vec<HExpireCode>> {
29        check_fields(fields)?;
30        let secs = ttl.as_secs();
31        match self {
32            Self::Embedded(s) => {
33                s.hexpire(key, fields, std::time::Duration::from_secs(secs), cond)
34            }
35            Self::Remote(c) => {
36                hash_ttl_request(c, b"HEXPIRE", key, Some(secs.to_string()), cond, fields)
37                    .map(to_codes)
38            }
39        }
40    }
41
42    /// `HPEXPIRE key milliseconds [NX|XX|GT|LT] FIELDS n field…` —
43    /// millisecond-precision variant of [`Self::hexpire`].
44    pub fn hpexpire(
45        &mut self,
46        key: &[u8],
47        fields: &[&[u8]],
48        ttl: std::time::Duration,
49        cond: HExpireCond,
50    ) -> io::Result<Vec<HExpireCode>> {
51        check_fields(fields)?;
52        let ms = ttl.as_millis().min(i64::MAX as u128) as u64;
53        match self {
54            Self::Embedded(s) => {
55                s.hexpire(key, fields, std::time::Duration::from_millis(ms), cond)
56            }
57            Self::Remote(c) => {
58                hash_ttl_request(c, b"HPEXPIRE", key, Some(ms.to_string()), cond, fields)
59                    .map(to_codes)
60            }
61        }
62    }
63
64    /// `HPERSIST key FIELDS n field…` — clear per-field TTLs. Codes:
65    /// `-2` missing, `-1` had no TTL, `1` cleared.
66    pub fn hpersist(&mut self, key: &[u8], fields: &[&[u8]]) -> io::Result<Vec<HExpireCode>> {
67        check_fields(fields)?;
68        match self {
69            Self::Embedded(s) => s.hpersist(key, fields),
70            Self::Remote(c) => {
71                hash_ttl_request(c, b"HPERSIST", key, None, HExpireCond::Always, fields)
72                    .map(to_codes)
73            }
74        }
75    }
76
77    /// `HTTL key FIELDS n field…` — remaining TTL per field in
78    /// **milliseconds** (`-2` key/field missing, `-1` no TTL).
79    pub fn httl(&mut self, key: &[u8], fields: &[&[u8]]) -> io::Result<Vec<i64>> {
80        check_fields(fields)?;
81        match self {
82            Self::Embedded(s) => s.httl(key, fields),
83            Self::Remote(c) => {
84                hash_ttl_request(c, b"HTTL", key, None, HExpireCond::Always, fields)
85            }
86        }
87    }
88}
89
90/// The verbs require `numfields > 0` — surface that before the wire.
91fn check_fields(fields: &[&[u8]]) -> io::Result<()> {
92    if fields.is_empty() {
93        return Err(io::Error::new(
94            io::ErrorKind::InvalidInput,
95            "hash field-TTL verbs need at least one field",
96        ));
97    }
98    Ok(())
99}
100
101fn cond_keyword(cond: HExpireCond) -> Option<&'static [u8]> {
102    match cond {
103        HExpireCond::Always => None,
104        HExpireCond::Nx => Some(b"NX"),
105        HExpireCond::Xx => Some(b"XX"),
106        HExpireCond::Gt => Some(b"GT"),
107        HExpireCond::Lt => Some(b"LT"),
108    }
109}
110
111/// Build `verb key [arg] [cond] FIELDS n field…`, parse the per-field
112/// integer array.
113fn hash_ttl_request(
114    c: &mut RespClient,
115    verb: &[u8],
116    key: &[u8],
117    arg: Option<String>,
118    cond: HExpireCond,
119    fields: &[&[u8]],
120) -> io::Result<Vec<i64>> {
121    let mut args = Vec::with_capacity(fields.len() + 6);
122    args.push(verb.to_vec());
123    args.push(key.to_vec());
124    if let Some(a) = arg {
125        args.push(a.into_bytes());
126    }
127    if let Some(kw) = cond_keyword(cond) {
128        args.push(kw.to_vec());
129    }
130    args.push(b"FIELDS".to_vec());
131    args.push(fields.len().to_string().into_bytes());
132    args.extend(fields.iter().map(|f| f.to_vec()));
133    match c.request(&args)? {
134        Reply::Array(items) => items
135            .into_iter()
136            .map(|r| match r {
137                Reply::Int(n) => Ok(n),
138                other => Err(unexpected(other)),
139            })
140            .collect(),
141        Reply::Error(e) => Err(io::Error::other(string(e))),
142        other => Err(unexpected(other)),
143    }
144}
145
146fn to_codes(v: Vec<i64>) -> Vec<HExpireCode> {
147    v.into_iter().map(|n| n as HExpireCode).collect()
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::time::Duration;
154
155    #[test]
156    fn embedded_hexpire_httl_hpersist_round_trip() {
157        let mut c = Connection::open("mem://").unwrap();
158        c.hset(b"h", &[(b"a".as_ref(), b"1".as_ref()), (b"b".as_ref(), b"2".as_ref())])
159            .unwrap();
160
161        let codes = c
162            .hexpire(b"h", &[&b"a"[..], &b"nope"[..]], Duration::from_secs(60), HExpireCond::Always)
163            .unwrap();
164        assert_eq!(codes, vec![1, -2]);
165
166        let ttls = c.httl(b"h", &[&b"a"[..], &b"b"[..]]).unwrap();
167        assert!((0..=60_000).contains(&ttls[0]), "ttl = {}", ttls[0]);
168        assert_eq!(ttls[1], -1);
169
170        let codes = c.hpersist(b"h", &[&b"a"[..], &b"b"[..]]).unwrap();
171        assert_eq!(codes, vec![1, -1]);
172        assert_eq!(c.httl(b"h", &[&b"a"[..]]).unwrap(), vec![-1]);
173    }
174
175    #[test]
176    fn embedded_hpexpire_ms_precision() {
177        let mut c = Connection::open("mem://").unwrap();
178        c.hset(b"h", &[(b"f".as_ref(), b"v".as_ref())]).unwrap();
179        let codes = c
180            .hpexpire(b"h", &[&b"f"[..]], Duration::from_millis(1500), HExpireCond::Always)
181            .unwrap();
182        assert_eq!(codes, vec![1]);
183        let ttl = c.httl(b"h", &[&b"f"[..]]).unwrap()[0];
184        assert!((0..=1500).contains(&ttl), "ttl = {ttl}");
185    }
186
187    #[test]
188    fn empty_fields_rejected() {
189        let mut c = Connection::open("mem://").unwrap();
190        let err = c.httl(b"h", &[]).unwrap_err();
191        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
192    }
193}