Skip to main content

subms_cuckoo_filter/
lib.rs

1//! Cuckoo filter. Bloom-alternative that supports delete.
2//!
3//! Two candidate buckets per key: `i1 = h(key) & mask`, `i2 = i1 ^ h(fp) & mask`.
4//! Each bucket holds `B` 8-bit fingerprints. Insert tries i1 then i2; if both
5//! full, kicks a random fingerprint out and re-places it. Delete removes a
6//! matching fingerprint from either bucket.
7//!
8//! ```
9//! use subms_cuckoo_filter::CuckooFilter;
10//! let mut cf = CuckooFilter::with_capacity(10_000);
11//! assert!(cf.insert("hello"));
12//! assert!(cf.contains("hello"));
13//! assert!(cf.delete("hello"));
14//! assert!(!cf.contains("hello"));
15//! ```
16//!
17//! Single writer. `CuckooFilter` is `Send + Sync` in the ordinary Rust sense
18//! (`&mut` for every mutation), and has no internal synchronisation: two
19//! threads mutating one filter is a compile error, and shared read access
20//! while a writer holds `&mut` is too. For read fan-out across threads take a
21//! [`CuckooSnapshot`] behind the `concurrent-reads` feature.
22//!
23//! Full writeup, design notes and measured benchmarks:
24//! <https://www.submillisecond.com/cookbook/recipes/subms-cuckoo-filter>
25
26use std::io::{self, Write};
27
28pub(crate) const FNV_OFFSET: u64 = 0xcbf29ce484222325;
29pub(crate) const FNV_PRIME: u64 = 0x100000001b3;
30/// Slots per bucket. 4 gives ~95% load factor; higher values raise load
31/// factor but slow lookups linearly.
32pub const BUCKET_SIZE: usize = 4;
33/// Max kick-out attempts during a single insert.
34pub const MAX_KICKS: usize = 500;
35/// Fingerprint bits held per slot in the base filter. The `variable-fingerprint`
36/// feature widens this to 12 or 16.
37pub const FINGERPRINT_BITS: u32 = 8;
38
39/// Every way an operation on a [`CuckooFilter`] can refuse.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CuckooError {
42    /// The eviction chain hit [`MAX_KICKS`] and the victim slot was already
43    /// occupied, so there is nowhere left to put a fingerprint. Size the
44    /// filter larger, or reach for the `dynamic` feature.
45    NotEnoughSpace,
46    /// [`CuckooFilter::union`] was handed a filter with a different bucket
47    /// count. Bucket `i` of one filter has no relationship to bucket `i` of
48    /// the other unless the geometries match.
49    GeometryMismatch { lhs: usize, rhs: usize },
50}
51
52impl std::fmt::Display for CuckooError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            CuckooError::NotEnoughSpace => write!(f, "cuckoo filter is saturated"),
56            CuckooError::GeometryMismatch { lhs, rhs } => {
57                write!(f, "incompatible cuckoo geometry: {lhs} buckets vs {rhs}")
58            }
59        }
60    }
61}
62
63impl std::error::Error for CuckooError {}
64
65pub struct CuckooFilter {
66    buckets: Vec<[u8; BUCKET_SIZE]>,
67    mask: usize,
68    count: usize,
69    rng_state: u64,
70    /// The one fingerprint the eviction chain could not re-home, held here
71    /// rather than dropped. Without it a saturating insert silently evicts an
72    /// already-present key and the no-false-negative guarantee breaks. Zero
73    /// means empty, matching the slot sentinel.
74    victim_fp: u8,
75    victim_bucket: usize,
76}
77
78impl CuckooFilter {
79    /// Sized for `expected_entries` at ~95% load. Bucket count rounded up to
80    /// a power of two.
81    pub fn with_capacity(expected_entries: usize) -> Self {
82        let needed = (expected_entries.max(1) * 105 / 100) / BUCKET_SIZE + 1;
83        let num_buckets = needed.max(2).next_power_of_two();
84        Self {
85            buckets: vec![[0u8; BUCKET_SIZE]; num_buckets],
86            mask: num_buckets - 1,
87            count: 0,
88            rng_state: 0x9e3779b97f4a7c15,
89            victim_fp: 0,
90            victim_bucket: 0,
91        }
92    }
93
94    pub fn len(&self) -> usize {
95        self.count
96    }
97    pub fn is_empty(&self) -> bool {
98        self.count == 0
99    }
100    pub fn bucket_count(&self) -> usize {
101        self.buckets.len()
102    }
103
104    /// Total fingerprint slots. The filter refuses new keys somewhere below
105    /// this, around 95% occupancy at `BUCKET_SIZE = 4`.
106    pub fn capacity(&self) -> usize {
107        self.buckets.len() * BUCKET_SIZE
108    }
109
110    /// Occupied fraction of [`Self::capacity`]. The number that decides
111    /// whether inserts are about to start failing.
112    pub fn load_factor(&self) -> f64 {
113        self.count as f64 / self.capacity() as f64
114    }
115
116    /// Bytes held by the bucket array. Excludes the `Vec` header and the
117    /// handful of scalar fields; this is the term that scales.
118    pub fn size_in_bytes(&self) -> usize {
119        self.buckets.len() * BUCKET_SIZE
120    }
121
122    /// False-positive probability at the current occupancy:
123    /// `1 - (1 - 2^-f)^(2 * b * alpha)` for `f` fingerprint bits, `b` slots
124    /// per bucket and load factor `alpha`. A query touches `2b` slots, each a
125    /// `2^-f` chance of a fingerprint collision. Empty filter reports zero.
126    pub fn estimated_fpp(&self) -> f64 {
127        let alpha = self.load_factor();
128        if alpha <= 0.0 {
129            return 0.0;
130        }
131        let per_slot = 1.0 - 2f64.powi(-(FINGERPRINT_BITS as i32));
132        1.0 - per_slot.powf(2.0 * BUCKET_SIZE as f64 * alpha)
133    }
134
135    /// Zero every slot, keeping the allocation. A session boundary that
136    /// rebuilds membership from a source of truth reuses the array instead of
137    /// dropping and re-allocating it.
138    pub fn clear(&mut self) {
139        self.buckets.fill([0u8; BUCKET_SIZE]);
140        self.count = 0;
141        self.victim_fp = 0;
142        self.victim_bucket = 0;
143    }
144
145    /// Insert a fingerprint of `key`. Returns `false` if the filter is too
146    /// full to place (after `MAX_KICKS` evictions with the victim slot
147    /// already spoken for).
148    pub fn insert(&mut self, key: &str) -> bool {
149        self.insert_bytes(key.as_bytes())
150    }
151
152    /// Insert over raw bytes. Market-data keys are rarely `String` - an order
153    /// id is a `u64`, a symbol is a fixed-width field off the wire - and
154    /// forcing a UTF-8 allocation to reach the filter would dominate the op.
155    pub fn insert_bytes(&mut self, key: &[u8]) -> bool {
156        let (fp, i1, i2) = self.indices_bytes(key);
157        self.place(fp, i1, i2)
158    }
159
160    /// Insert only if the key is not already present, returning `true` when it
161    /// was added. One probe instead of two for the dedup shape: a feed handler
162    /// asking "have I seen this sequence number" and recording it in the same
163    /// breath. A false positive suppresses a genuinely new key, which is the
164    /// trade a dedup window is making anyway.
165    pub fn insert_if_absent(&mut self, key: &str) -> bool {
166        self.insert_if_absent_bytes(key.as_bytes())
167    }
168
169    pub fn insert_if_absent_bytes(&mut self, key: &[u8]) -> bool {
170        let (fp, i1, i2) = self.indices_bytes(key);
171        if self.bucket_has(i1, fp) || self.bucket_has(i2, fp) || self.victim_matches(fp, i1, i2) {
172            return false;
173        }
174        self.place(fp, i1, i2)
175    }
176
177    /// [`Self::insert`] with a typed refusal instead of a bare `bool`.
178    pub fn try_insert(&mut self, key: &str) -> Result<(), CuckooError> {
179        if self.insert(key) {
180            Ok(())
181        } else {
182            Err(CuckooError::NotEnoughSpace)
183        }
184    }
185
186    /// Probe membership. False positives possible (per the FPR analysis);
187    /// false negatives impossible - every cuckoo move leaves a fingerprint in
188    /// one of its two candidate buckets, and the one fingerprint an
189    /// oversubscribed insert cannot re-home is held in the victim slot rather
190    /// than dropped.
191    pub fn contains(&self, key: &str) -> bool {
192        self.contains_bytes(key.as_bytes())
193    }
194
195    pub fn contains_bytes(&self, key: &[u8]) -> bool {
196        let (fp, i1, i2) = self.indices_bytes(key);
197        self.bucket_has(i1, fp) || self.bucket_has(i2, fp) || self.victim_matches(fp, i1, i2)
198    }
199
200    /// Delete one occurrence of `key`. Returns `false` if not found.
201    pub fn delete(&mut self, key: &str) -> bool {
202        self.delete_bytes(key.as_bytes())
203    }
204
205    pub fn delete_bytes(&mut self, key: &[u8]) -> bool {
206        let (fp, i1, i2) = self.indices_bytes(key);
207        if self.bucket_remove(i1, fp) || self.bucket_remove(i2, fp) {
208            self.count -= 1;
209            self.rehome_victim();
210            return true;
211        }
212        if self.victim_matches(fp, i1, i2) {
213            self.victim_fp = 0;
214            self.count -= 1;
215            return true;
216        }
217        false
218    }
219
220    /// Merge `other` into this filter. Both must have the same bucket count:
221    /// a fingerprint's home bucket is an index into a specific geometry, so
222    /// copying one across filters of different widths would land it somewhere
223    /// neither candidate bucket covers, which is a false negative.
224    ///
225    /// Unlike a bloom filter's OR, this walks and re-places every fingerprint,
226    /// so it is O(N) in `other`'s capacity and can fail on saturation. A
227    /// failed merge leaves the fingerprints placed so far in place; rebuild
228    /// from the sources rather than retrying into the same filter.
229    pub fn union(&mut self, other: &CuckooFilter) -> Result<(), CuckooError> {
230        if self.buckets.len() != other.buckets.len() {
231            return Err(CuckooError::GeometryMismatch {
232                lhs: self.buckets.len(),
233                rhs: other.buckets.len(),
234            });
235        }
236        for (i, bucket) in other.buckets.iter().enumerate() {
237            for &fp in bucket {
238                if fp != 0 && !self.place(fp, i, (i ^ alt_index_of_fp(fp)) & self.mask) {
239                    return Err(CuckooError::NotEnoughSpace);
240                }
241            }
242        }
243        if other.victim_fp != 0 {
244            let i1 = other.victim_bucket;
245            let i2 = (i1 ^ alt_index_of_fp(other.victim_fp)) & self.mask;
246            if !self.place(other.victim_fp, i1, i2) {
247                return Err(CuckooError::NotEnoughSpace);
248            }
249        }
250        Ok(())
251    }
252
253    /// Serialise to the cross-language wire format: bucket count, live count
254    /// and victim slot as big-endian headers, then the bucket bytes in index
255    /// order. The Java port reads and writes the same bytes. The PRNG state
256    /// is deliberately not carried - it only picks which slot to evict, so a
257    /// reloaded filter answers every query identically.
258    pub fn write_to<W: Write>(&self, out: &mut W) -> io::Result<()> {
259        out.write_all(&(self.buckets.len() as u32).to_be_bytes())?;
260        out.write_all(&(self.count as u64).to_be_bytes())?;
261        out.write_all(&[self.victim_fp])?;
262        out.write_all(&(self.victim_bucket as u32).to_be_bytes())?;
263        for b in &self.buckets {
264            out.write_all(b)?;
265        }
266        Ok(())
267    }
268
269    /// Parse a serialised filter. Rejects a truncated buffer and a bucket
270    /// count that is not a power of two - the mask arithmetic is only a
271    /// modular reduction when it is, and a corrupt header would otherwise
272    /// index out of the array on the first probe.
273    pub fn parse(buf: &[u8]) -> io::Result<Self> {
274        const HEADER: usize = 4 + 8 + 1 + 4;
275        if buf.len() < HEADER {
276            return Err(io::Error::new(
277                io::ErrorKind::InvalidData,
278                "cuckoo header too short",
279            ));
280        }
281        let num_buckets = u32::from_be_bytes(buf[0..4].try_into().unwrap()) as usize;
282        let count = u64::from_be_bytes(buf[4..12].try_into().unwrap()) as usize;
283        let victim_fp = buf[12];
284        let victim_bucket = u32::from_be_bytes(buf[13..17].try_into().unwrap()) as usize;
285        if num_buckets < 2 || !num_buckets.is_power_of_two() {
286            return Err(io::Error::new(
287                io::ErrorKind::InvalidData,
288                "cuckoo bucket count must be a power of two >= 2",
289            ));
290        }
291        if buf.len() < HEADER + num_buckets * BUCKET_SIZE {
292            return Err(io::Error::new(
293                io::ErrorKind::InvalidData,
294                "cuckoo body truncated",
295            ));
296        }
297        if victim_bucket >= num_buckets {
298            return Err(io::Error::new(
299                io::ErrorKind::InvalidData,
300                "cuckoo victim bucket out of range",
301            ));
302        }
303        let mut buckets = Vec::with_capacity(num_buckets);
304        for i in 0..num_buckets {
305            let off = HEADER + i * BUCKET_SIZE;
306            let mut b = [0u8; BUCKET_SIZE];
307            b.copy_from_slice(&buf[off..off + BUCKET_SIZE]);
308            buckets.push(b);
309        }
310        Ok(Self {
311            buckets,
312            mask: num_buckets - 1,
313            count,
314            rng_state: 0x9e3779b97f4a7c15,
315            victim_fp,
316            victim_bucket,
317        })
318    }
319
320    fn place(&mut self, fp: u8, i1: usize, i2: usize) -> bool {
321        if self.try_place(i1, fp) || self.try_place(i2, fp) {
322            self.count += 1;
323            return true;
324        }
325        if self.victim_fp != 0 {
326            return false;
327        }
328        let mut bucket_idx = if self.rand_bit() { i1 } else { i2 };
329        let mut victim = fp;
330        for _ in 0..MAX_KICKS {
331            let slot = (self.next_random() as usize) % BUCKET_SIZE;
332            std::mem::swap(&mut victim, &mut self.buckets[bucket_idx][slot]);
333            bucket_idx ^= alt_index_of_fp(victim) & self.mask;
334            if self.try_place(bucket_idx, victim) {
335                self.count += 1;
336                return true;
337            }
338        }
339        // The chain ran out of moves holding a fingerprint that is already
340        // part of the set. Park it instead of dropping it.
341        self.victim_fp = victim;
342        self.victim_bucket = bucket_idx;
343        self.count += 1;
344        true
345    }
346
347    fn victim_matches(&self, fp: u8, i1: usize, i2: usize) -> bool {
348        self.victim_fp == fp && (self.victim_bucket == i1 || self.victim_bucket == i2)
349    }
350
351    /// A delete frees a slot, so the parked fingerprint may fit again. Try it
352    /// on the way out of every successful delete: leaving the victim set is
353    /// what turns the next insert into a refusal.
354    fn rehome_victim(&mut self) {
355        if self.victim_fp == 0 {
356            return;
357        }
358        let fp = self.victim_fp;
359        let alt = (self.victim_bucket ^ alt_index_of_fp(fp)) & self.mask;
360        if self.try_place(self.victim_bucket, fp) || self.try_place(alt, fp) {
361            self.victim_fp = 0;
362        }
363    }
364
365    fn indices_bytes(&self, key: &[u8]) -> (u8, usize, usize) {
366        let h = mix(fnv1a64(key));
367        // Use the low byte as the 8-bit fingerprint. Avoid fp == 0 (we use 0
368        // to mark empty slots).
369        let fp = ((h & 0xff) as u8).max(1);
370        let i1 = (h >> 8) as usize & self.mask;
371        let i2 = (i1 ^ alt_index_of_fp(fp)) & self.mask;
372        (fp, i1, i2)
373    }
374
375    fn try_place(&mut self, i: usize, fp: u8) -> bool {
376        for slot in &mut self.buckets[i] {
377            if *slot == 0 {
378                *slot = fp;
379                return true;
380            }
381        }
382        false
383    }
384
385    fn bucket_has(&self, i: usize, fp: u8) -> bool {
386        self.buckets[i].contains(&fp)
387    }
388
389    fn bucket_remove(&mut self, i: usize, fp: u8) -> bool {
390        for slot in &mut self.buckets[i] {
391            if *slot == fp {
392                *slot = 0;
393                return true;
394            }
395        }
396        false
397    }
398
399    fn rand_bit(&mut self) -> bool {
400        self.next_random() & 1 != 0
401    }
402
403    fn next_random(&mut self) -> u64 {
404        self.rng_state = self
405            .rng_state
406            .wrapping_mul(6364136223846793005)
407            .wrapping_add(1442695040888963407);
408        self.rng_state
409    }
410}
411
412#[cfg(feature = "concurrent-reads")]
413impl CuckooFilter {
414    /// Borrow the raw bucket array. Used by the snapshot to traverse without
415    /// copying twice.
416    pub(crate) fn buckets_view(&self) -> &[[u8; BUCKET_SIZE]] {
417        &self.buckets
418    }
419
420    pub(crate) fn mask_view(&self) -> usize {
421        self.mask
422    }
423
424    /// `(fingerprint, bucket)` of the parked eviction victim, or `(0, 0)`.
425    pub(crate) fn victim_view(&self) -> (u8, usize) {
426        (self.victim_fp, self.victim_bucket)
427    }
428}
429
430/// `alt(fp)` deterministically derives the second bucket offset from a
431/// fingerprint. Multiplying by an odd constant keeps the map invertible
432/// (we never need the inverse, but it bounds collisions).
433pub(crate) fn alt_index_of_fp(fp: u8) -> usize {
434    (fp as u64).wrapping_mul(0x5bd1e9955_u64) as usize
435}
436
437pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 {
438    let mut h = FNV_OFFSET;
439    for &b in bytes {
440        h ^= b as u64;
441        h = h.wrapping_mul(FNV_PRIME);
442    }
443    h
444}
445
446pub(crate) fn mix(mut h: u64) -> u64 {
447    h ^= h >> 30;
448    h = h.wrapping_mul(0xbf58476d1ce4e5b9);
449    h ^= h >> 27;
450    h = h.wrapping_mul(0x94d049bb133111eb);
451    h ^= h >> 31;
452    h
453}
454
455#[cfg(test)]
456#[path = "cuckoo_tests.rs"]
457mod cuckoo_tests;
458
459#[cfg(test)]
460#[path = "sample_app_tests.rs"]
461mod sample_app_tests;
462
463#[cfg(feature = "harness")]
464pub mod recipe;
465
466// Opt-in feature catalog. Each submodule is gated by its own Cargo
467// feature; the base filter stays zero-dep + std-only.
468#[cfg(any(
469    feature = "variable-fingerprint",
470    feature = "dynamic",
471    feature = "concurrent-reads",
472    feature = "compressed-buckets",
473))]
474pub mod features;
475
476#[cfg(feature = "compressed-buckets")]
477pub use features::compressed_buckets::CompressedCuckooFilter;
478#[cfg(feature = "concurrent-reads")]
479pub use features::concurrent_reads::CuckooSnapshot;
480#[cfg(feature = "dynamic")]
481pub use features::dynamic::DynamicCuckooFilter;
482#[cfg(feature = "variable-fingerprint")]
483pub use features::variable_fingerprint::{FingerprintWidth, VariableFpCuckooFilter};