Skip to main content

erigon_seg/
bloom.rs

1//! Reader for a `.kvei` existence filter — a *negative* lookup accelerator.
2//!
3//! `contains_hash(h)` returning `false` means the key is **definitely absent**; `true`
4//! means "probably present" (a small false-positive rate). It never reports a real key
5//! as absent, so a `false` lets a point lookup skip the `.bt` search entirely.
6//!
7//! Two `.kvei` encodings exist in the wild:
8//!
9//! * the `holiman/bloomfilter/v2` layout (magic = 8 zero bytes then `v02\n`), a `k=3`
10//!   filter with a rotate-17 + per-key-XOR hash schedule — fully supported here;
11//! * a newer "fuse filter" layout (no bloom magic; a small version byte) — detected but
12//!   not yet decoded. We treat it as "matches everything", which keeps lookups correct
13//!   (just unaccelerated).
14
15use std::path::Path;
16
17use memmap2::Mmap;
18
19use crate::error::Result;
20use crate::util::{Advice, advise_mmap, lock_mmap, mmap_file, preload_mmap, unlock_mmap};
21
22/// holiman/bloomfilter/v2 header magic: 8 zero bytes followed by `v02\n`.
23const BLOOM_MAGIC: [u8; 12] = [0, 0, 0, 0, 0, 0, 0, 0, b'v', b'0', b'2', b'\n'];
24/// Byte offset of the bit array in the bloom layout: magic(12) + k(8) + n(8) + m(8) + keys(24).
25const BLOOM_BITS_OFFSET: usize = 60;
26
27/// What kind of filter a `.kvei` turned out to be.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum FilterKind {
31    /// An empty (0-byte) filter: matches every key.
32    Empty,
33    /// A supported `holiman/bloomfilter/v2` bloom filter.
34    Bloom,
35    /// A recognized-but-unsupported encoding (e.g. fuse filter): treated as match-all.
36    Unsupported,
37}
38
39enum Inner {
40    Empty,
41    Bloom {
42        keys: [u64; 3],
43        m: u64,
44        bits_off: usize,
45    },
46    Unsupported,
47}
48
49/// A `.kvei` existence filter.
50pub struct ExistenceFilter {
51    // Held to keep the bit array mapped for the lifetime of `Inner::Bloom`.
52    mmap: Mmap,
53    inner: Inner,
54}
55
56impl ExistenceFilter {
57    /// Open and parse a `.kvei` file.
58    ///
59    /// Unknown-but-structurally-valid encodings open successfully as
60    /// [`FilterKind::Unsupported`] (match-all) rather than erroring, so a reader can
61    /// fall back to the exact `.bt` search without special-casing the filter format.
62    pub fn open(path: impl AsRef<Path>) -> Result<ExistenceFilter> {
63        let mmap = mmap_file(path.as_ref())?;
64        let inner = Self::parse(&mmap);
65        Ok(ExistenceFilter { mmap, inner })
66    }
67
68    fn parse(d: &[u8]) -> Inner {
69        if d.is_empty() {
70            return Inner::Empty;
71        }
72        if d.len() >= BLOOM_BITS_OFFSET && d[0..12] == BLOOM_MAGIC {
73            let k = u64::from_le_bytes(d[12..20].try_into().unwrap());
74            let m = u64::from_le_bytes(d[28..36].try_into().unwrap());
75            let mut keys = [0u64; 3];
76            for (i, key) in keys.iter_mut().enumerate() {
77                *key = u64::from_le_bytes(d[36 + i * 8..44 + i * 8].try_into().unwrap());
78            }
79            let nwords = m.div_ceil(64) as usize;
80            if k == 3 && m >= 2 && BLOOM_BITS_OFFSET + nwords * 8 <= d.len() {
81                return Inner::Bloom {
82                    keys,
83                    m,
84                    bits_off: BLOOM_BITS_OFFSET,
85                };
86            }
87        }
88        // Not a (valid) bloom: a fuse filter or something we don't decode. Safe to
89        // treat as match-all — it only ever disables the negative speedup.
90        Inner::Unsupported
91    }
92
93    /// Advise the kernel that this `.kvei` is probed in random order. See
94    /// [`KvReader::advise_random`](crate::KvReader::advise_random).
95    pub fn advise_random(&self) -> std::io::Result<()> {
96        advise_mmap(&self.mmap, Advice::Random)
97    }
98
99    /// Bytes this `.kvei` occupies when fully resident.
100    pub fn mapped_bytes(&self) -> u64 {
101        self.mmap.len() as u64
102    }
103
104    /// Read the whole `.kvei` into the page cache, returning once it is resident. See
105    /// [`KvReader::preload_index`](crate::KvReader::preload_index).
106    pub fn preload(&self) -> u64 {
107        preload_mmap(&self.mmap) as u64
108    }
109
110    /// Pin the whole `.kvei` in RAM with `mlock`. See
111    /// [`KvReader::lock_index`](crate::KvReader::lock_index) for the caveats.
112    pub fn lock(&self) -> std::io::Result<()> {
113        lock_mmap(&self.mmap)
114    }
115
116    /// Release an [`mlock`](ExistenceFilter::lock).
117    pub fn unlock(&self) -> std::io::Result<()> {
118        unlock_mmap(&self.mmap)
119    }
120
121    /// Which encoding this filter turned out to be.
122    pub fn kind(&self) -> FilterKind {
123        match self.inner {
124            Inner::Empty => FilterKind::Empty,
125            Inner::Bloom { .. } => FilterKind::Bloom,
126            Inner::Unsupported => FilterKind::Unsupported,
127        }
128    }
129
130    /// Whether this filter can actually exclude keys (i.e. is a supported bloom). When
131    /// `false`, [`contains_hash`](Self::contains_hash) always returns `true`.
132    pub fn is_accelerating(&self) -> bool {
133        matches!(self.inner, Inner::Bloom { .. })
134    }
135
136    #[inline]
137    fn bit_word(&self, bits_off: usize, idx: usize) -> u64 {
138        let off = bits_off + idx * 8;
139        u64::from_le_bytes(self.mmap[off..off + 8].try_into().unwrap())
140    }
141
142    /// `ContainsHash`: `false` ⇒ the key is definitely absent. `hash` is the murmur3
143    /// `h1` of the key (see [`crate::murmur3_x64_128_h1`]). Always `true` for an empty
144    /// or unsupported filter.
145    #[inline]
146    pub fn contains_hash(&self, mut hash: u64) -> bool {
147        let (keys, m, bits_off) = match &self.inner {
148            Inner::Bloom { keys, m, bits_off } => (keys, *m, *bits_off),
149            Inner::Empty | Inner::Unsupported => return true,
150        };
151        let mut r = 1u64;
152        for &key in keys {
153            if r == 0 {
154                break;
155            }
156            hash = hash.rotate_left(17) ^ key;
157            let i = hash % m;
158            r &= (self.bit_word(bits_off, (i >> 6) as usize) >> (i & 0x3f)) & 1;
159        }
160        r != 0
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::hash::murmur3_x64_128_h1;
168
169    /// Build a tiny holiman/bloomfilter/v2 `.kvei`, read it back, and confirm membership.
170    #[test]
171    fn bloom_roundtrip_via_file() {
172        let m: u64 = 4096;
173        let keys: [u64; 3] = [
174            0x1111_2222_3333_4444,
175            0xaaaa_bbbb_cccc_dddd,
176            0xdead_beef_0bad_f00d,
177        ];
178        let nwords = (m as usize).div_ceil(64);
179        let mut bits = vec![0u64; nwords];
180        // AddHash(h): h = rotl(h,17) ^ key[n]; set bit (h % m).
181        let add = |bits: &mut [u64], mut h: u64| {
182            for &k in &keys {
183                h = h.rotate_left(17) ^ k;
184                let i = h % m;
185                bits[(i >> 6) as usize] |= 1 << (i & 63);
186            }
187        };
188        // Use real murmur3 hashes of a few keys so the test exercises the full path.
189        let present_keys: [&[u8]; 3] = [b"alpha", b"bravo-key", b"0123456789abcdef0123"];
190        let present: Vec<u64> = present_keys
191            .iter()
192            .map(|k| murmur3_x64_128_h1(k, 9))
193            .collect();
194        for &h in &present {
195            add(&mut bits, h);
196        }
197        // Serialize: magic(12) + k,n,m (LE) + keys (LE) + bits (LE) + sha384(48, ignored).
198        let mut buf: Vec<u8> = Vec::new();
199        buf.extend_from_slice(&BLOOM_MAGIC);
200        buf.extend_from_slice(&3u64.to_le_bytes());
201        buf.extend_from_slice(&(present.len() as u64).to_le_bytes());
202        buf.extend_from_slice(&m.to_le_bytes());
203        for k in keys {
204            buf.extend_from_slice(&k.to_le_bytes());
205        }
206        for w in &bits {
207            buf.extend_from_slice(&w.to_le_bytes());
208        }
209        buf.extend_from_slice(&[0u8; 48]);
210
211        let path =
212            std::env::temp_dir().join(format!("erigon_seg_bloom_{}.kvei", std::process::id()));
213        std::fs::write(&path, &buf).unwrap();
214        let f = ExistenceFilter::open(&path).expect("open bloom");
215        let _ = std::fs::remove_file(&path);
216
217        assert_eq!(f.kind(), FilterKind::Bloom);
218        assert!(f.is_accelerating());
219        for (k, &h) in present_keys.iter().zip(&present) {
220            assert!(f.contains_hash(h), "added key {k:?} must be present");
221            assert!(f.contains_hash(murmur3_x64_128_h1(k, 9)));
222        }
223        // A key we didn't add should (almost certainly, with this m) be reported absent.
224        assert!(!f.contains_hash(murmur3_x64_128_h1(b"definitely-not-added", 9)));
225    }
226
227    #[test]
228    fn empty_and_unsupported_match_all() {
229        let dir = std::env::temp_dir();
230        let empty = dir.join(format!("erigon_seg_empty_{}.kvei", std::process::id()));
231        std::fs::write(&empty, []).unwrap();
232        let f = ExistenceFilter::open(&empty).unwrap();
233        let _ = std::fs::remove_file(&empty);
234        assert_eq!(f.kind(), FilterKind::Empty);
235        assert!(!f.is_accelerating());
236        assert!(f.contains_hash(0xdead_beef)); // match-all
237
238        // A non-bloom blob (looks like a fuse filter) -> Unsupported, still match-all.
239        let fuse = dir.join(format!("erigon_seg_fuse_{}.kvei", std::process::id()));
240        std::fs::write(
241            &fuse,
242            [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
243        )
244        .unwrap();
245        let f = ExistenceFilter::open(&fuse).unwrap();
246        let _ = std::fs::remove_file(&fuse);
247        assert_eq!(f.kind(), FilterKind::Unsupported);
248        assert!(f.contains_hash(123));
249    }
250}