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