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}