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