Skip to main content

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}