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