Skip to main content

kevy_store/
expire.rs

1//! Active TTL reaper — Redis's `activeExpireCycle`, adapted to the
2//! thread-per-core / single-shard `Store`.
3//!
4//! Lazy expiry (in `live_entry[_mut]`) still handles the common case where
5//! the next access to a TTL'd key removes it. The active reaper exists for
6//! the harder case: a key has TTL but is never touched again, so without an
7//! explicit sweep it would sit in the map until the next FLUSH or eviction.
8//!
9//! Entry point: [`Store::tick_expire`]. The shard runtime calls it at the
10//! configured `[expiry].hz` cadence (default 10 Hz / every 100 ms);
11//! embedded users without a runtime call it themselves from whatever event
12//! loop they have (mandatory for WASM, which has no threads).
13//!
14//! ```
15//! use core::time::Duration;
16//! use kevy_store::{SetCondition, Store};
17//! let mut s = Store::new();
18//! s.set(b"k", b"v".to_vec(), Some(Duration::from_millis(1)), SetCondition::Always);
19//! // the key is never read again; only the reaper can remove it, and a
20//! // tick samples at random, so drive it the way a runtime would
21//! for _ in 0..500 {
22//!     s.tick_expire(16, 16);
23//!     if s.dbsize() == 0 {
24//!         break;
25//!     }
26//!     std::thread::sleep(Duration::from_millis(2));
27//! }
28//! assert_eq!(s.dbsize(), 0);
29//! ```
30
31#[cfg(not(feature = "std"))]
32use crate::nostd_prelude::*;
33use crate::{Store, now_ns};
34
35/// What [`Store::tick_expire`] saw and did. Surfaced for tests, INFO
36/// keyspace, and (eventually) Wave 2 task #4's crash-safe verifier.
37///
38/// ```
39/// use kevy_store::{ExpireStats, Store};
40/// let mut s = Store::new();
41/// // no TTL-bearing keys: the reaper does no work at all
42/// assert_eq!(s.tick_expire(16, 16), ExpireStats::default());
43/// ```
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub struct ExpireStats {
47    /// Total TTL-bearing keys sampled across all rounds.
48    ///
49    /// ```
50    /// use core::time::Duration;
51    /// use kevy_store::{SetCondition, Store};
52    /// let mut s = Store::new();
53    /// s.set(b"live", b"v".to_vec(), Some(Duration::from_secs(60)), SetCondition::Always);
54    /// // the walk starts at a random bucket, so one key may or may not be seen
55    /// let stats = s.tick_expire(16, 1);
56    /// assert!(stats.sampled <= 1);
57    /// assert_eq!(stats.expired, 0);
58    /// ```
59    pub sampled: u32,
60    /// How many of those were past their deadline and got removed.
61    ///
62    /// ```
63    /// use core::time::Duration;
64    /// use kevy_store::{SetCondition, Store};
65    /// let mut s = Store::new();
66    /// for k in [b"a", b"b"] {
67    ///     s.set(k, b"v".to_vec(), Some(Duration::from_millis(1)), SetCondition::Always);
68    /// }
69    /// let mut expired = 0;
70    /// for _ in 0..500 {
71    ///     expired += s.tick_expire(16, 16).expired;
72    ///     if s.dbsize() == 0 {
73    ///         break;
74    ///     }
75    ///     std::thread::sleep(Duration::from_millis(2));
76    /// }
77    /// assert_eq!(expired, 2);
78    /// ```
79    pub expired: u32,
80    /// Rounds executed before the loop exited (either `max_rounds` reached
81    /// or in-batch expire-rate dropped below the continuation threshold).
82    ///
83    /// ```
84    /// use core::time::Duration;
85    /// use kevy_store::{SetCondition, Store};
86    /// let mut s = Store::new();
87    /// s.set(b"live", b"v".to_vec(), Some(Duration::from_secs(60)), SetCondition::Always);
88    /// // nothing is due, so the tick stops well short of its 8-round budget
89    /// let stats = s.tick_expire(16, 8);
90    /// assert!((1..=3).contains(&stats.rounds));
91    /// ```
92    pub rounds: u32,
93}
94
95/// Continuation threshold: when an in-batch expire-rate is above this
96/// percentage, run another round (the keyspace is "expiry-heavy"). Mirrors
97/// Redis's 25% from `activeExpireCycle`.
98const EXPIRE_RATE_CONTINUATION: u32 = 25;
99
100/// Sample a single round of up to `samples` TTL-bearing keys starting at a
101/// random bucket; remove any that are past their deadline. Returns
102/// `(sampled, expired)` counts for this round. Walking is `O(visited)` —
103/// bounded by `2 * map.capacity()` to keep a sparsely-populated table from
104/// spinning the inner scan forever.
105pub(crate) fn sample_round(store: &mut Store, samples: usize, now: u64) -> (u32, u32) {
106    let cap = store.map.capacity();
107    if cap == 0 || store.map.is_empty() {
108        return (0, 0);
109    }
110    // Random start derived from the access-ordinal clock; Fibonacci-hash
111    // multiplier shifts the sampling window every call so we don't re-visit
112    // the same bucket range twice in a row. (No-quality PRNG needed for
113    // sampling, just want to spread starting positions.)
114    store.clock_counter = store.clock_counter.wrapping_add(1);
115    let start = (store.clock_counter.wrapping_mul(0x9E37_79B9_7F4A_7C15) as usize) % cap;
116    let (sampled, victims) = collect_victims(store, samples, now, start);
117    let expired = victims.len() as u32;
118    for k in &victims {
119        store.note_expired(k);
120        store.remove_entry(k);
121    }
122    // Active-expire-driven removals are still expirations from the shard's
123    // perspective — surface them under the same counter `MEMORY STATS` /
124    // `INFO memory` already exposes.
125    if expired > 0 {
126        store.expired_keys_total = store.expired_keys_total.saturating_add(u64::from(expired));
127    }
128    (sampled, expired)
129}
130
131/// The sampling walk of [`sample_round`]: visit up to `8 * samples` buckets
132/// from `start`, sample up to `samples` TTL-bearing keys, and return
133/// `(sampled, past-deadline victim keys)`.
134///
135/// Single-pass walk from `start`, bounded in *visited entries*, not just
136/// in TTL-bearing samples: without the bound, a keyspace with few (or
137/// zero) TTL'd keys made every round walk to the end of the table
138/// looking for them — measured at 6 % of server CPU on a 300k-key
139/// TTL-free shard under a pinned 8-shard profile, for a reaper
140/// with nothing to reap. With it, a TTL-free round costs O(samples)
141/// buckets; sparse-TTL keyspaces sample fewer keys per round and rely
142/// on the rotating random start (plus lazy expiry) for coverage —
143/// the same time-boxing trade Redis's activeExpireCycle makes.
144fn collect_victims(store: &Store, samples: usize, now: u64, start: usize) -> (u32, Vec<Vec<u8>>) {
145    let mut victims: Vec<Vec<u8>> = Vec::with_capacity(samples);
146    let mut sampled = 0u32;
147    let visit_cap = samples.saturating_mul(8);
148    let mut visited = 0usize;
149    for (k, e) in store.map.iter_from_bucket(start) {
150        visited += 1;
151        if sampled as usize >= samples || visited > visit_cap {
152            break;
153        }
154        let Some(deadline_ns) = e.expire_at_ns else {
155            continue;
156        };
157        sampled += 1;
158        if deadline_ns.get() <= now {
159            victims.push(k.to_vec());
160        }
161    }
162    (sampled, victims)
163}
164
165impl Store {
166    /// Run up to `max_rounds` of active-expiry sampling against this shard.
167    ///
168    /// Per round: sample `samples_per_round` TTL-bearing keys at random and
169    /// drop any whose deadline has passed. Stop early as soon as the
170    /// in-batch expire-rate drops below 25 % (Redis's `activeExpireCycle`
171    /// continuation threshold) — that's the signal the keyspace doesn't
172    /// have a "thick band" of expired keys to clean up right now.
173    ///
174    /// Cost when there are no TTL-bearing keys at all: one map-emptiness
175    /// check + a single bucket-iter probe per round. Designed so the active
176    /// reaper is never a tax on TTL-free workloads.
177    ///
178    /// ```
179    /// use core::time::Duration;
180    /// use kevy_store::{SetCondition, Store};
181    /// let mut s = Store::new();
182    /// s.set(b"k", b"v".to_vec(), Some(Duration::from_millis(1)), SetCondition::Always);
183    /// s.set(b"keep", b"v".to_vec(), None, SetCondition::Always);
184    /// for _ in 0..500 {
185    ///     s.tick_expire(20, 16);
186    ///     if s.dbsize() == 1 {
187    ///         break;
188    ///     }
189    ///     std::thread::sleep(Duration::from_millis(2));
190    /// }
191    /// assert_eq!(s.dbsize(), 1);
192    /// assert_eq!(s.expired_keys_total(), 1);
193    /// ```
194    pub fn tick_expire(&mut self, samples_per_round: usize, max_rounds: u32) -> ExpireStats {
195        // Refresh the coarse cached clock every tick (the read path's lazy
196        // expiry compares against it) — even when there's nothing to reap.
197        self.refresh_clock();
198        // Skip the sampling loop entirely when no key
199        // carries a TTL. `expires` is the O(1)-maintained count of
200        // TTL-bearing keys (incremented/decremented in `adjust_expires`).
201        // The standard redis-benchmark workload sets no TTLs, so
202        // `expires == 0` is the common case — saving up to `max_rounds *
203        // samples_per_round` probe lookups per tick (~256 at the default
204        // 16×16 budget). For TTL-bearing workloads (cache patterns) this
205        // adds one comparison; the bigger "splay / skip-list" reaper
206        // structure that the task entry mentioned would only beat the
207        // current random-sample algorithm at very high TTL fractions,
208        // and is left as a future workload-driven follow-up.
209        if samples_per_round == 0 || max_rounds == 0 || self.map.is_empty() || self.expires == 0 {
210            return ExpireStats::default();
211        }
212        self.run_expire_rounds(samples_per_round, max_rounds)
213    }
214
215    /// The round loop of [`Self::tick_expire`]: run [`sample_round`] up to
216    /// `max_rounds` times, stopping early on the 25 % continuation gate or
217    /// after 3 consecutive zero-sample rounds.
218    fn run_expire_rounds(&mut self, samples_per_round: usize, max_rounds: u32) -> ExpireStats {
219        let now = now_ns();
220        let mut total_sampled = 0u32;
221        let mut total_expired = 0u32;
222        let mut rounds = 0u32;
223        // Single-pass sample_round can return sampled=0 when the random
224        // start lands in an empty bucket region (sparse tables / unlucky
225        // starts). Allow 3 consecutive zero-sample rounds before declaring
226        // the keyspace TTL-free this tick, so a small table doesn't miss
227        // its expired keys for several ticks.
228        let mut consecutive_zero = 0u32;
229        for _ in 0..max_rounds {
230            let (sampled, expired) = sample_round(self, samples_per_round, now);
231            rounds += 1;
232            total_sampled = total_sampled.saturating_add(sampled);
233            total_expired = total_expired.saturating_add(expired);
234            if sampled == 0 {
235                consecutive_zero += 1;
236                if consecutive_zero >= 3 {
237                    break;
238                }
239                continue;
240            }
241            consecutive_zero = 0;
242            // Continuation gate: only push another round if THIS round was
243            // expiry-heavy. A round that finds nothing expired-enough exits.
244            if expired * 100 < sampled * EXPIRE_RATE_CONTINUATION {
245                break;
246            }
247        }
248        ExpireStats { sampled: total_sampled, expired: total_expired, rounds }
249    }
250
251    /// Total keys expired (by lazy reap OR active reaper). Surfaced via
252    /// `INFO keyspace` and `MEMORY STATS` once those grow the field.
253    ///
254    /// ```
255    /// use core::time::Duration;
256    /// use kevy_store::{SetCondition, Store};
257    /// let mut s = Store::new();
258    /// s.set(b"k", b"v".to_vec(), Some(Duration::from_millis(1)), SetCondition::Always);
259    /// while s.get(b"k")?.is_some() {
260    ///     std::thread::sleep(Duration::from_millis(1));
261    /// }
262    /// // the read that found it due removed it, and that counts too
263    /// assert_eq!(s.expired_keys_total(), 1);
264    /// # Ok::<(), kevy_store::StoreError>(())
265    /// ```
266    #[inline]
267    pub fn expired_keys_total(&self) -> u64 {
268        self.expired_keys_total
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::value::SmallBytes;
276    use core::time::Duration;
277
278    #[test]
279    fn tick_expire_drops_past_deadline() {
280        let mut s = Store::new();
281        s.set(b"k1", b"v".to_vec(), Some(Duration::from_millis(1)), crate::SetCondition::Always);
282        s.set(b"k2", b"v".to_vec(), Some(Duration::from_millis(1)), crate::SetCondition::Always);
283        s.set(b"perm", b"v".to_vec(), None, crate::SetCondition::Always);
284        // Two flake sources, both observed on virtualized CI runners:
285        // a single tick may legitimately miss a key (the sampling walk is
286        // time-boxed with a rotating start — the a635d65 trade; coverage
287        // comes from repeated ticks), and on a starved macOS VM the
288        // monotonic clock (`Instant`, mach_absolute_time) can advance far
289        // slower than the wall-clock `sleep`, so a 1 ms deadline may not
290        // have passed yet. Sleep-and-tick until converged (bounded), like
291        // the production reaper drives it — the eventual contract.
292        for _ in 0..500 {
293            s.tick_expire(20, 16);
294            if s.dbsize() == 1 {
295                break;
296            }
297            std::thread::sleep(Duration::from_millis(10));
298        }
299        assert_eq!(s.dbsize(), 1, "perm survives, both TTL'd keys reaped");
300        assert!(s.expired_keys_total() >= 2);
301    }
302
303    #[test]
304    fn tick_expire_no_op_on_fresh_ttls() {
305        let mut s = Store::new();
306        s.set(b"k1", b"v".to_vec(), Some(Duration::from_hours(1)), crate::SetCondition::Always);
307        s.set(b"k2", b"v".to_vec(), Some(Duration::from_hours(1)), crate::SetCondition::Always);
308        let stats = s.tick_expire(20, 16);
309        assert_eq!(stats.expired, 0, "no fresh TTL should expire");
310        // sampled may be 0..=2 depending on how many our walk hit
311        assert_eq!(s.dbsize(), 2);
312    }
313
314    #[test]
315    fn tick_expire_no_op_on_ttl_free_keyspace() {
316        let mut s = Store::new();
317        for i in 0..50 {
318            s.set(format!("k{i}").as_bytes(), b"v".to_vec(), None, crate::SetCondition::Always);
319        }
320        let stats = s.tick_expire(20, 16);
321        assert_eq!(stats.expired, 0);
322        assert_eq!(stats.sampled, 0, "no TTL'd keys ⇒ nothing sampled");
323        // Loop tolerates up to 3 consecutive zero-sample rounds (the
324        // unlucky-start guard) before exiting, so a TTL-free keyspace
325        // costs at most 3 cheap bucket-iter passes per tick.
326        assert!(stats.rounds <= 3, "got {}", stats.rounds);
327    }
328
329    #[test]
330    fn tick_expire_zero_args_short_circuit() {
331        let mut s = Store::new();
332        s.set(b"k", b"v".to_vec(), Some(Duration::from_millis(1)), crate::SetCondition::Always);
333        std::thread::sleep(Duration::from_millis(5));
334        assert_eq!(s.tick_expire(0, 16), ExpireStats::default());
335        assert_eq!(s.tick_expire(20, 0), ExpireStats::default());
336        // store still has the expired key (active reaper disabled).
337        assert_eq!(s.dbsize(), 1);
338    }
339
340    #[test]
341    fn tick_expire_loops_on_heavy_batch() {
342        let mut s = Store::new();
343        // 40 TTL'd keys (all expired) + 1 perm. A single tick samples from
344        // a random bucket window, so we may need several ticks for full
345        // coverage of a 40-key keyspace — that matches how `activeExpire`
346        // converges in production (10 ticks/sec until everything's cleaned).
347        for i in 0..40 {
348            s.set(
349                format!("k{i}").as_bytes(),
350                b"v".to_vec(),
351                Some(Duration::from_millis(1)),
352                crate::SetCondition::Always,
353            );
354        }
355        s.set(b"perm", b"v".to_vec(), None, crate::SetCondition::Always);
356        // Sleep-and-tick until converged: on a starved CI VM the monotonic
357        // clock can lag the wall-clock sleep, so a fixed pre-sleep + a
358        // bounded dry tick loop under-counts (see
359        // tick_expire_drops_past_deadline).
360        let mut total_expired = 0u32;
361        let mut any_round_ge_2 = false;
362        for _ in 0..500 {
363            let stats = s.tick_expire(20, 16);
364            total_expired += stats.expired;
365            if stats.rounds >= 2 {
366                any_round_ge_2 = true;
367            }
368            if s.dbsize() == 1 {
369                break;
370            }
371            std::thread::sleep(Duration::from_millis(10));
372        }
373        assert_eq!(total_expired, 40);
374        assert!(any_round_ge_2, "at least one heavy-batch tick should loop");
375        assert_eq!(s.dbsize(), 1);
376        let _ = SmallBytes::from_slice(b"k0"); // touch SmallBytes import
377    }
378}