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::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}