Skip to main content

kevy_map/
scan.rs

1//! Incremental, rehash-tolerant table scanning — the cursor arithmetic
2//! behind a Redis-style `SCAN`.
3//!
4//! [`KevyMap::scan_step`] visits one *home bucket-group* (16 buckets) per
5//! call and returns the next cursor (0 = the sweep is complete). The
6//! cursor walks group indices in **reverse-binary-increment** order —
7//! Redis `dictScan`'s algorithm — masked to the CURRENT table size on
8//! every call. Because the table's capacity is always a power of two and
9//! an entry's home group in a doubled table is its old home group plus
10//! one new high bit, this ordering guarantees: **every key present in
11//! the map for the whole duration of a sweep is visited at least once,
12//! even if the table grows (rehashes) between calls.** Keys may be
13//! visited more than once across a grow; callers must tolerate
14//! duplicates (Redis SCAN has the same contract).
15//!
16//! Displacement: `KevyMap` is open-addressing, so an entry's *stored*
17//! slot is not a pure function of its hash — a rehash can move it
18//! relative to other entries, which would break the guarantee if we
19//! enumerated stored positions. We therefore enumerate by **home**
20//! bucket (`hash & mask`, a pure function of hash and capacity) and,
21//! for each home group, walk the probe run forward collecting entries
22//! whose recomputed home falls in the group. See
23//! [`KevyMap::visit_home_group`] for the run-termination proof.
24
25use kevy_hash::KevyHash;
26
27use crate::map::{EMPTY, GROUP_WIDTH, KevyMap};
28
29impl<K: KevyHash + Eq, V> KevyMap<K, V> {
30    /// Visit one bucket-group's worth of the map and return the next
31    /// cursor. Start a sweep with `cursor = 0`; a returned `0` means the
32    /// sweep is complete. Grows between calls never skip a key that was
33    /// present throughout (see the module docs); shrinks don't exist
34    /// (`KevyMap` never shrinks in place).
35    ///
36    /// `f` is called once per entry whose home group is the cursor's
37    /// group — at most once per entry per call, in unspecified order.
38    ///
39    /// Bounded work: one call touches one 16-bucket home group plus its
40    /// probe-run overhang (short at the 7/8 load factor).
41    /// # Examples
42    ///
43    /// ```
44    /// let mut m = kevy_map::KevyMap::new();
45    /// for i in 0..64u32 { m.insert(i, i); }
46    ///
47    /// // A SCAN-style cursor: bounded work per call, 0 both starts and
48    /// // ends the walk, and every key is seen at least once across it.
49    /// let mut seen = Vec::new();
50    /// let mut cur = 0u64;
51    /// loop {
52    ///     cur = m.scan_step(cur, |k, _| seen.push(*k));
53    ///     if cur == 0 { break; }
54    /// }
55    /// seen.sort_unstable();
56    /// seen.dedup();
57    /// assert_eq!(seen.len(), 64);
58    /// ```
59    pub fn scan_step(&self, cursor: u64, mut f: impl FnMut(&K, &V)) -> u64 {
60        if self.cap == 0 {
61            return 0;
62        }
63        // cap is a power of two ≥ MIN_CAP (16), so ngroups ≥ 1 and gmask
64        // is a low-bit mask.
65        let ngroups = (self.cap / GROUP_WIDTH) as u64;
66        let gmask = ngroups - 1;
67        self.visit_home_group((cursor & gmask) as usize, &mut f);
68        // dictScan's reverse-binary increment: force every bit above the
69        // mask to 1 so the +1 carry (performed in the bit-reversed
70        // domain) clears them — the result is always ≤ gmask, and a
71        // cursor minted against a smaller table resumes correctly
72        // against a larger one.
73        let v = (cursor | !gmask).reverse_bits();
74        v.wrapping_add(1).reverse_bits()
75    }
76
77    /// Visit buckets `[start, start + count)` in storage order and return
78    /// where the next call should start; a return of [`Self::capacity`]
79    /// means the walk reached the end.
80    ///
81    /// The order [`KevyMap::iter`] uses, so neighbouring entries come out
82    /// together and nothing is rehashed to find its place. Positions hold
83    /// only while the capacity does: removals leave their slot and inserts
84    /// never move an entry, but a growth moves every one. A walk that lets
85    /// the map change between calls restarts when the capacity changes;
86    /// [`KevyMap::scan_step`] is the walk that needs no restart, at the
87    /// price of hashing every entry it visits.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// let mut m = kevy_map::KevyMap::new();
93    /// for i in 0..100u32 { m.insert(i, i); }
94    /// let (mut seen, mut at) = (Vec::new(), 0);
95    /// while at < m.capacity() {
96    ///     at = m.scan_buckets(at, 32, |k, _| seen.push(*k));
97    /// }
98    /// seen.sort_unstable();
99    /// assert_eq!(seen, (0..100).collect::<Vec<_>>());
100    /// ```
101    pub fn scan_buckets(&self, start: usize, count: usize, mut f: impl FnMut(&K, &V)) -> usize {
102        let end = start.saturating_add(count).min(self.cap);
103        for p in start..end {
104            // SAFETY: p < cap ⇒ metadata pointer in-bounds.
105            let meta = unsafe { *self.metadata_ptr.as_ptr().add(p) };
106            if meta & 0x80 == 0 {
107                // SAFETY: occupied slot ⇒ initialised.
108                let kv = unsafe { (*self.slots_ptr.as_ptr().add(p)).assume_init_ref() };
109                f(&kv.0, &kv.1);
110            }
111        }
112        end
113    }
114
115    /// Call `f` for every entry whose *home* bucket (`hash & mask`) lies
116    /// in aligned group `g` (buckets `[16g, 16g + 16)`).
117    ///
118    /// Walk + termination: insert probes 16-wide windows starting at the
119    /// home bucket, so an entry with home `h` sits at `p ≥ h` where the
120    /// contiguous range `[h, window(p))` holds no EMPTY — except that
121    /// tombstone reuse may place it up to `GROUP_WIDTH - 1` slots past
122    /// the first EMPTY at-or-after `h` (both inside one window). EMPTY
123    /// slots are only ever consumed (deletes leave DELETED; only a grow
124    /// mints a fresh all-EMPTY table), so the bound never decays.
125    /// Therefore every group-`g` entry lies at or before
126    /// `first-EMPTY-at-or-after(16g + 15) + GROUP_WIDTH - 1`, which is
127    /// exactly where this walk stops.
128    fn visit_home_group(&self, g: usize, f: &mut impl FnMut(&K, &V)) {
129        let start = g * GROUP_WIDTH;
130        let mut past_empty: Option<usize> = None;
131        // `off` covers at most the whole table (a live table always has
132        // ≥ cap/8 EMPTY slots — occupied + deleted never exceeds the 7/8
133        // threshold — so the walk terminates far earlier in practice).
134        for off in 0..self.cap {
135            let p = (start + off) & self.mask;
136            // SAFETY: p < cap ⇒ metadata pointer in-bounds.
137            let meta = unsafe { *self.metadata_ptr.as_ptr().add(p) };
138            if meta & 0x80 == 0 {
139                // SAFETY: occupied slot ⇒ initialised.
140                let kv = unsafe { (*self.slots_ptr.as_ptr().add(p)).assume_init_ref() };
141                let home = (kv.0.kevy_hash() as usize) & self.mask;
142                if home / GROUP_WIDTH == g {
143                    f(&kv.0, &kv.1);
144                }
145            }
146            match &mut past_empty {
147                Some(0) => return,
148                Some(t) => *t -= 1,
149                // The terminating EMPTY must sit at-or-after the LAST
150                // home bucket of the group (off ≥ GROUP_WIDTH - 1) so it
151                // bounds every home in the group, not just the first.
152                None if meta == EMPTY && off + 1 >= GROUP_WIDTH => {
153                    past_empty = Some(GROUP_WIDTH - 1);
154                }
155                None => {}
156            }
157        }
158    }
159}
160
161#[cfg(test)]
162#[path = "scan_tests.rs"]
163mod tests;