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