Skip to main content

kevy_store/
hash_ttl.rs

1//! Per-field hash TTLs (`HEXPIRE` / `HPEXPIRE` / `HPEXPIREAT` /
2//! `HTTL` / `HPERSIST`, Redis 7.4 semantics).
3//!
4//! Storage: a store-level sidecar `hfttl: key → (field → absolute
5//! unix-ms deadline)` holding ONLY keys that have at least one
6//! field TTL — a store that never uses the feature pays one
7//! `is_empty()` branch per hash access and nothing else.
8//!
9//! Enforcement follows the key-TTL discipline exactly:
10//! - **lazy on access**: every hash op calls [`Store::purge_hash_ttl`]
11//!   first, which removes expired fields from the hash (and the
12//!   sidecar) — mutating-on-read like `live_entry`. No AOF frames are
13//!   written for lazy purges: the `HPEXPIREAT` frame that created the
14//!   deadline is already in the log, so a replay reconstructs the
15//!   sidecar and purges identically (deterministic).
16//! - **actively by the reaper**: [`Store::tick_hash_ttl`] sweeps due
17//!   fields and reports what it removed so the caller can log the
18//!   `HDEL` effect (server tick / embedded reaper).
19//! - **cleared on overwrite**: `HSET`/`HINCRBY*` on a field discards
20//!   that field's TTL (Redis 7.4 behavior) via
21//!   [`Store::clear_hash_field_ttls`]; whole-key removal drops the
22//!   sidecar entry in `remove_entry`.
23
24#[cfg(not(feature = "std"))]
25use crate::nostd_prelude::*;
26use crate::{SmallBytes, Store, StoreError, Value, now_unix_ms};
27
28/// Per-field reply codes for `HEXPIRE`-family calls (Redis 7.4):
29/// `-2` key or field missing, `0` condition (NX/XX/GT/LT) not met,
30/// `1` deadline set, `2` field deleted (deadline already due).
31pub type HExpireCode = i8;
32
33/// Condition flags for `HEXPIRE` (`NX`/`XX`/`GT`/`LT`; at most one).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum HExpireCond {
36    /// Unconditional.
37    #[default]
38    Always,
39    /// Only when the field has no TTL.
40    Nx,
41    /// Only when the field already has a TTL.
42    Xx,
43    /// Only when the new deadline is later than the current one
44    /// (no TTL counts as infinitely late — GT never replaces it).
45    Gt,
46    /// Only when the new deadline is earlier (no TTL = always).
47    Lt,
48}
49
50impl Store {
51    /// Does this hash field exist (ignoring TTL state)?
52    fn hash_has_field(&mut self, key: &[u8], field: &[u8]) -> Result<bool, StoreError> {
53        match self.tier_serve(key, crate::value::COLD_TAG_HASH)? {
54            None => Ok(false),
55            Some(e) => match &e.value {
56                Value::Hash(h) => Ok(h.get(field).is_some()),
57                Value::SegHash(h) => Ok(h.get(field).is_some()),
58                Value::SmallHashInline(h) => Ok(h.get(field).is_some()),
59                _ => Err(StoreError::WrongType),
60            },
61        }
62    }
63
64    /// Set per-field deadlines (absolute unix-ms). One code per field,
65    /// request order. Due-or-past deadlines delete the field
66    /// immediately (code `2`, Redis semantics).
67    pub fn hexpire_at(
68        &mut self,
69        key: &[u8],
70        fields: &[&[u8]],
71        deadline_ms: u64,
72        cond: HExpireCond,
73    ) -> Result<Vec<HExpireCode>, StoreError> {
74        self.purge_hash_ttl(key);
75        let mut codes = Vec::with_capacity(fields.len());
76        let now = now_unix_ms();
77        for f in fields {
78            if !self.hash_has_field(key, f)? {
79                codes.push(-2);
80                continue;
81            }
82            let current = self
83                .hfttl
84                .get(key)
85                .and_then(|m| m.get(*f))
86                .copied();
87            let pass = match cond {
88                HExpireCond::Always => true,
89                HExpireCond::Nx => current.is_none(),
90                HExpireCond::Xx => current.is_some(),
91                HExpireCond::Gt => current.is_some_and(|c| deadline_ms > c),
92                HExpireCond::Lt => current.is_none_or(|c| deadline_ms < c),
93            };
94            if !pass {
95                codes.push(0);
96                continue;
97            }
98            if deadline_ms <= now {
99                if let Some(m) = self.hfttl.get_mut(key) {
100                    m.remove(*f);
101                }
102                self.hdel(key, &[f])?;
103                codes.push(2);
104                continue;
105            }
106            hfttl_slot(&mut self.hfttl, key)
107                .insert(SmallBytes::from_slice(f), deadline_ms);
108            codes.push(1);
109        }
110        self.prune_hfttl_key(key);
111        Ok(codes)
112    }
113
114    /// Remaining TTL per field: `-2` key/field missing, `-1` no TTL,
115    /// else remaining ms.
116    pub fn hpttl(&mut self, key: &[u8], fields: &[&[u8]]) -> Result<Vec<i64>, StoreError> {
117        self.purge_hash_ttl(key);
118        let now = now_unix_ms();
119        let mut out = Vec::with_capacity(fields.len());
120        for f in fields {
121            if !self.hash_has_field(key, f)? {
122                out.push(-2);
123                continue;
124            }
125            match self.hfttl.get(key).and_then(|m| m.get(*f)) {
126                Some(&d) => out.push(d.saturating_sub(now) as i64),
127                None => out.push(-1),
128            }
129        }
130        Ok(out)
131    }
132
133    /// Clear per-field TTLs: `-2` missing, `-1` had no TTL, `1` cleared.
134    pub fn hpersist(&mut self, key: &[u8], fields: &[&[u8]]) -> Result<Vec<HExpireCode>, StoreError> {
135        self.purge_hash_ttl(key);
136        let mut out = Vec::with_capacity(fields.len());
137        for f in fields {
138            if !self.hash_has_field(key, f)? {
139                out.push(-2);
140                continue;
141            }
142            let had = self
143                .hfttl
144                .get_mut(key)
145                .and_then(|m| m.remove(*f))
146                .is_some();
147            out.push(if had { 1 } else { -1 });
148        }
149        self.prune_hfttl_key(key);
150        Ok(out)
151    }
152
153    /// Lazy enforcement hook — call at the top of every hash op.
154    /// Removes expired fields from the hash + sidecar. One `is_empty`
155    /// branch when the feature is unused.
156    pub(crate) fn purge_hash_ttl(&mut self, key: &[u8]) {
157        if self.hfttl.is_empty() {
158            return;
159        }
160        let now = now_unix_ms();
161        let due: Vec<Vec<u8>> = match self.hfttl.get(key) {
162            None => return,
163            Some(m) => m
164                .iter()
165                .filter(|(_, d)| **d <= now)
166                .map(|(f, _)| f.to_vec())
167                .collect(),
168        };
169        if due.is_empty() {
170            return;
171        }
172        // Sidecar first: `hdel` re-enters this fn, and a clean sidecar
173        // makes the re-entry a no-op (no recursion).
174        if let Some(m) = self.hfttl.get_mut(key) {
175            for f in &due {
176                m.remove(f.as_slice());
177            }
178        }
179        self.prune_hfttl_key(key);
180        let due_refs: Vec<&[u8]> = due.iter().map(Vec::as_slice).collect();
181        let _ = self.hdel(key, &due_refs);
182    }
183
184    /// Overwrite hook — `HSET`/`HINCRBY*` on a field discards its TTL.
185    pub(crate) fn clear_hash_field_ttls(&mut self, key: &[u8], fields: &[&[u8]]) {
186        if self.hfttl.is_empty() {
187            return;
188        }
189        if let Some(m) = self.hfttl.get_mut(key) {
190            for f in fields {
191                m.remove(*f);
192            }
193        }
194        self.prune_hfttl_key(key);
195    }
196
197    /// Whole-key hook — key removed/overwritten wholesale.
198    pub(crate) fn clear_hash_key_ttls(&mut self, key: &[u8]) {
199        if self.hfttl.is_empty() {
200            return;
201        }
202        self.hfttl.remove(key);
203    }
204
205    fn prune_hfttl_key(&mut self, key: &[u8]) {
206        if self.hfttl.get(key).is_some_and(kevy_map_is_empty) {
207            self.hfttl.remove(key);
208        }
209    }
210
211    /// Reaper sweep: remove every due field store-wide; returns
212    /// `(key, removed fields)` pairs so the caller logs `HDEL` effects.
213    pub fn tick_hash_ttl(&mut self, max_keys: usize) -> Vec<(Vec<u8>, Vec<Vec<u8>>)> {
214        if self.hfttl.is_empty() {
215            return Vec::new();
216        }
217        let now = now_unix_ms();
218        let candidates: Vec<Vec<u8>> = self
219            .hfttl
220            .iter()
221            .filter(|(_, m)| m.iter().any(|(_, d)| *d <= now))
222            .take(max_keys)
223            .map(|(k, _)| k.to_vec())
224            .collect();
225        let mut out = Vec::with_capacity(candidates.len());
226        for k in candidates {
227            let due: Vec<Vec<u8>> = self
228                .hfttl
229                .get(k.as_slice())
230                .map(|m| {
231                    m.iter()
232                        .filter(|(_, d)| **d <= now)
233                        .map(|(f, _)| f.to_vec())
234                        .collect()
235                })
236                .unwrap_or_default();
237            if due.is_empty() {
238                continue;
239            }
240            if let Some(m) = self.hfttl.get_mut(k.as_slice()) {
241                for f in &due {
242                    m.remove(f.as_slice());
243                }
244            }
245            self.prune_hfttl_key(&k);
246            let due_refs: Vec<&[u8]> = due.iter().map(Vec::as_slice).collect();
247            let _ = self.hdel(&k, &due_refs);
248            out.push((k, due));
249        }
250        out
251    }
252
253    /// Snapshot loader hook: restore one field TTL (deadlines already
254    /// absolute unix-ms; past deadlines simply purge on first access).
255    pub fn load_hash_field_ttl(&mut self, key: &[u8], field: &[u8], deadline_ms: u64) {
256        hfttl_slot(&mut self.hfttl, key)
257            .insert(SmallBytes::from_slice(field), deadline_ms);
258    }
259
260    /// Snapshot support: visit every live (key, field, deadline_ms).
261    pub fn hash_ttl_each<F: FnMut(&[u8], &[u8], u64)>(&self, mut f: F) {
262        for (k, m) in self.hfttl.iter() {
263            for (field, &d) in m.iter() {
264                f(k.as_slice(), field.as_slice(), d);
265            }
266        }
267    }
268}
269
270fn kevy_map_is_empty(m: &crate::KevyMap<SmallBytes, u64>) -> bool {
271    m.iter().next().is_none()
272}
273
274
275/// `entry().or_default()` over both side-map backends — `KevyMap` (the
276/// `no_std` arm) has no entry API, so that arm inserts-if-absent and
277/// re-probes.
278fn hfttl_slot<'a>(
279    hfttl: &'a mut crate::SideMap<SmallBytes, kevy_map::KevyMap<SmallBytes, u64>>,
280    key: &[u8],
281) -> &'a mut kevy_map::KevyMap<SmallBytes, u64> {
282    #[cfg(feature = "std")]
283    {
284        hfttl.entry(SmallBytes::from_slice(key)).or_default()
285    }
286    #[cfg(not(feature = "std"))]
287    {
288        if hfttl.get(key).is_none() {
289            hfttl.insert(SmallBytes::from_slice(key), kevy_map::KevyMap::default());
290        }
291        hfttl.get_mut(key).expect("inserted above")
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn h(s: &mut Store) {
300        s.hset(
301            b"h",
302            &[(b"a".as_slice(), b"1".as_slice()), (b"b".as_slice(), b"2".as_slice())],
303        )
304        .unwrap();
305    }
306
307    #[test]
308    fn hexpire_httl_hpersist_codes() {
309        let mut s = Store::new();
310        h(&mut s);
311        let far = now_unix_ms() + 100_000;
312        // set on a + missing field
313        let codes = s
314            .hexpire_at(b"h", &[b"a", b"nope"], far, HExpireCond::Always)
315            .unwrap();
316        assert_eq!(codes, vec![1, -2]);
317        let ttls = s.hpttl(b"h", &[b"a", b"b", b"nope"]).unwrap();
318        assert!(ttls[0] > 90_000 && ttls[0] <= 100_000);
319        assert_eq!(&ttls[1..], &[-1, -2]);
320        // NX refuses existing, XX refuses missing
321        assert_eq!(
322            s.hexpire_at(b"h", &[b"a"], far + 1, HExpireCond::Nx).unwrap(),
323            vec![0]
324        );
325        assert_eq!(
326            s.hexpire_at(b"h", &[b"b"], far, HExpireCond::Xx).unwrap(),
327            vec![0]
328        );
329        // GT/LT
330        assert_eq!(
331            s.hexpire_at(b"h", &[b"a"], far + 500, HExpireCond::Gt).unwrap(),
332            vec![1]
333        );
334        assert_eq!(
335            s.hexpire_at(b"h", &[b"a"], far, HExpireCond::Gt).unwrap(),
336            vec![0]
337        );
338        // persist
339        assert_eq!(s.hpersist(b"h", &[b"a", b"b", b"nope"]).unwrap(), vec![1, -1, -2]);
340        assert_eq!(s.hpttl(b"h", &[b"a"]).unwrap(), vec![-1]);
341    }
342
343    #[test]
344    fn past_deadline_deletes_and_lazy_purge_enforces() {
345        let mut s = Store::new();
346        h(&mut s);
347        // past deadline → immediate delete, code 2
348        assert_eq!(
349            s.hexpire_at(b"h", &[b"a"], 1, HExpireCond::Always).unwrap(),
350            vec![2]
351        );
352        assert!(!s.hexists(b"h", b"a").unwrap());
353        // near-future deadline → lazily gone after it passes
354        let soon = now_unix_ms() + 30;
355        s.hexpire_at(b"h", &[b"b"], soon, HExpireCond::Always).unwrap();
356        std::thread::sleep(core::time::Duration::from_millis(50));
357        assert!(!s.hexists(b"h", b"b").unwrap(), "lazy purge on access");
358        // hash is now empty → hlen 0, sidecar pruned
359        assert_eq!(s.hlen(b"h").unwrap(), 0);
360        assert!(s.hfttl.is_empty());
361    }
362
363    #[test]
364    fn overwrite_clears_ttl_and_reaper_reports() {
365        let mut s = Store::new();
366        h(&mut s);
367        let soon = now_unix_ms() + 20;
368        s.hexpire_at(b"h", &[b"a", b"b"], soon, HExpireCond::Always).unwrap();
369        // overwrite a → its TTL is discarded (Redis 7.4)
370        s.hset(b"h", &[(b"a".as_slice(), b"new".as_slice())]).unwrap();
371        assert_eq!(s.hpttl(b"h", &[b"a"]).unwrap(), vec![-1]);
372        std::thread::sleep(core::time::Duration::from_millis(40));
373        // reaper sweeps b, reports the removal for effect logging
374        let swept = s.tick_hash_ttl(100);
375        assert_eq!(swept, vec![(b"h".to_vec(), vec![b"b".to_vec()])]);
376        assert!(s.hexists(b"h", b"a").unwrap(), "overwritten field survived");
377        // whole-key delete drops the sidecar
378        let far = now_unix_ms() + 100_000;
379        s.hexpire_at(b"h", &[b"a"], far, HExpireCond::Always).unwrap();
380        s.del(&[b"h".as_slice()]);
381        assert!(s.hfttl.is_empty());
382    }
383}