Skip to main content

reddb_server/storage/primitives/
split_block_bloom.rs

1//! SplitBlockBloom — cache-line-aligned, SIMD-friendly bloom filter.
2//!
3//! Direct port of MongoDB's split-block bloom from
4//! `src/mongo/db/exec/sbe/util/bloom_filter.h`.
5//!
6//! # Design
7//!
8//! Each block is 32 bytes (8 × u32 = 256 bits), aligned to a cache line.
9//! Insert/probe uses 8 independent salt multiplications, one bit per word.
10//! Block index uses power-of-2 masking (fast modulo via bitwise AND).
11//!
12//! For k=8 bits per element: ~10 bits needed per element for ~1% FPR.
13//! At n=1000: 8 blocks (256 bytes). At n=10_000: 128 blocks (4 KB).
14//!
15//! # Usage in `CompiledEntityFilter`
16//!
17//! For `Filter::In` with >IN_BLOOM_THRESHOLD values, the compiled filter
18//! builds a `SplitBlockBloom` at compile time. At evaluate time:
19//! 1. Hash field value to u32 (fast, no allocation)
20//! 2. Bloom probe — if **false**, skip HashSet (definite miss, O(1))
21//! 3. HashSet probe — exact membership check (only ~1% FPR false positives reach here)
22//!
23//! Benefit: for rows where the field exists but doesn't match any IN value,
24//! the bloom eliminates the HashSet::contains call ~99% of the time.
25
26const SALTS: [u32; 8] = [
27    0x47b6137b, 0x44974d91, 0x8824ad5b, 0xa2b7289d, 0x705495c7, 0x2df1424b, 0x9efc4947, 0x5c6bfb31,
28];
29
30/// One 32-byte cache-line-aligned block: 8 × u32 words.
31#[repr(align(32))]
32#[derive(Clone, Default, PartialEq, Eq)]
33pub struct Block {
34    words: [u32; 8],
35}
36
37impl std::fmt::Debug for Block {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "Block({:08x?})", &self.words)
40    }
41}
42
43/// Split-block bloom filter. Build once at compile time, probe at query time.
44/// Zero-allocation probe path.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SplitBlockBloom {
47    blocks: Vec<Block>,
48    /// `num_blocks - 1` — mask for fast modulo via bitwise AND.
49    mask: usize,
50}
51
52impl SplitBlockBloom {
53    /// Build a filter sized for `n` elements with ~1% FPR.
54    pub fn with_capacity(n: usize) -> Self {
55        // ~10 bits per element for 1% FPR with 8 salt bits per insert.
56        // Each block holds 256 bits. Round up to next power of two.
57        let bits_needed = (n * 10).max(256);
58        let blocks_needed = bits_needed.div_ceil(256);
59        let num_blocks = blocks_needed.next_power_of_two();
60        Self {
61            blocks: vec![Block::default(); num_blocks],
62            mask: num_blocks - 1,
63        }
64    }
65
66    /// Insert a u32 key into the filter.
67    #[inline]
68    pub fn insert(&mut self, key: u32) {
69        let block_idx = (key as usize) & self.mask;
70        let block = &mut self.blocks[block_idx];
71        for (i, &salt) in SALTS.iter().enumerate() {
72            let bit = key.wrapping_mul(salt) >> 27; // 5-bit position 0..31
73            block.words[i] |= 1u32 << bit;
74        }
75    }
76
77    /// Insert an arbitrary byte key into the filter using one stable hash pass.
78    #[inline]
79    pub fn insert_bytes(&mut self, key: &[u8]) {
80        let h = stable_hash64(key);
81        let block_idx = ((h >> 32) as usize) & self.mask;
82        let probe_word = h as u32;
83        let block = &mut self.blocks[block_idx];
84        for (i, &salt) in SALTS.iter().enumerate() {
85            let bit = probe_word.wrapping_mul(salt) >> 27;
86            block.words[i] |= 1u32 << bit;
87        }
88    }
89
90    /// Return `true` if `key` **may** be in the set (false positives possible).
91    /// Return `false` if `key` is **definitely absent** (no false negatives).
92    #[inline]
93    pub fn probe(&self, key: u32) -> bool {
94        let block_idx = (key as usize) & self.mask;
95        let block = &self.blocks[block_idx];
96        for (i, &salt) in SALTS.iter().enumerate() {
97            let bit = key.wrapping_mul(salt) >> 27;
98            if block.words[i] & (1u32 << bit) == 0 {
99                return false;
100            }
101        }
102        true
103    }
104
105    /// Return `true` if `key` **may** be in the set (false positives possible).
106    /// Return `false` if `key` is **definitely absent** (no false negatives).
107    #[inline]
108    pub fn probe_bytes(&self, key: &[u8]) -> bool {
109        let h = stable_hash64(key);
110        let block_idx = ((h >> 32) as usize) & self.mask;
111        let probe_word = h as u32;
112        let block = &self.blocks[block_idx];
113        for (i, &salt) in SALTS.iter().enumerate() {
114            let bit = probe_word.wrapping_mul(salt) >> 27;
115            if block.words[i] & (1u32 << bit) == 0 {
116                return false;
117            }
118        }
119        true
120    }
121
122    /// Fold sparse halves together without rehashing keys. Reducing the mask
123    /// routes every old block to its index modulo the new block count, so OR
124    /// preserves all inserted bits. Stop before any merged word is over half
125    /// full, keeping compaction conservative for equality pruning.
126    pub(crate) fn compact(&mut self) {
127        let original_len = self.blocks.len();
128        while self.blocks.len() > 1 {
129            let half = self.blocks.len() / 2;
130            let can_fold =
131                self.blocks[..half]
132                    .iter()
133                    .zip(&self.blocks[half..])
134                    .all(|(left, right)| {
135                        left.words
136                            .iter()
137                            .zip(right.words)
138                            .all(|(left, right)| (left | right).count_ones() <= u32::BITS / 2)
139                    });
140            if !can_fold {
141                break;
142            }
143            for index in 0..half {
144                for word in 0..8 {
145                    self.blocks[index].words[word] |= self.blocks[index + half].words[word];
146                }
147            }
148            self.blocks.truncate(half);
149            self.mask = half - 1;
150        }
151        if self.blocks.len() != original_len {
152            self.blocks.shrink_to_fit();
153        }
154    }
155
156    /// Number of blocks allocated (each block = 32 bytes).
157    pub fn num_blocks(&self) -> usize {
158        self.blocks.len()
159    }
160
161    /// Number of bytes used by the block payload, excluding the serialized
162    /// block-count prefix.
163    pub fn byte_size(&self) -> usize {
164        self.blocks.len() * 32
165    }
166
167    /// OR-merge another split-block bloom into this filter. Returns `false`
168    /// when the filters have different block counts, because their block
169    /// routing masks are incompatible.
170    pub fn union_inplace(&mut self, other: &SplitBlockBloom) -> bool {
171        if self.blocks.len() != other.blocks.len() {
172            return false;
173        }
174        for (left, right) in self.blocks.iter_mut().zip(&other.blocks) {
175            for (left_word, right_word) in left.words.iter_mut().zip(right.words) {
176                *left_word |= right_word;
177            }
178        }
179        true
180    }
181
182    /// Fraction of block words that have at least one bit set.
183    pub fn fill_ratio(&self) -> f64 {
184        let total_words = self.blocks.len() * 8;
185        if total_words == 0 {
186            return 0.0;
187        }
188        let filled_words = self
189            .blocks
190            .iter()
191            .flat_map(|block| block.words)
192            .filter(|word| *word != 0)
193            .count();
194        filled_words as f64 / total_words as f64
195    }
196
197    /// Serialize to a self-describing blob: a 4-byte LE block count followed
198    /// by `num_blocks × 8` little-endian `u32` words. The block count is
199    /// always a power of two, so [`from_bytes`](Self::from_bytes) can rebuild
200    /// the modulo mask without storing it. Used to persist a per-granule
201    /// bloom in a sealed columnar chunk's footer (#855).
202    pub fn to_bytes(&self) -> Vec<u8> {
203        let mut out = Vec::with_capacity(4 + self.blocks.len() * 32);
204        out.extend_from_slice(&(self.blocks.len() as u32).to_le_bytes());
205        for block in &self.blocks {
206            for &w in &block.words {
207                out.extend_from_slice(&w.to_le_bytes());
208            }
209        }
210        out
211    }
212
213    /// Rebuild a filter from [`to_bytes`](Self::to_bytes). Returns `None` on a
214    /// truncated blob or a non-power-of-two block count (a corrupt mask would
215    /// silently mis-route probes and could manufacture false negatives, so we
216    /// refuse it rather than risk under-inclusion).
217    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
218        if bytes.len() < 4 {
219            return None;
220        }
221        let num_blocks = u32::from_le_bytes(bytes[0..4].try_into().ok()?) as usize;
222        if num_blocks == 0 || !num_blocks.is_power_of_two() {
223            return None;
224        }
225        if bytes.len() < 4 + num_blocks * 32 {
226            return None;
227        }
228        let mut blocks = Vec::with_capacity(num_blocks);
229        let mut cur = 4;
230        for _ in 0..num_blocks {
231            let mut words = [0u32; 8];
232            for w in &mut words {
233                *w = u32::from_le_bytes(bytes[cur..cur + 4].try_into().ok()?);
234                cur += 4;
235            }
236            blocks.push(Block { words });
237        }
238        Some(Self {
239            blocks,
240            mask: num_blocks - 1,
241        })
242    }
243}
244
245/// Hash a raw byte slice to a stable `u64` for byte-key bloom-filter use.
246///
247/// This hasher is seedless, dependency-free, and deterministic across
248/// processes and restarts. Persisted filters that store byte-key bloom bits
249/// MUST use this same function for both writers and readers so the stored
250/// bloom and probe key agree bit-for-bit across the persisted boundary.
251pub fn stable_hash64(bytes: &[u8]) -> u64 {
252    let mut hash = 0xcbf2_9ce4_8422_2325u64;
253    for &byte in bytes {
254        hash ^= u64::from(byte);
255        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
256    }
257    hash ^= hash >> 33;
258    hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
259    hash ^= hash >> 33;
260    hash = hash.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
261    hash ^ (hash >> 33)
262}
263
264/// Hash a raw byte slice to a `u32` for bloom-filter use. The writer and the
265/// pruner MUST fold a value through this same function so a granule's stored
266/// bloom and a probe key agree bit-for-bit — that identity is what makes the
267/// no-false-negative guarantee hold across the persisted boundary (#855).
268pub fn hash_bytes_u32(bytes: &[u8]) -> u32 {
269    use std::hash::{Hash, Hasher};
270    let mut h = std::collections::hash_map::DefaultHasher::new();
271    bytes.hash(&mut h);
272    let bits = h.finish();
273    (bits ^ (bits >> 32)) as u32
274}
275
276/// Hash a `Value` to a u32 for bloom filter use.
277/// Uses the standard Hash impl (which hashes discriminant + content).
278/// Folds the 64-bit DefaultHasher output to 32 bits via XOR.
279pub fn hash_value_u32(v: &reddb_types::Value) -> u32 {
280    use std::hash::{Hash, Hasher};
281    let mut h = std::collections::hash_map::DefaultHasher::new();
282    v.hash(&mut h);
283    let bits = h.finish();
284    (bits ^ (bits >> 32)) as u32
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_insert_then_probe() {
293        let mut bloom = SplitBlockBloom::with_capacity(100);
294        for i in 0u32..100 {
295            bloom.insert(i);
296        }
297        for i in 0u32..100 {
298            assert!(bloom.probe(i), "false negative for key {i}");
299        }
300    }
301
302    #[test]
303    fn test_absent_key_may_return_false() {
304        let mut bloom = SplitBlockBloom::with_capacity(1000);
305        for i in 0u32..1000 {
306            bloom.insert(i * 2); // insert even numbers
307        }
308        // odd numbers were never inserted — some may be false positives, but
309        // most should be absent. We just verify there are NO false negatives.
310        for i in 0u32..1000 {
311            assert!(bloom.probe(i * 2), "false negative for key {}", i * 2);
312        }
313    }
314
315    #[test]
316    fn to_bytes_from_bytes_round_trips_and_keeps_no_false_negatives() {
317        let mut bloom = SplitBlockBloom::with_capacity(500);
318        for i in 0u32..500 {
319            bloom.insert(i.wrapping_mul(2_654_435_761));
320        }
321        let blob = bloom.to_bytes();
322        let restored = SplitBlockBloom::from_bytes(&blob).expect("round-trips");
323        assert_eq!(restored, bloom);
324        // The persisted filter still never reports a false negative.
325        for i in 0u32..500 {
326            assert!(restored.probe(i.wrapping_mul(2_654_435_761)));
327        }
328    }
329
330    #[test]
331    fn compact_preserves_keys_and_serialized_routing() {
332        for distinct in [1u64, 4, 16, 64, 128, 200, 255, 256] {
333            let mut bloom = SplitBlockBloom::with_capacity(8192);
334            for value in 0u64..8192 {
335                bloom.insert(hash_bytes_u32(&(value % distinct).to_le_bytes()));
336            }
337            let original_size = bloom.byte_size();
338            bloom.compact();
339            assert!(bloom.byte_size() < original_size);
340            let restored = SplitBlockBloom::from_bytes(&bloom.to_bytes()).expect("compacted bloom");
341            for value in 0..distinct {
342                assert!(restored.probe(hash_bytes_u32(&value.to_le_bytes())));
343            }
344            let compacted = bloom.clone();
345            bloom.compact();
346            assert_eq!(bloom, compacted);
347        }
348    }
349
350    #[test]
351    fn compact_preserves_byte_key_routing_and_dense_filters() {
352        let mut sparse = SplitBlockBloom::with_capacity(8192);
353        for value in 0u64..16 {
354            sparse.insert_bytes(&value.to_le_bytes());
355        }
356        sparse.compact();
357        let restored =
358            SplitBlockBloom::from_bytes(&sparse.to_bytes()).expect("compacted byte bloom");
359        for value in 0u64..16 {
360            assert!(restored.probe_bytes(&value.to_le_bytes()));
361        }
362
363        let mut dense = SplitBlockBloom::with_capacity(128);
364        for value in 0u64..128 {
365            dense.insert(hash_bytes_u32(&value.to_le_bytes()));
366        }
367        let original = dense.clone();
368        dense.compact();
369        assert_eq!(
370            dense, original,
371            "dense filters should not pay for a rebuild"
372        );
373    }
374
375    #[test]
376    fn from_bytes_rejects_truncated_or_non_power_of_two() {
377        assert!(SplitBlockBloom::from_bytes(&[]).is_none());
378        assert!(SplitBlockBloom::from_bytes(&[1, 2, 3]).is_none());
379        // Claims 3 blocks (not a power of two) → rejected.
380        assert!(SplitBlockBloom::from_bytes(&3u32.to_le_bytes()).is_none());
381        // Claims 2 blocks but supplies no word payload → truncated.
382        assert!(SplitBlockBloom::from_bytes(&2u32.to_le_bytes()).is_none());
383    }
384
385    #[test]
386    fn hash_bytes_u32_is_stable_across_calls() {
387        let a = hash_bytes_u32(&7u64.to_le_bytes());
388        let b = hash_bytes_u32(&7u64.to_le_bytes());
389        assert_eq!(a, b);
390    }
391
392    #[test]
393    fn test_false_positive_rate_approximately_one_percent() {
394        const N: usize = 10_000;
395        let mut bloom = SplitBlockBloom::with_capacity(N);
396        for i in 0u32..N as u32 {
397            bloom.insert(i);
398        }
399        let mut fp = 0usize;
400        let probes = 10_000usize;
401        for i in N as u32..(N as u32 + probes as u32) {
402            if bloom.probe(i) {
403                fp += 1;
404            }
405        }
406        let fpr = fp as f64 / probes as f64;
407        // Allow up to 5% FPR — the theoretical ~1% varies with data patterns.
408        assert!(fpr < 0.05, "FPR too high: {fpr:.3}");
409    }
410
411    #[test]
412    fn insert_bytes_has_no_false_negatives() {
413        const N: usize = 10_000;
414        let mut bloom = SplitBlockBloom::with_capacity(N);
415        for i in 0..N as u64 {
416            bloom.insert_bytes(&byte_key(i));
417        }
418        for i in 0..N as u64 {
419            assert!(
420                bloom.probe_bytes(&byte_key(i)),
421                "false negative for key {i}"
422            );
423        }
424    }
425
426    #[test]
427    fn insert_bytes_false_positive_rate_stays_below_two_percent() {
428        const N: usize = 10_000;
429        let mut bloom = SplitBlockBloom::with_capacity(N);
430        for i in 0..N as u64 {
431            bloom.insert_bytes(&byte_key(i));
432        }
433        let mut fp = 0usize;
434        let probes = 10_000usize;
435        for i in N as u64..(N + probes) as u64 {
436            if bloom.probe_bytes(&byte_key(i)) {
437                fp += 1;
438            }
439        }
440        let fpr = fp as f64 / probes as f64;
441        assert!(fpr < 0.02, "FPR too high: {fpr:.3}");
442    }
443
444    #[test]
445    fn bytes_round_trip_keeps_no_false_negatives() {
446        const N: usize = 10_000;
447        let mut bloom = SplitBlockBloom::with_capacity(N);
448        for i in 0..N as u64 {
449            bloom.insert_bytes(&byte_key(i));
450        }
451        let blob = bloom.to_bytes();
452        let restored = SplitBlockBloom::from_bytes(&blob).expect("round-trips");
453        assert_eq!(restored, bloom);
454        for i in 0..N as u64 {
455            assert!(
456                restored.probe_bytes(&byte_key(i)),
457                "false negative for key {i}"
458            );
459        }
460    }
461
462    #[test]
463    fn bytes_hashing_agrees_across_filters() {
464        const N: usize = 10_000;
465        let mut a = SplitBlockBloom::with_capacity(N);
466        let mut b = SplitBlockBloom::with_capacity(N);
467        for i in 0..N as u64 {
468            let key = byte_key(i);
469            a.insert_bytes(&key);
470            b.insert_bytes(&key);
471        }
472        assert_eq!(a, b);
473        for i in 0..N as u64 {
474            let key = byte_key(i);
475            assert_eq!(a.probe_bytes(&key), b.probe_bytes(&key));
476            assert!(a.probe_bytes(&key), "false negative for key {i}");
477        }
478    }
479
480    #[test]
481    fn union_inplace_merges_matching_block_counts() {
482        let mut a = SplitBlockBloom::with_capacity(1024);
483        let mut b = SplitBlockBloom::with_capacity(1024);
484        a.insert_bytes(b"one");
485        b.insert_bytes(b"two");
486
487        assert!(a.union_inplace(&b));
488        assert!(a.probe_bytes(b"one"));
489        assert!(a.probe_bytes(b"two"));
490    }
491
492    #[test]
493    fn union_inplace_rejects_mismatched_block_counts() {
494        let mut a = SplitBlockBloom::with_capacity(1024);
495        let b = SplitBlockBloom::with_capacity(4096);
496
497        assert!(!a.union_inplace(&b));
498    }
499
500    fn byte_key(i: u64) -> [u8; 16] {
501        let mut key = [0u8; 16];
502        key[..8].copy_from_slice(&i.to_le_bytes());
503        key[8..].copy_from_slice(&i.wrapping_mul(0x9e37_79b9_7f4a_7c15).to_le_bytes());
504        key
505    }
506}