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}