yo_kv/expiry.rs
1//! The active expiry cycle, which is what reclaims a key nobody asks for again.
2//!
3//! Lazy expiry answers the correctness question on its own: a key past its
4//! deadline is not returned to any client, because every read reaps it on the way
5//! past. What it does not answer is the memory question. A cache that writes ten
6//! million keys with a one hour deadline and then never reads them again holds
7//! all ten million of them forever under lazy expiry alone, because nothing ever
8//! goes past them. That is the whole reason Redis runs a cycle, and `14` section
9//! 1 asks for the same thing here.
10//!
11//! # A budget in keys looked at
12//!
13//! Redis samples twenty keys from its expires dictionary, deletes the ones that
14//! are past, and goes round again while more than a quarter of what it sampled
15//! was dead. The rule adapts: a database full of dead keys gets swept hard and a
16//! database with a few gets one cheap look.
17//!
18//! The sample comes off a second index of just the keys that carry a deadline,
19//! the way Redis's comes off `db->expires`. That index lives in the map, because
20//! the map is the only thing that knows where a record is and the only thing
21//! that moves one, and it is described where it is kept. What it buys here is
22//! that every key this looks at is a key that could have expired, so the quarter
23//! rule is Redis's quarter over Redis's denominator and a database where one key
24//! in a million is volatile costs the same per round as one where all of them
25//! are.
26//!
27//! This used to sample the main index and skip most of what it found, and the
28//! shape of that is worth remembering, because it is what the budget still
29//! protects against. A sweep over a mostly non volatile database spent its whole
30//! budget walking past keys with nothing wrong with them, and the ratio it then
31//! judged had to be taken over the volatile keys alone rather than over
32//! everything looked at, because a quarter of every key sampled is a bar that a
33//! database which is one percent volatile can never clear however much dead
34//! memory is sitting in it. Both denominators are the same now, and the code
35//! still counts them separately because the difference between them is exactly
36//! the thing a test should be able to see going wrong.
37//!
38//! The common case is still the one that costs nothing: a count of the keys
39//! carrying a deadline sits in the keyspace, and a zero there ends this before it
40//! draws anything.
41
42use crate::keyspace::Keyspace;
43use crate::value;
44use yo_common::Addr;
45
46/// Keys with a deadline that one round looks at before it decides.
47///
48/// Redis's `ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP`, and the same twenty. It is the
49/// sample size the quarter rule is judged on, so it wants to be small enough
50/// that a round is cheap and large enough that the ratio means something. Twenty
51/// gives the rule a resolution of five percent, which is finer than the quarter
52/// it is compared against.
53const PER_ROUND: usize = 20;
54
55/// What one call to the cycle did.
56///
57/// Three numbers rather than one, because they answer different questions. The
58/// caller charges its budget against `examined`, a test asserts on `expired`, and
59/// `volatile` is what says whether a cheap sweep found nothing because there was
60/// nothing dead or because it never got near a key that could be.
61#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub struct Cycle {
63 /// Keys the sample walked past, whether or not they had a deadline.
64 pub examined: usize,
65 /// Of those, how many carried a deadline.
66 pub volatile: usize,
67 /// Of those, how many were past it and were dropped.
68 pub expired: usize,
69}
70
71impl Keyspace {
72 /// Sweep dead keys until the budget runs out or the sweep stops paying.
73 ///
74 /// `budget` is how many keys this is allowed to look at, and it is a ceiling
75 /// and not a target: a database with nothing dead in it returns after one
76 /// round having spent a fraction of it, and a database with nothing volatile
77 /// in it returns having spent none of it at all.
78 ///
79 /// Safe to call on any database at any time. It takes only keys that are past
80 /// their deadline, which are keys no client can see, so nothing observable
81 /// changes except the memory going back and `INFO stats` counting the
82 /// reclaim. Redis counts its cycle into `expired_keys` alongside lazy expiry
83 /// and so does this.
84 pub fn expire_cycle(&mut self, budget: usize) -> Cycle {
85 let mut c = Cycle::default();
86 // The point of the count. A database where no key has a deadline is the
87 // common one, and this is where it finds that out, for one comparison
88 // rather than for a walk of a segment that was never going to hold
89 // anything worth taking.
90 if budget == 0 || self.expires() == 0 {
91 return c;
92 }
93 let now = self.clock.now_ms();
94 loop {
95 let round = self.sweep_round(now, budget - c.examined, &mut c);
96 // Redis's quarter rule, over the keys that could have expired rather
97 // than over every key looked at. Both of the stops below matter: the
98 // budget bounds the worst case and the ratio ends a sweep that has
99 // stopped finding anything, which is what keeps an idle server from
100 // spending its whole slice on a database that is already clean.
101 if c.examined >= budget || round.expired * 4 <= round.volatile {
102 return c;
103 }
104 }
105 }
106
107 /// One round of twenty, which is a draw and then the deletions it found.
108 ///
109 /// The two halves are separate because the sample holds the map still: it
110 /// hands out an address and a borrow, and deleting is a write. So the round
111 /// writes down what it found, lets go, and then drops. The addresses survive
112 /// that gap because freeing a record only moves a counter, and the one thing
113 /// that does move records is compaction, which runs quiesced and cannot be
114 /// underneath this.
115 fn sweep_round(&mut self, now: u64, budget: usize, c: &mut Cycle) -> Cycle {
116 let mut found = [Addr::NONE; PER_ROUND];
117 let mut n = 0usize;
118 let mut round = Cycle::default();
119 let r = self.rng.next_u64();
120 self.map.sample_tagged(r, |_key, rec, addr| {
121 round.examined += 1;
122 // Every key the marked index holds carries a deadline, so this is
123 // not a filter any more and the two counts move together. It stays
124 // because it is cheap, it is read off a record that is already in
125 // cache, and a divergence between them is the marked index having
126 // gone wrong, which is the one bug this whole arrangement can have.
127 debug_assert!(value::has_expiry(rec), "a marked key with no deadline");
128 if value::has_expiry(rec) {
129 round.volatile += 1;
130 if value::is_expired(rec, now) {
131 found[n] = addr;
132 n += 1;
133 }
134 }
135 round.examined < budget && round.volatile < PER_ROUND && n < PER_ROUND
136 });
137 c.examined += round.examined;
138 c.volatile += round.volatile;
139 for addr in &found[..n] {
140 // Through the scratch buffer, the same way eviction does it, because
141 // the key has to outlive the borrow that found its address and this
142 // runs in a loop when it runs at all.
143 let mut buf = core::mem::take(&mut self.scratch);
144 buf.clear();
145 buf.extend_from_slice(self.map.entry_at(*addr).0);
146 let gone = self.drop_key(&buf);
147 self.scratch = buf;
148 if gone {
149 c.expired += 1;
150 round.expired += 1;
151 self.expired += 1;
152 }
153 }
154 round
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::clock::Clock;
162
163 fn db() -> Keyspace {
164 Keyspace::with_clock(Clock::fixed(1_000))
165 }
166
167 #[test]
168 fn a_database_with_no_deadlines_anywhere_is_not_swept() {
169 let mut d = db();
170 for i in 0..2_000u32 {
171 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
172 }
173 let c = d.expire_cycle(4096);
174 assert_eq!(c, Cycle::default(), "it should not have drawn anything");
175 assert_eq!(d.len(), 2_000);
176 }
177
178 #[test]
179 fn dead_keys_nobody_reads_are_reclaimed() {
180 let mut d = db();
181 for i in 0..2_000u32 {
182 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
183 .expect("room");
184 }
185 for i in 0..2_000u32 {
186 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
187 }
188 assert_eq!(d.expires(), 2_000);
189 d.clock().advance(200);
190 assert_eq!(
191 d.len(),
192 4_000,
193 "and nothing has read them, so they are all still there"
194 );
195
196 // The sweep is bounded, so this is a loop the way a shard loop is a loop.
197 let mut spent = 0;
198 for _ in 0..500 {
199 let c = d.expire_cycle(4096);
200 spent += c.examined;
201 if d.expires() == 0 {
202 break;
203 }
204 }
205 assert_eq!(d.expires(), 0, "spent {spent} looks and did not finish");
206 assert_eq!(d.len(), 2_000, "the keys with no deadline are untouched");
207 assert_eq!(d.expired_keys(), 2_000);
208 for i in 0..2_000u32 {
209 assert!(d.exists(format!("k{i}").as_bytes()));
210 }
211 }
212
213 /// The reason the second index exists, as a number a test can hold.
214 ///
215 /// Ten thousand keys, a hundred of them with a deadline that has passed. The
216 /// sweep has to reclaim all hundred, and the thing to watch is what it spent
217 /// getting there: every key it looks at comes off the marked index, so it
218 /// looks at about a hundred keys and not about ten thousand. Off the main
219 /// index it would have had to walk a hundred keys for every one it wanted.
220 ///
221 /// It comes out at exactly a hundred, because a round starts at a random
222 /// slot of the marked index and then walks forward, so one round covers all
223 /// of them. The bound is twice that rather than exactly that, because the
224 /// number a test should hold is the shape and not the arithmetic.
225 #[test]
226 fn a_sweep_only_looks_at_keys_that_could_have_expired() {
227 let mut d = db();
228 for i in 0..10_000u32 {
229 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
230 }
231 for i in 0..100u32 {
232 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
233 .expect("room");
234 }
235 assert_eq!(d.expires(), 100);
236 d.clock().advance(200);
237
238 let mut spent = 0;
239 for _ in 0..100 {
240 let c = d.expire_cycle(4096);
241 spent += c.examined;
242 assert_eq!(
243 c.examined, c.volatile,
244 "it looked at a key with no deadline"
245 );
246 if d.expires() == 0 {
247 break;
248 }
249 }
250 assert_eq!(d.expires(), 0);
251 assert_eq!(d.len(), 10_000, "and it took none of the others");
252 assert!(
253 spent <= 200,
254 "spent {spent} looks to reclaim a hundred keys"
255 );
256 }
257
258 #[test]
259 fn a_key_whose_deadline_has_not_passed_is_left_alone() {
260 let mut d = db();
261 let now = d.clock().now_ms();
262 for i in 0..500u32 {
263 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
264 d.set_expiry(format!("k{i}").as_bytes(), Some(now + 900_000));
265 }
266 for _ in 0..20 {
267 let c = d.expire_cycle(4096);
268 assert_eq!(c.expired, 0, "it took a key that was still live");
269 }
270 assert_eq!(d.len(), 500);
271 }
272
273 #[test]
274 fn the_budget_is_a_ceiling_on_what_a_sweep_looks_at() {
275 let mut d = db();
276 for i in 0..5_000u32 {
277 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
278 .expect("room");
279 }
280 d.clock().advance(200);
281 // A budget of one still ends, and it ends having drawn one round rather
282 // than having walked the database. One round can overshoot by the rest of
283 // a bucket, which is the whole point of charging afterwards instead of
284 // asking before every entry.
285 let c = d.expire_cycle(1);
286 assert!(c.examined <= 8, "one round looked at {} keys", c.examined);
287 assert!(d.expires() > 4_900, "and it barely touched the database");
288 }
289
290 /// The ratio has to be over the keys that could expire and not over every key
291 /// looked at, or a database that is one percent volatile can never clear the
292 /// bar and its dead keys are never swept however many there are.
293 #[test]
294 fn a_mostly_permanent_database_still_gets_its_dead_keys_back() {
295 let mut d = db();
296 for i in 0..10_000u32 {
297 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
298 }
299 for i in 0..100u32 {
300 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
301 .expect("room");
302 }
303 d.clock().advance(200);
304 let mut spent = 0;
305 for _ in 0..2_000 {
306 spent += d.expire_cycle(4096).examined;
307 if d.expires() == 0 {
308 break;
309 }
310 }
311 assert_eq!(d.expires(), 0, "one percent volatile, spent {spent} looks");
312 assert_eq!(d.len(), 10_000);
313 }
314
315 #[test]
316 fn the_cycle_leaves_collections_and_their_bodies_correct() {
317 let mut d = db();
318 let now = d.clock().now_ms();
319 for i in 0..200u32 {
320 let k = format!("s{i}");
321 d.sadd(k.as_bytes(), [b"a".as_slice(), b"b".as_slice()].into_iter())
322 .expect("room");
323 d.set_expiry(k.as_bytes(), Some(now + 100));
324 }
325 d.sadd(b"keep", [b"a".as_slice()].into_iter())
326 .expect("room");
327 d.clock().advance(200);
328 for _ in 0..500 {
329 d.expire_cycle(4096);
330 if d.expires() == 0 {
331 break;
332 }
333 }
334 assert_eq!(d.len(), 1);
335 assert_eq!(d.scard(b"keep"), Ok(1));
336 // The bodies went back with the records rather than being left behind in
337 // their slabs, which a length check on the keyspace alone would not see.
338 assert_eq!(d.bodies, 1);
339 }
340}