Skip to main content

mira_core/
bloom.rs

1//! Block-level "might contain this" filters.
2//!
3//! Block names prune by time, which answers every question that has a time
4//! bound and none of the ones that do not. Two queries have no useful time
5//! bound at all, and both of them read every block on disk without a filter:
6//!
7//! * *"Every span of trace `0000…6eaa`."* You do not know when the trace
8//!   happened — that is why you are looking it up.
9//! * *"Any record with `k8s.pod.name = api-7f9`."* A predicate that matches
10//!   nothing has no early exit: `limit` never fills, so the scan runs to the
11//!   end of retention to prove a negative.
12//!
13//! A sidecar per block, written next to the Arrow tables at publish and read
14//! before any of them, turns both into "open the blocks that can answer".
15//! Measured on this machine, each query against its own corpus (section 11):
16//!
17//! ```text
18//!                                       blocks   rows scanned   time
19//! 4.1 GB / 25M spans / 84 blocks
20//! every span of one trace, without         84      25,000,000   14.3 s
21//!                          with             1         212,992    250 ms cold, 20 warm
22//! 6.4 GB / 25M logs / 69 blocks
23//! absent attribute value,  without         69      24,961,024   10.4 s
24//!                          with             0               0    71 ms cold, 5.7 warm
25//! ```
26//!
27//! [`TRACE_IDX`] costs 65 KB per block, 0.13% of the data. [`ATTR_IDX`] is
28//! sized by the block's *distinct* `(key, value)` pairs, so on that corpus it is
29//! 70 bytes per block and it grows only where pruning pays best.
30//!
31//! Both go through a splitmix64 finalizer before Kirsch-Mitzenmacher double
32//! hashing, and that is not optional. W3C only requires a trace id to be
33//! non-zero, and plenty of real ones are structured rather than random: X-Ray
34//! puts an epoch in the first four bytes, a counter-derived id leaves whole
35//! bytes constant. The bit index is `(h1 + i*h2) & mask`, so it reads only the
36//! *low* bits of the halves; ids that vary in their middle bytes would map every
37//! trace in a block onto the same handful of bits and the filter would answer
38//! "maybe" to everything. Five ops per half buys immunity to that.
39//!
40//! Everything here fails open. A short, corrupt or unrecognized file means
41//! "scan the block", never "skip it" — a false positive costs one wasted block
42//! read, a false negative silently loses data from a correct query.
43
44use std::collections::HashSet;
45
46use arrow_array::{Array, FixedSizeBinaryArray};
47
48/// Filenames inside a published block. Part of the on-disk format.
49pub const TRACE_IDX: &str = "trace.idx";
50pub const ATTR_IDX: &str = "attr.idx";
51
52const MAGIC: [u8; 4] = *b"MBLM";
53const VERSION: u8 = 1;
54/// magic 4 | version 1 | k 1 | flags 1 | pad 1 | words 4 | crc32 4
55const HEADER: usize = 16;
56
57/// Set in [`ATTR_IDX`] when the block holds at least one `double`-typed
58/// attribute.
59///
60/// Doubles are the one type not indexed. The canonical text of a float is not
61/// unique — `200`, `200.0` and `2e2` are the same number and three different
62/// strings, and a query writing one of the forms the writer did not would be
63/// pruned away. So the block declares that it has some, and any query whose
64/// value could be read as a number scans it. Blocks with no double attributes,
65/// which is most of them, pay nothing for the rule.
66pub const HAS_DOUBLE: u8 = 1;
67
68/// Bits per inserted key. 10 with k=7 is the textbook ~0.8% false positive
69/// rate.
70const BITS_PER_KEY: usize = 10;
71const K: u32 = 7;
72
73/// Build a filter over a `FixedSizeBinary(16)` column. `None` when there is
74/// nothing to index, in which case no file is written and the reader's
75/// fail-open path scans the block.
76///
77/// The column holds one row per *span*, and a trace is eight of them. Sizing on
78/// the row count would make every filter eight times bigger than the key set it
79/// holds, and the read path pays that on every block it probes. Runs of the
80/// same id collapse — spans of a trace arrive in one export and land adjacent —
81/// which is a 16-byte compare per row against a hash set that would cost an
82/// allocation and a probe. Interleaved traces just fall back to the row count,
83/// which is the size we would have had anyway.
84pub fn build(ids: &FixedSizeBinaryArray) -> Option<Vec<u8>> {
85    if ids.value_length() != 16 {
86        return None;
87    }
88    let keys = || Runs {
89        ids,
90        i: 0,
91        prev: None,
92    };
93    let n = keys().count();
94    encode(n, 0, keys().map(halves))
95}
96
97/// A set of hashed keys, deduplicated so the filter is sized by what it holds.
98///
99/// The trace filter gets its dedup from adjacency and needs none of this. An
100/// attribute filter cannot: `http.route = GET /cart` recurs on a third of the
101/// rows in a block and nowhere near adjacently, so without a set the filter
102/// would be sized for a million rows holding five values.
103#[derive(Default)]
104pub struct Keys {
105    seen: HashSet<(u64, u64)>,
106    flags: u8,
107    full: bool,
108}
109
110/// Beyond this many distinct keys the filter stops being a rounding error on
111/// the block — 1M keys is 1.25 MB — and a block that diverse prunes little
112/// anyway. Past it we write nothing, which the reader reads as "scan me".
113const MAX_KEYS: usize = 1 << 20;
114
115impl Keys {
116    pub fn insert(&mut self, h: (u64, u64)) {
117        if self.seen.len() < MAX_KEYS {
118            self.seen.insert(h);
119        } else {
120            self.full = true;
121        }
122    }
123
124    pub fn flag(&mut self, bit: u8) {
125        self.flags |= bit;
126    }
127
128    pub fn build(&self) -> Option<Vec<u8>> {
129        if self.full {
130            return None;
131        }
132        encode(self.seen.len(), self.flags, self.seen.iter().copied())
133    }
134}
135
136fn encode(n: usize, flags: u8, keys: impl Iterator<Item = (u64, u64)>) -> Option<Vec<u8>> {
137    if n == 0 {
138        return None;
139    }
140    let words = ((n * BITS_PER_KEY).div_ceil(64)).next_power_of_two();
141    let mut bits = vec![0u64; words];
142    // A power-of-two word count makes the modulo an `and`, which matters: this
143    // runs seven times per key on the seal path.
144    let mask = (words as u64 * 64) - 1;
145    for (h1, h2) in keys {
146        for i in 0..K {
147            let bit = h1.wrapping_add((i as u64).wrapping_mul(h2)) & mask;
148            bits[bit as usize / 64] |= 1 << (bit % 64);
149        }
150    }
151
152    let mut out = Vec::with_capacity(HEADER + words * 8);
153    out.extend_from_slice(&MAGIC);
154    out.push(VERSION);
155    out.push(K as u8);
156    out.push(flags);
157    out.push(0);
158    out.extend_from_slice(&(words as u32).to_le_bytes());
159    let body: Vec<u8> = bits.iter().flat_map(|w| w.to_le_bytes()).collect();
160    out.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
161    out.extend_from_slice(&body);
162    Some(out)
163}
164
165/// A filter checked out, with its header validated once.
166pub struct Filter<'a> {
167    body: &'a [u8],
168    k: u32,
169    mask: u64,
170    pub flags: u8,
171}
172
173impl<'a> Filter<'a> {
174    /// `None` for anything unreadable, which every caller must treat as "scan
175    /// the block".
176    pub fn open(file: &'a [u8]) -> Option<Filter<'a>> {
177        if file.len() < HEADER || file[..4] != MAGIC || file[4] != VERSION {
178            return None;
179        }
180        let k = file[5] as u32;
181        let flags = file[6];
182        let words = u32::from_le_bytes(file[8..12].try_into().expect("4 bytes")) as usize;
183        let crc = u32::from_le_bytes(file[12..16].try_into().expect("4 bytes"));
184        let body = &file[HEADER..];
185        if k == 0 || !words.is_power_of_two() || body.len() != words * 8 {
186            return None;
187        }
188        // The one check that costs something — 65 KB of CRC against a block
189        // read of tens of megabytes. Skipping a block on the word of a corrupt
190        // filter is the one outcome worth paying to avoid.
191        if crc32fast::hash(body) != crc {
192            return None;
193        }
194        Some(Filter {
195            body,
196            k,
197            mask: (words as u64 * 64) - 1,
198            flags,
199        })
200    }
201
202    pub fn may_contain(&self, (h1, h2): (u64, u64)) -> bool {
203        for i in 0..self.k {
204            let bit = h1.wrapping_add((i as u64).wrapping_mul(h2)) & self.mask;
205            let word = u64::from_le_bytes(
206                self.body[(bit as usize / 64) * 8..][..8]
207                    .try_into()
208                    .expect("slice of 8"),
209            );
210            if word & (1 << (bit % 64)) == 0 {
211                return false;
212            }
213        }
214        true
215    }
216}
217
218/// Could this block contain `id`? Anything unreadable answers yes.
219pub fn may_contain(file: &[u8], id: &[u8; 16]) -> bool {
220    Filter::open(file).is_none_or(|f| f.may_contain(halves(id)))
221}
222
223/// Hash one `(key, canonical value)` pair for [`ATTR_IDX`].
224///
225/// The value is the *text* of the scalar, not its typed bytes, and that is what
226/// makes one probe enough. A query scalar is compared against whatever type the
227/// column happens to hold — `{attr: http.status_code, eq: "200"}` matches a
228/// stored integer `200`, because the comparison parses rather than requiring the
229/// query to know the SDK's choice. Indexing the decimal text makes the filter
230/// agree with that rule instead of quietly disagreeing with it, which would be a
231/// false negative and therefore a lost row.
232pub fn attr_hash(key: &str, value: &[u8]) -> (u64, u64) {
233    let k = crate::identity::hash64(key.as_bytes());
234    let v = crate::identity::hash64(value);
235    (
236        mix(k ^ v.rotate_left(17)),
237        mix(k.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ v) | 1,
238    )
239}
240
241/// Non-null values, with adjacent duplicates dropped.
242struct Runs<'a> {
243    ids: &'a FixedSizeBinaryArray,
244    i: usize,
245    prev: Option<&'a [u8]>,
246}
247
248impl<'a> Iterator for Runs<'a> {
249    type Item = &'a [u8];
250
251    fn next(&mut self) -> Option<&'a [u8]> {
252        while self.i < self.ids.len() {
253            let i = self.i;
254            self.i += 1;
255            if self.ids.is_null(i) {
256                continue;
257            }
258            let v = self.ids.value(i);
259            if self.prev != Some(v) {
260                self.prev = Some(v);
261                return Some(v);
262            }
263        }
264        None
265    }
266}
267
268fn halves(id: &[u8]) -> (u64, u64) {
269    let h1 = mix(u64::from_le_bytes(id[..8].try_into().expect("16-byte id")));
270    // Odd, so the probe sequence walks the whole filter instead of landing on
271    // the same bit whenever the second half happens to be even.
272    let h2 = mix(u64::from_le_bytes(
273        id[8..16].try_into().expect("16-byte id"),
274    )) | 1;
275    (h1, h2)
276}
277
278/// splitmix64's finalizer: every input bit reaches every output bit.
279fn mix(mut x: u64) -> u64 {
280    x ^= x >> 30;
281    x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
282    x ^= x >> 27;
283    x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
284    x ^ (x >> 31)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use arrow_array::FixedSizeBinaryArray;
291
292    fn id(n: u64) -> [u8; 16] {
293        let mut b = [0u8; 16];
294        // Not a counter: the filter's whole premise is that trace ids are
295        // uniform, so a test over 0,1,2,… would measure a case that cannot
296        // happen and hide a probe sequence that clusters.
297        b[..8].copy_from_slice(&crate::identity::hash64(&n.to_le_bytes()).to_le_bytes());
298        b[8..].copy_from_slice(&crate::identity::hash64(&(!n).to_le_bytes()).to_le_bytes());
299        b
300    }
301
302    fn filter(n: u64) -> Vec<u8> {
303        let ids: Vec<[u8; 16]> = (0..n).map(id).collect();
304        let arr = FixedSizeBinaryArray::try_from_iter(ids.iter().map(|v| v.as_slice())).unwrap();
305        build(&arr).unwrap()
306    }
307
308    /// Everything inserted must be found — a false negative is a lost trace —
309    /// and the false positive rate has to be near the 0.8% the sizing promises,
310    /// or the filter is a 16 KB file that skips nothing.
311    #[test]
312    fn no_false_negatives_and_few_false_positives() {
313        let n = 10_000u64;
314        let f = filter(n);
315        // The size the two "skips nothing" comments quote: 10 bits a key,
316        // rounded up to a power-of-two word count, plus the header.
317        assert_eq!(f.len(), HEADER + 2048 * 8);
318        for i in 0..n {
319            assert!(may_contain(&f, &id(i)), "false negative at {i}");
320        }
321        let probes = 100_000u64;
322        let fp = (n..n + probes).filter(|&i| may_contain(&f, &id(i))).count();
323        let rate = fp as f64 / probes as f64;
324        assert!(rate < 0.02, "false positive rate {rate}");
325    }
326
327    /// Every way the file can be wrong has to answer "scan it". This is the
328    /// property the whole thing rests on: a filter is an optimization, and an
329    /// optimization that can lose data is a bug.
330    #[test]
331    fn a_damaged_filter_says_maybe() {
332        let f = filter(1_000);
333        let probe = id(999_999);
334        assert!(!may_contain(&f, &probe), "test needs a known-absent id");
335
336        assert!(may_contain(&[], &probe), "empty");
337        assert!(may_contain(&f[..HEADER - 1], &probe), "truncated header");
338        assert!(may_contain(&f[..f.len() - 8], &probe), "truncated body");
339
340        let mut bad = f.clone();
341        bad[0] = b'X';
342        assert!(may_contain(&bad, &probe), "wrong magic");
343
344        let mut bad = f.clone();
345        bad[4] = VERSION + 1;
346        assert!(may_contain(&bad, &probe), "future version");
347
348        // A word count of zero is the one header field that is not merely
349        // wrong but *unrepresentable*: `mask` is `words * 64 - 1`, so a filter
350        // that got this far with no bits at all would underflow — a panic in
351        // debug, a `u64::MAX` mask and an out-of-bounds body index in release.
352        // The power-of-two check is what stands between a single zeroed byte
353        // and that, so it is worth its own case rather than being folded into
354        // "truncated body".
355        let mut bad = f.clone();
356        bad[8..12].copy_from_slice(&0u32.to_le_bytes());
357        assert!(may_contain(&bad, &probe), "no bits at all");
358
359        // Not a power of two, so the `and` that stands in for the modulo would
360        // address words the body does not have.
361        let mut bad = f.clone();
362        bad[8..12].copy_from_slice(&3u32.to_le_bytes());
363        assert!(may_contain(&bad, &probe), "word count not a power of two");
364
365        // k = 0 probes nothing and therefore answers "no" to everything, which
366        // is the one answer a filter is never allowed to get wrong.
367        let mut bad = f.clone();
368        bad[5] = 0;
369        assert!(may_contain(&bad, &probe), "no hash functions");
370
371        // A single flipped bit in the bitmap is exactly the case a checksum
372        // exists for: it turns a "no" into a wrong "no" with nothing else to
373        // notice it.
374        let mut bad = f;
375        bad[HEADER + 3] ^= 0x40;
376        assert!(may_contain(&bad, &probe), "corrupt body");
377    }
378
379    /// [`halves`] reads bytes 0..8 and 8..16 of whatever it is handed, so a
380    /// column of any other width is an index out of bounds — a panic on the
381    /// seal path, which takes the process down with `panic = "abort"` and loses
382    /// the whole open block. Refusing to index it writes no file, and no file
383    /// means "scan the block".
384    #[test]
385    fn a_column_that_is_not_a_16_byte_id_indexes_nothing() {
386        let narrow = [[7u8; 8].as_slice(), [9u8; 8].as_slice()];
387        let arr = FixedSizeBinaryArray::try_from_iter(narrow.into_iter()).unwrap();
388        assert_eq!(arr.value_length(), 8);
389        assert!(build(&arr).is_none());
390        assert!(build(&FixedSizeBinaryArray::new_null(32, 4)).is_none());
391    }
392
393    /// Past [`MAX_KEYS`] the filter stops being a rounding error on the block,
394    /// so the writer gives up and writes nothing. Giving up has to fail *open*:
395    /// the reader sees a block with no sidecar, which is the same thing it sees
396    /// for a block published before the filter existed, and it scans.
397    ///
398    /// The boundary is asserted from both sides because the interesting bug is
399    /// off-by-one in the other direction — a filter dropped one key early is
400    /// invisible, since the answer is still correct and only slower.
401    #[test]
402    fn too_many_distinct_keys_write_no_filter_rather_than_a_huge_one() {
403        let mut keys = Keys::default();
404        for i in 0..MAX_KEYS as u64 {
405            keys.insert((i, i | 1));
406        }
407        assert!(keys.build().is_some(), "MAX_KEYS keys still fit");
408
409        keys.insert((u64::MAX, 1));
410        assert!(keys.build().is_none(), "one key past the cap");
411    }
412
413    /// The ids the load generator emits, and the shape every counter-derived or
414    /// timestamp-prefixed id has: constant bytes at both ends, variation in the
415    /// middle. Without the finalizer every one of these lands on the same bits
416    /// and the filter says "maybe" to everything — which is not a wrong answer,
417    /// just a 16 KB file that skips nothing.
418    #[test]
419    fn structured_ids_still_spread() {
420        let structured = |n: u64| {
421            let mut b = [0u8; 16];
422            b[..8].copy_from_slice(&(n as u32 as u64).to_be_bytes());
423            b[8..].copy_from_slice(&(0x5555_5555_5500_0000 | n).to_be_bytes());
424            b
425        };
426        let ids: Vec<[u8; 16]> = (0..10_000u64).map(structured).collect();
427        let arr = FixedSizeBinaryArray::try_from_iter(ids.iter().map(|v| v.as_slice())).unwrap();
428        let f = build(&arr).unwrap();
429
430        for i in 0..10_000u64 {
431            assert!(may_contain(&f, &structured(i)), "false negative at {i}");
432        }
433        let probes = 100_000u64;
434        let fp = (10_000..10_000 + probes)
435            .filter(|&i| may_contain(&f, &structured(i)))
436            .count();
437        let rate = fp as f64 / probes as f64;
438        assert!(rate < 0.02, "false positive rate {rate}");
439    }
440
441    /// Eight spans per trace is the ordinary shape of a block, and the filter
442    /// has to be sized for the traces, not the spans — otherwise every probe on
443    /// the read path pays 8x for nothing.
444    #[test]
445    fn adjacent_duplicates_do_not_inflate_the_filter() {
446        let ids: Vec<[u8; 16]> = (0..1_000u64).flat_map(|n| [id(n); 8]).collect();
447        let arr = FixedSizeBinaryArray::try_from_iter(ids.iter().map(|v| v.as_slice())).unwrap();
448        let fanned = build(&arr).unwrap();
449        assert_eq!(fanned.len(), filter(1_000).len());
450        for i in 0..1_000u64 {
451            assert!(may_contain(&fanned, &id(i)), "false negative at {i}");
452        }
453    }
454
455    /// An empty column writes no file, and the reader treats a missing one as
456    /// "scan the block" — the same path a block from an older writer takes.
457    #[test]
458    fn nothing_to_index_writes_nothing() {
459        assert!(build(&FixedSizeBinaryArray::new_null(16, 0)).is_none());
460        assert!(build(&FixedSizeBinaryArray::new_null(16, 100)).is_none());
461    }
462}