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}