Skip to main content

kevy_client/
hash_ttl.rs

1//! Hash field-TTL: `HEXPIRE` / `HPEXPIRE` / `HPERSIST` / `HTTL`
2//! (Redis 7.4 command 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 crate::{KevyError, KevyResult};
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    ) -> KevyResult<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    ) -> KevyResult<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]]) -> KevyResult<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 **seconds**
78    /// (`-2` key/field missing, `-1` no TTL).
79    ///
80    /// Before 4.0 this returned milliseconds, which no caller reading the name
81    /// would have expected. [`Self::hpttl`] is the millisecond form.
82    pub fn httl(&mut self, key: &[u8], fields: &[&[u8]]) -> KevyResult<Vec<i64>> {
83        check_fields(fields)?;
84        match self {
85            Self::Embedded(s) => s.httl(key, fields),
86            Self::Remote(c) => {
87                hash_ttl_request(c, b"HTTL", key, None, HExpireCond::Always, fields)
88            }
89        }
90    }
91
92    /// `HPTTL key FIELDS n field…` — remaining TTL per field in
93    /// **milliseconds** (`-2` key/field missing, `-1` no TTL).
94    pub fn hpttl(&mut self, key: &[u8], fields: &[&[u8]]) -> KevyResult<Vec<i64>> {
95        check_fields(fields)?;
96        match self {
97            Self::Embedded(s) => s.hpttl(key, fields),
98            Self::Remote(c) => {
99                hash_ttl_request(c, b"HPTTL", key, None, HExpireCond::Always, fields)
100            }
101        }
102    }
103}
104
105/// The verbs require `numfields > 0` — surface that before the wire.
106fn check_fields(fields: &[&[u8]]) -> KevyResult<()> {
107    if fields.is_empty() {
108        return Err(KevyError::InvalidInput("hash field-TTL verbs need at least one field".into()));
109    }
110    Ok(())
111}
112
113fn cond_keyword(cond: HExpireCond) -> Option<&'static [u8]> {
114    match cond {
115        HExpireCond::Always => None,
116        HExpireCond::Nx => Some(b"NX"),
117        HExpireCond::Xx => Some(b"XX"),
118        HExpireCond::Gt => Some(b"GT"),
119        HExpireCond::Lt => Some(b"LT"),
120    }
121}
122
123/// Build `verb key [arg] [cond] FIELDS n field…`, parse the per-field
124/// integer array.
125fn hash_ttl_request(
126    c: &mut RespClient,
127    verb: &[u8],
128    key: &[u8],
129    arg: Option<String>,
130    cond: HExpireCond,
131    fields: &[&[u8]],
132) -> KevyResult<Vec<i64>> {
133    let mut args = Vec::with_capacity(fields.len() + 6);
134    args.push(verb.to_vec());
135    args.push(key.to_vec());
136    if let Some(a) = arg {
137        args.push(a.into_bytes());
138    }
139    if let Some(kw) = cond_keyword(cond) {
140        args.push(kw.to_vec());
141    }
142    args.push(b"FIELDS".to_vec());
143    args.push(fields.len().to_string().into_bytes());
144    args.extend(fields.iter().map(|f| f.to_vec()));
145    match c.request(&args)? {
146        Reply::Array(items) => items
147            .into_iter()
148            .map(|r| match r {
149                Reply::Int(n) => Ok(n),
150                other => Err(unexpected(other)),
151            })
152            .collect(),
153        Reply::Error(e) => Err(KevyError::Protocol(string(e))),
154        other => Err(unexpected(other)),
155    }
156}
157
158fn to_codes(v: Vec<i64>) -> Vec<HExpireCode> {
159    v.into_iter().map(|n| n as HExpireCode).collect()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::time::Duration;
166
167    #[test]
168    fn embedded_hexpire_httl_hpersist_round_trip() {
169        let mut c = Connection::connect("mem://").unwrap();
170        c.hset(b"h", &[(b"a".as_ref(), b"1".as_ref()), (b"b".as_ref(), b"2".as_ref())])
171            .unwrap();
172
173        let codes = c
174            .hexpire(b"h", &[&b"a"[..], &b"nope"[..]], Duration::from_secs(60), HExpireCond::Always)
175            .unwrap();
176        assert_eq!(codes, vec![1, -2]);
177
178        let ttls = c.httl(b"h", &[&b"a"[..], &b"b"[..]]).unwrap();
179        assert!((0..=60_000).contains(&ttls[0]), "ttl = {}", ttls[0]);
180        assert_eq!(ttls[1], -1);
181
182        let codes = c.hpersist(b"h", &[&b"a"[..], &b"b"[..]]).unwrap();
183        assert_eq!(codes, vec![1, -1]);
184        assert_eq!(c.httl(b"h", &[&b"a"[..]]).unwrap(), vec![-1]);
185    }
186
187    #[test]
188    fn embedded_hpexpire_ms_precision() {
189        let mut c = Connection::connect("mem://").unwrap();
190        c.hset(b"h", &[(b"f".as_ref(), b"v".as_ref())]).unwrap();
191        let codes = c
192            .hpexpire(b"h", &[&b"f"[..]], Duration::from_millis(1500), HExpireCond::Always)
193            .unwrap();
194        assert_eq!(codes, vec![1]);
195        let ttl = c.httl(b"h", &[&b"f"[..]]).unwrap()[0];
196        assert!((0..=1500).contains(&ttl), "ttl = {ttl}");
197    }
198
199    #[test]
200    fn empty_fields_rejected() {
201        let mut c = Connection::connect("mem://").unwrap();
202        let err = c.httl(b"h", &[]).unwrap_err();
203        assert!(matches!(err, KevyError::InvalidInput(_)));
204    }
205}