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::hash::Hash;
43use crate::keyspace::Keyspace;
44use crate::value;
45use yo_common::Addr;
46
47/// Keys with a deadline that one round looks at before it decides.
48///
49/// Redis's `ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP`, and the same twenty. It is the
50/// sample size the quarter rule is judged on, so it wants to be small enough
51/// that a round is cheap and large enough that the ratio means something. Twenty
52/// gives the rule a resolution of five percent, which is finer than the quarter
53/// it is compared against.
54const PER_ROUND: usize = 20;
55
56/// What one call to the cycle did.
57///
58/// Three numbers rather than one, because they answer different questions. The
59/// caller charges its budget against `examined`, a test asserts on `expired`, and
60/// `volatile` is what says whether a cheap sweep found nothing because there was
61/// nothing dead or because it never got near a key that could be.
62#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
63pub struct Cycle {
64 /// Keys the sample walked past, whether or not they had a deadline.
65 pub examined: usize,
66 /// Of those, how many carried a deadline.
67 pub volatile: usize,
68 /// Of those, how many were past it and were dropped.
69 pub expired: usize,
70}
71
72impl Keyspace {
73 /// Sweep dead keys until the budget runs out or the sweep stops paying.
74 ///
75 /// `budget` is how many keys this is allowed to look at, and it is a ceiling
76 /// and not a target: a database with nothing dead in it returns after one
77 /// round having spent a fraction of it, and a database with nothing volatile
78 /// in it returns having spent none of it at all.
79 ///
80 /// Safe to call on any database at any time. It takes only keys that are past
81 /// their deadline, which are keys no client can see, so nothing observable
82 /// changes except the memory going back and `INFO stats` counting the
83 /// reclaim. Redis counts its cycle into `expired_keys` alongside lazy expiry
84 /// and so does this.
85 pub fn expire_cycle(&mut self, budget: usize) -> Cycle {
86 let mut c = Cycle::default();
87 // The point of the count. A database where no key has a deadline is the
88 // common one, and this is where it finds that out, for one comparison
89 // rather than for a walk of a segment that was never going to hold
90 // anything worth taking.
91 if budget == 0 || self.expires() == 0 {
92 return c;
93 }
94 let now = self.clock.now_ms();
95 loop {
96 let round = self.sweep_round(now, budget - c.examined, &mut c);
97 // Redis's quarter rule, over the keys that could have expired rather
98 // than over every key looked at. Both of the stops below matter: the
99 // budget bounds the worst case and the ratio ends a sweep that has
100 // stopped finding anything, which is what keeps an idle server from
101 // spending its whole slice on a database that is already clean.
102 if c.examined >= budget || round.expired * 4 <= round.volatile {
103 return c;
104 }
105 }
106 }
107
108 /// One round of twenty, which is a draw and then the deletions it found.
109 ///
110 /// The two halves are separate because the sample holds the map still: it
111 /// hands out an address and a borrow, and deleting is a write. So the round
112 /// writes down what it found, lets go, and then drops. The addresses survive
113 /// that gap because freeing a record only moves a counter, and the one thing
114 /// that does move records is compaction, which runs quiesced and cannot be
115 /// underneath this.
116 fn sweep_round(&mut self, now: u64, budget: usize, c: &mut Cycle) -> Cycle {
117 let mut found = [Addr::NONE; PER_ROUND];
118 let mut n = 0usize;
119 let mut round = Cycle::default();
120 let r = self.rng.next_u64();
121 self.map.sample_tagged(r, |_key, rec, addr| {
122 round.examined += 1;
123 // Every key the marked index holds carries a deadline, so this is
124 // not a filter any more and the two counts move together. It stays
125 // because it is cheap, it is read off a record that is already in
126 // cache, and a divergence between them is the marked index having
127 // gone wrong, which is the one bug this whole arrangement can have.
128 debug_assert!(value::has_expiry(rec), "a marked key with no deadline");
129 if value::has_expiry(rec) {
130 round.volatile += 1;
131 if value::is_expired(rec, now) {
132 found[n] = addr;
133 n += 1;
134 }
135 }
136 round.examined < budget && round.volatile < PER_ROUND && n < PER_ROUND
137 });
138 c.examined += round.examined;
139 c.volatile += round.volatile;
140 for addr in &found[..n] {
141 // Through the scratch buffer, the same way eviction does it, because
142 // the key has to outlive the borrow that found its address and this
143 // runs in a loop when it runs at all.
144 let mut buf = core::mem::take(&mut self.scratch);
145 buf.clear();
146 buf.extend_from_slice(self.map.entry_at(*addr).0);
147 let gone = self.reaped(&buf);
148 self.scratch = buf;
149 if gone {
150 c.expired += 1;
151 round.expired += 1;
152 }
153 }
154 round
155 }
156
157 /// Sweep hash fields that are past their deadline, looking at no more than
158 /// `budget` hashes, and answer how many it looked at.
159 ///
160 /// The other cycle, and it is a different shape because the thing it hunts
161 /// is not in a record. A field deadline lives inside the hash body, so the
162 /// marked index above cannot see one and the sample it draws would never
163 /// offer a hash that has fields to lose but a key with no deadline of its
164 /// own, which is the usual way the `HEXPIRE` family is used. What this draws
165 /// from instead is a list the keyspace keeps of the keys whose hashes have
166 /// ever taken a field deadline, and the list is short because most servers
167 /// have none.
168 ///
169 /// A straight walk with a cursor rather than a random sample, for the same
170 /// reason: the list holds only candidates, so there is nothing for a sample
171 /// to filter out and going round it in order gets to every one of them in
172 /// bounded time. A hash whose earliest deadline has not passed costs a load
173 /// and a comparison, which is what makes the whole list affordable to walk.
174 ///
175 /// Why a server needs this at all is the question [`Keyspace::expire_cycle`]
176 /// answers for keys, and the answer for fields has a second half. Memory is
177 /// the first: a hash that nobody reads again holds every field it was told
178 /// to drop. The second is that the events are observable. A client watching
179 /// `hexpired` on a key hears about the field within a tick of its deadline
180 /// on a real server, whether or not anybody touches the hash, and a server
181 /// that only reaped lazily would go quiet until the next command arrived.
182 pub fn field_expire_cycle(&mut self, budget: usize) -> usize {
183 // The point of the list, the same way the count of keys with deadlines
184 // is the point of the one above.
185 if budget == 0 || self.field_deadlines.is_empty() {
186 return 0;
187 }
188 let now = self.clock.now_ms();
189 // One pass round the list at most, however much budget is left over. A
190 // server with three hashes on the list and a big budget would otherwise
191 // spend the whole of it going round those three again and again, and the
192 // second look at a name in the same tick can only say what the first one
193 // said.
194 let mut left = budget.min(self.field_deadlines.len());
195 let mut looked = 0;
196 while left > 0 && !self.field_deadlines.is_empty() {
197 left -= 1;
198 if self.field_at >= self.field_deadlines.len() {
199 self.field_at = 0;
200 }
201 looked += 1;
202 if self.field_look(self.field_at, now) {
203 self.field_at += 1;
204 } else {
205 // The name came off, so whatever was moved into its place is
206 // what the cursor is already pointing at.
207 self.field_deadlines.swap_remove(self.field_at);
208 }
209 }
210 looked
211 }
212
213 /// Look at one name on the list, and say whether it is worth keeping there.
214 ///
215 /// A name stays for as long as the key under it is a hash. It does not have
216 /// to have a deadline on anything right now: a hash whose only deadline was
217 /// taken off with `HPERSIST` can be given another one without this list
218 /// hearing about it, so dropping the name then would be dropping it for
219 /// good. What ends a name is the key going, or something else taking it,
220 /// which is when the hash this was about no longer exists to sweep.
221 fn field_look(&mut self, at: usize, now: u64) -> bool {
222 let key = &self.field_deadlines[at];
223 let Some(rec) = self.map.get(key) else {
224 return false;
225 };
226 let meta = value::Meta::from_byte(rec[0]);
227 if meta.kind() != value::Kind::Hash {
228 return false;
229 }
230 // A hash whose body is on the device is left alone rather than brought
231 // back for this. Reading a hash off the file to find out whether one of
232 // its fields is a second late is the whole cost of a fault spent on
233 // something no client is waiting for, and the next command that promotes
234 // it reaps the field on the way past.
235 if meta.is_cold() {
236 return true;
237 }
238 let slot = value::slot(rec);
239 // The cheap question first, and it is the one nearly every look answers.
240 // A hash with no deadline that has passed is a load of the bound it
241 // carries and a comparison against the clock.
242 match self.hashes.get(slot).map(Hash::soonest_deadline) {
243 Some(Some(soonest)) if soonest <= now => {}
244 _ => return true,
245 }
246 // Through the scratch buffer, the same way the sweep above does it,
247 // because the reap needs the key by name and the name is borrowed from
248 // the list this is walking.
249 let mut buf = core::mem::take(&mut self.scratch);
250 buf.clear();
251 buf.extend_from_slice(&self.field_deadlines[at]);
252 let gone = self.reap_fields(&buf, slot, now, true);
253 self.scratch = buf;
254 !gone
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::clock::Clock;
262 use crate::many;
263 use crate::ttl::Cond;
264
265 fn db() -> Keyspace {
266 Keyspace::with_clock(Clock::fixed(1_000))
267 }
268
269 #[test]
270 fn a_database_with_no_deadlines_anywhere_is_not_swept() {
271 let n = many(2_000u32);
272 let mut d = db();
273 for i in 0..n {
274 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
275 }
276 let c = d.expire_cycle(4096);
277 assert_eq!(c, Cycle::default(), "it should not have drawn anything");
278 assert_eq!(d.len(), n as usize);
279 }
280
281 #[test]
282 fn dead_keys_nobody_reads_are_reclaimed() {
283 // Half with a deadline and half without either way, which is the mix
284 // the sweep has to pick its way through.
285 let n = many(2_000u32);
286 let mut d = db();
287 for i in 0..n {
288 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
289 .expect("room");
290 }
291 for i in 0..n {
292 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
293 }
294 assert_eq!(d.expires(), n as usize);
295 d.clock().advance(200);
296 assert_eq!(
297 d.len(),
298 n as usize * 2,
299 "and nothing has read them, so they are all still there"
300 );
301
302 // The sweep is bounded, so this is a loop the way a shard loop is a loop.
303 let mut spent = 0;
304 for _ in 0..500 {
305 let c = d.expire_cycle(4096);
306 spent += c.examined;
307 if d.expires() == 0 {
308 break;
309 }
310 }
311 assert_eq!(d.expires(), 0, "spent {spent} looks and did not finish");
312 assert_eq!(
313 d.len(),
314 n as usize,
315 "the keys with no deadline are untouched"
316 );
317 assert_eq!(d.expired_keys(), u64::from(n));
318 for i in 0..n {
319 assert!(d.exists(format!("k{i}").as_bytes()));
320 }
321 }
322
323 /// The reason the second index exists, as a number a test can hold.
324 ///
325 /// Ten thousand keys, a hundred of them with a deadline that has passed. The
326 /// sweep has to reclaim all hundred, and the thing to watch is what it spent
327 /// getting there: every key it looks at comes off the marked index, so it
328 /// looks at about a hundred keys and not about ten thousand. Off the main
329 /// index it would have had to walk a hundred keys for every one it wanted.
330 ///
331 /// It comes out at exactly a hundred, because a round starts at a random
332 /// slot of the marked index and then walks forward, so one round covers all
333 /// of them. The bound is twice that rather than exactly that, because the
334 /// number a test should hold is the shape and not the arithmetic.
335 #[test]
336 fn a_sweep_only_looks_at_keys_that_could_have_expired() {
337 // Only the permanent keys come down under Miri. The hundred with
338 // deadlines stay, because the bound below is written against them and
339 // a round that overshoots by the rest of a bucket would eat a smaller
340 // one.
341 let keys = many(10_000u32);
342 let mut d = db();
343 for i in 0..keys {
344 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
345 }
346 for i in 0..100u32 {
347 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
348 .expect("room");
349 }
350 assert_eq!(d.expires(), 100);
351 d.clock().advance(200);
352
353 let mut spent = 0;
354 for _ in 0..100 {
355 let c = d.expire_cycle(4096);
356 spent += c.examined;
357 assert_eq!(
358 c.examined, c.volatile,
359 "it looked at a key with no deadline"
360 );
361 if d.expires() == 0 {
362 break;
363 }
364 }
365 assert_eq!(d.expires(), 0);
366 assert_eq!(d.len(), keys as usize, "and it took none of the others");
367 assert!(
368 spent <= 200,
369 "spent {spent} looks to reclaim a hundred keys"
370 );
371 }
372
373 #[test]
374 fn a_key_whose_deadline_has_not_passed_is_left_alone() {
375 let mut d = db();
376 let now = d.clock().now_ms();
377 for i in 0..500u32 {
378 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
379 d.set_expiry(format!("k{i}").as_bytes(), Some(now + 900_000));
380 }
381 for _ in 0..20 {
382 let c = d.expire_cycle(4096);
383 assert_eq!(c.expired, 0, "it took a key that was still live");
384 }
385 assert_eq!(d.len(), 500);
386 }
387
388 #[test]
389 fn the_budget_is_a_ceiling_on_what_a_sweep_looks_at() {
390 let n = many(5_000u32);
391 let mut d = db();
392 for i in 0..n {
393 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
394 .expect("room");
395 }
396 d.clock().advance(200);
397 // A budget of one still ends, and it ends having drawn one round rather
398 // than having walked the database. One round can overshoot by the rest of
399 // a bucket, which is the whole point of charging afterwards instead of
400 // asking before every entry.
401 let c = d.expire_cycle(1);
402 assert!(c.examined <= 8, "one round looked at {} keys", c.examined);
403 let left = n as usize - n as usize / 50;
404 assert!(d.expires() > left, "and it barely touched the database");
405 }
406
407 /// The ratio has to be over the keys that could expire and not over every key
408 /// looked at, or a database that is one percent volatile can never clear the
409 /// bar and its dead keys are never swept however many there are.
410 #[test]
411 fn a_mostly_permanent_database_still_gets_its_dead_keys_back() {
412 // Both counts come down together, because one percent volatile is the
413 // thing being claimed and not the ten thousand it is one percent of.
414 let (keys, dying) = (many(10_000u32), many(100u32));
415 let mut d = db();
416 for i in 0..keys {
417 d.set_plain(format!("k{i}").as_bytes(), b"v").expect("room");
418 }
419 for i in 0..dying {
420 d.psetex(format!("d{i}").as_bytes(), 100, b"v")
421 .expect("room");
422 }
423 d.clock().advance(200);
424 let mut spent = 0;
425 for _ in 0..2_000 {
426 spent += d.expire_cycle(4096).examined;
427 if d.expires() == 0 {
428 break;
429 }
430 }
431 assert_eq!(d.expires(), 0, "one percent volatile, spent {spent} looks");
432 assert_eq!(d.len(), keys as usize);
433 }
434
435 #[test]
436 fn the_cycle_leaves_collections_and_their_bodies_correct() {
437 let mut d = db();
438 let now = d.clock().now_ms();
439 for i in 0..200u32 {
440 let k = format!("s{i}");
441 d.sadd(k.as_bytes(), [b"a".as_slice(), b"b".as_slice()].into_iter())
442 .expect("room");
443 d.set_expiry(k.as_bytes(), Some(now + 100));
444 }
445 d.sadd(b"keep", [b"a".as_slice()].into_iter())
446 .expect("room");
447 d.clock().advance(200);
448 for _ in 0..500 {
449 d.expire_cycle(4096);
450 if d.expires() == 0 {
451 break;
452 }
453 }
454 assert_eq!(d.len(), 1);
455 assert_eq!(d.scard(b"keep"), Ok(1));
456 // The bodies went back with the records rather than being left behind in
457 // their slabs, which a length check on the keyspace alone would not see.
458 assert_eq!(d.bodies, 1);
459 }
460
461 /// Give a hash a field deadline and let it pass with nobody reading the
462 /// hash. The field has to go anyway, because that is what the field cycle is
463 /// for, and the counters have to say it was the cycle that took it.
464 #[test]
465 fn a_field_nobody_reads_goes_on_its_own() {
466 let mut d = db();
467 let now = d.clock().now_ms();
468 d.hset(b"h", [(b"a".as_slice(), b"1".as_slice())].into_iter())
469 .expect("room");
470 d.hset(b"h", [(b"b".as_slice(), b"2".as_slice())].into_iter())
471 .expect("room");
472 d.hexpire(
473 b"h",
474 now + 100,
475 Cond::Always,
476 [b"a".as_slice()].into_iter(),
477 |_| {},
478 )
479 .expect("room");
480 d.clock().advance(200);
481 assert_eq!(d.field_expire_cycle(16), 1, "one hash to look at");
482 assert_eq!(d.hlen(b"h"), Ok(1), "and the other field is still there");
483 assert_eq!(d.expired_fields(), 1);
484 assert_eq!(d.expired_fields_active(), 1);
485 assert_eq!(d.expired_keys(), 0, "the key itself had no deadline");
486 }
487
488 /// And when it was the only field, the key goes with it, since an empty hash
489 /// is not a key.
490 #[test]
491 fn the_last_field_takes_the_key_with_it() {
492 let mut d = db();
493 let now = d.clock().now_ms();
494 d.hset(b"h", [(b"a".as_slice(), b"1".as_slice())].into_iter())
495 .expect("room");
496 d.hexpire(
497 b"h",
498 now + 100,
499 Cond::Always,
500 [b"a".as_slice()].into_iter(),
501 |_| {},
502 )
503 .expect("room");
504 d.clock().advance(200);
505 d.field_expire_cycle(16);
506 assert!(!d.exists(b"h"));
507 assert_eq!(d.len(), 0);
508 assert_eq!(d.bodies, 0, "and the body went back to its slab");
509 // The name came off the list with the key, so a second sweep has nothing
510 // to look at rather than a dangling name to look up.
511 assert_eq!(d.field_expire_cycle(16), 0);
512 }
513
514 /// The list only holds hashes that took a deadline at some point, so a
515 /// database full of ordinary hashes costs the cycle nothing.
516 #[test]
517 fn hashes_with_no_field_deadlines_are_not_swept() {
518 let mut d = db();
519 for i in 0..100u32 {
520 d.hset(
521 format!("h{i}").as_bytes(),
522 [(b"a".as_slice(), b"1".as_slice())].into_iter(),
523 )
524 .expect("room");
525 }
526 assert_eq!(d.field_expire_cycle(4096), 0);
527 }
528
529 /// A name whose key is gone, or is no longer a hash, comes off the list the
530 /// first time the cycle reaches it. Nothing else prunes it, because nothing
531 /// else knows the list is there.
532 #[test]
533 fn a_name_that_is_no_longer_a_hash_comes_off_the_list() {
534 let mut d = db();
535 let now = d.clock().now_ms();
536 for i in 0..3u32 {
537 let k = format!("h{i}");
538 d.hset(
539 k.as_bytes(),
540 [(b"a".as_slice(), b"1".as_slice())].into_iter(),
541 )
542 .expect("room");
543 d.hexpire(
544 k.as_bytes(),
545 now + 100_000,
546 Cond::Always,
547 [b"a".as_slice()].into_iter(),
548 |_| {},
549 )
550 .expect("room");
551 }
552 d.del(b"h0");
553 d.set_plain(b"h1", b"v").expect("room");
554 // Three looks is one round of the list, and two of the three names have
555 // nothing behind them any more.
556 d.field_expire_cycle(3);
557 assert_eq!(d.field_deadlines.len(), 1);
558 assert_eq!(d.field_deadlines[0].as_ref(), b"h2");
559 }
560
561 /// Setting the same deadline over and over on the same hash puts its name on
562 /// the list once, which is the thing that would otherwise grow without
563 /// bound on a key a client keeps refreshing.
564 #[test]
565 fn a_hash_is_only_listed_once_however_often_it_is_touched() {
566 let mut d = db();
567 let now = d.clock().now_ms();
568 d.hset(b"h", [(b"a".as_slice(), b"1".as_slice())].into_iter())
569 .expect("room");
570 for i in 0..50u64 {
571 d.hexpire(
572 b"h",
573 now + 100_000 + i,
574 Cond::Always,
575 [b"a".as_slice()].into_iter(),
576 |_| {},
577 )
578 .expect("room");
579 }
580 assert_eq!(d.field_deadlines.len(), 1);
581 }
582}