Skip to main content

mira_core/
zone.rs

1//! Block-level numeric ranges — the zone map.
2//!
3//! [`crate::bloom`] answers "could this block hold *this exact value*", which is
4//! everything an equality needs and nothing an ordering can use. The queries
5//! that actually open a tracing UI are orderings: *"spans slower than a
6//! second"*, *"anything that returned 5xx"*. Both scan every block in retention
7//! today, because `duration_nano > 1_000_000_000` has no value to hash.
8//!
9//! So a second sidecar, holding one `(min, max)` pair per numeric thing the
10//! block contains — every numeric column of the root table, and every attribute
11//! key with a numeric value at any level. A term whose range cannot reach the
12//! block's is a block not opened. Reading it is a `read(2)` of a few kilobytes
13//! and a binary search, against the tens of megabytes it decides not to fault
14//! in.
15//!
16//! Three properties make it safe to skip a block on this file's word, and all
17//! three are the reason the build side is fussier than "call min and max":
18//!
19//! * **Absent key means prune.** The map is complete over the block, so a key
20//!   with no entry is a key with no comparable value — no row can satisfy an
21//!   ordering against it. That is only true because [`index`] is driven off the
22//!   schema, exactly as [`crate::attrs::index`] is: a signal that grows a fourth
23//!   attribute level is covered without anyone remembering it here.
24//! * **Strings that look like numbers are numbers.** `query::attr_matches`
25//!   parses a `str`-typed attribute before an ordered comparison, because half
26//!   the SDKs emit `http.response.status_code` as text. A range built only from
27//!   the `int` and `double` columns would therefore prune away blocks holding
28//!   `"503"`, so parseable text is folded into the double range.
29//! * **Strings that do not look like numbers fall back to a lexicographic
30//!   compare**, which no interval over the reals describes. A key with any of
31//!   those gets [`Range::ANY`] — the entry exists, and it answers "maybe" to
32//!   everything. It costs the pruning for that one key rather than the
33//!   correctness of the file.
34//!
35//! Two ranges per key rather than one, `i64` beside `f64`, because the query
36//! layer compares integers as integers: an `i64` past 2⁵³ rounds when it becomes
37//! a double, and rounding a *max* down is exactly the false negative this whole
38//! module is not allowed to produce. The pair costs 32 bytes on entries that
39//! number in the dozens.
40//!
41//! Like every sidecar here it fails open: unreadable, short or unrecognized is
42//! "scan the block".
43
44use std::collections::HashMap;
45
46use arrow_array::cast::AsArray;
47use arrow_array::types::{
48    Float64Type, Int32Type, Int64Type, TimestampNanosecondType, UInt8Type, UInt16Type, UInt32Type,
49    UInt64Type,
50};
51use arrow_array::{Array, RecordBatch};
52use arrow_schema::DataType;
53
54use crate::query::Op;
55use crate::schema::{ATTRS, AttrType};
56
57/// Filename inside a published block. Part of the on-disk format.
58pub const ZONE_IDX: &str = "zone.idx";
59
60const MAGIC: [u8; 4] = *b"MZON";
61const VERSION: u8 = 1;
62/// magic 4 | version 1 | pad 3 | entries 4 | crc32 4
63const HEADER: usize = 16;
64/// key 8 | int_min 8 | int_max 8 | dbl_min 8 | dbl_max 8
65const ENTRY: usize = 40;
66
67/// Beyond this many distinct keys the map stops being a rounding error on the
68/// block and a block that diverse prunes little anyway. Past it we write
69/// nothing, which the reader reads as "scan me".
70///
71/// 4,096 entries is 160 KB, against `bloom`'s 1M-key ceiling: the two are sized
72/// differently on purpose, because a Bloom filter degrades into false positives
73/// as it fills and this degrades into a linear amount of disk.
74const MAX_KEYS: usize = 4096;
75
76/// What one key's values span, in whichever of the two number lines they live
77/// on.
78///
79/// Empty is `min > max`, which every test below rejects without a special case:
80/// an `Lt` against a `min` of `i64::MAX` is false, a `Gt` against a `max` of
81/// `i64::MIN` is false, and `Eq` needs both.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct Range {
84    pub int_min: i64,
85    pub int_max: i64,
86    pub dbl_min: f64,
87    pub dbl_max: f64,
88}
89
90impl Range {
91    /// Nothing seen yet: both intervals empty.
92    const EMPTY: Range = Range {
93        int_min: i64::MAX,
94        int_max: i64::MIN,
95        dbl_min: f64::INFINITY,
96        dbl_max: f64::NEG_INFINITY,
97    };
98
99    /// "I cannot describe this key" — both intervals unbounded, so every probe
100    /// against it answers maybe. What a lexicographically-compared string
101    /// value degrades a key to.
102    pub const ANY: Range = Range {
103        int_min: i64::MIN,
104        int_max: i64::MAX,
105        dbl_min: f64::NEG_INFINITY,
106        dbl_max: f64::INFINITY,
107    };
108
109    fn int(&mut self, v: i64) {
110        self.int_min = self.int_min.min(v);
111        self.int_max = self.int_max.max(v);
112    }
113
114    /// NaN is dropped rather than folded in. `partial_cmp` returns `None`
115    /// against it, so no operator matches a NaN row and no interval has to
116    /// cover one — and `min`/`max` over a NaN would poison the interval that
117    /// covers the rest.
118    fn float(&mut self, v: f64) {
119        if !v.is_nan() {
120            self.dbl_min = self.dbl_min.min(v);
121            self.dbl_max = self.dbl_max.max(v);
122        }
123    }
124}
125
126/// One ordered term, in the form a zone map answers.
127///
128/// `int` and `float` are the same query scalar read two ways, because the row
129/// it will be compared against may be stored either way and the block holds
130/// both intervals. Absent means "no row of that number line can match", which
131/// is the query layer's own rule: `attr_matches`'s `INT` arm is
132/// `v.as_i64().is_some_and(..)`, so a fractional scalar never matches an
133/// integer column.
134pub struct Probe {
135    pub key: u64,
136    pub op: Op,
137    pub int: Option<i64>,
138    pub float: Option<f64>,
139}
140
141impl Probe {
142    /// Could a row in this block satisfy the term? A key the map does not hold
143    /// is a key the block has no comparable value for, so the answer is no —
144    /// see the module docs for why that is sound and not merely convenient.
145    pub fn maybe(&self, m: &Map) -> bool {
146        match m.get(self.key) {
147            None => false,
148            Some(r) => {
149                self.int
150                    .is_some_and(|t| reachable(self.op, r.int_min, r.int_max, t))
151                    || self
152                        .float
153                        .is_some_and(|t| reachable(self.op, r.dbl_min, r.dbl_max, t))
154            }
155        }
156    }
157}
158
159/// Could any value in `[min, max]` satisfy `x op target`?
160///
161/// `Ne` and `Contains` are deliberately absent from the callers rather than
162/// answered here: a range only rules `Ne` out when it is a single point, which
163/// is a rounding error's worth of pruning for a branch that is easy to get
164/// backwards.
165fn reachable<T: PartialOrd + Copy>(op: Op, min: T, max: T, target: T) -> bool {
166    match op {
167        Op::Eq => min <= target && target <= max,
168        Op::Lt => min < target,
169        Op::Lte => min <= target,
170        Op::Gt => max > target,
171        Op::Gte => max >= target,
172        Op::Ne | Op::Contains => true,
173    }
174}
175
176/// Accumulates the ranges of one block.
177#[derive(Default)]
178pub struct Builder {
179    keys: HashMap<u64, Range>,
180    full: bool,
181}
182
183impl Builder {
184    fn at(&mut self, key: u64) -> Option<&mut Range> {
185        if !self.keys.contains_key(&key) && self.keys.len() >= MAX_KEYS {
186            self.full = true;
187            return None;
188        }
189        Some(self.keys.entry(key).or_insert(Range::EMPTY))
190    }
191
192    pub fn int(&mut self, key: u64, v: i64) {
193        if let Some(r) = self.at(key) {
194            r.int(v);
195        }
196    }
197
198    pub fn float(&mut self, key: u64, v: f64) {
199        if let Some(r) = self.at(key) {
200            r.float(v);
201        }
202    }
203
204    /// Give up on one key: it holds something no interval describes.
205    pub fn any(&mut self, key: u64) {
206        if let Some(r) = self.at(key) {
207            *r = Range::ANY;
208        }
209    }
210
211    /// `None` when there is nothing to say, or too much of it. Both mean no
212    /// file, which the reader reads as "scan me".
213    pub fn build(&self) -> Option<Vec<u8>> {
214        if self.full || self.keys.is_empty() {
215            return None;
216        }
217        // Sorted, so the reader binary-searches instead of holding a hash map
218        // it would have to allocate per block probed.
219        let mut entries: Vec<(&u64, &Range)> = self.keys.iter().collect();
220        entries.sort_unstable_by_key(|(k, _)| **k);
221
222        let mut body = Vec::with_capacity(entries.len() * ENTRY);
223        for (k, r) in entries {
224            body.extend_from_slice(&k.to_le_bytes());
225            body.extend_from_slice(&r.int_min.to_le_bytes());
226            body.extend_from_slice(&r.int_max.to_le_bytes());
227            body.extend_from_slice(&r.dbl_min.to_le_bytes());
228            body.extend_from_slice(&r.dbl_max.to_le_bytes());
229        }
230
231        let mut out = Vec::with_capacity(HEADER + body.len());
232        out.extend_from_slice(&MAGIC);
233        out.push(VERSION);
234        out.extend_from_slice(&[0, 0, 0]);
235        out.extend_from_slice(&(self.keys.len() as u32).to_le_bytes());
236        out.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
237        out.extend_from_slice(&body);
238        Some(out)
239    }
240}
241
242/// A zone map checked out, with its header validated once.
243pub struct Map<'a> {
244    body: &'a [u8],
245    n: usize,
246}
247
248impl<'a> Map<'a> {
249    /// `None` for anything unreadable, which every caller must treat as "scan
250    /// the block".
251    pub fn open(file: &'a [u8]) -> Option<Map<'a>> {
252        if file.len() < HEADER || file[..4] != MAGIC || file[4] != VERSION {
253            return None;
254        }
255        let n = u32::from_le_bytes(file[8..12].try_into().expect("4 bytes")) as usize;
256        let crc = u32::from_le_bytes(file[12..16].try_into().expect("4 bytes"));
257        let body = &file[HEADER..];
258        if n == 0 || body.len() != n * ENTRY || crc32fast::hash(body) != crc {
259            return None;
260        }
261        Some(Map { body, n })
262    }
263
264    fn key_at(&self, i: usize) -> u64 {
265        u64::from_le_bytes(self.body[i * ENTRY..][..8].try_into().expect("8 bytes"))
266    }
267
268    /// Binary search over the packed entries by hand: the body is bytes, not
269    /// `Range`s, so `slice::binary_search` has nothing to search.
270    fn get(&self, key: u64) -> Option<Range> {
271        let (mut lo, mut hi) = (0usize, self.n);
272        while lo < hi {
273            let mid = lo + (hi - lo) / 2;
274            match self.key_at(mid).cmp(&key) {
275                std::cmp::Ordering::Less => lo = mid + 1,
276                std::cmp::Ordering::Greater => hi = mid,
277                std::cmp::Ordering::Equal => {
278                    lo = mid;
279                    break;
280                }
281            }
282        }
283        if lo >= self.n || self.key_at(lo) != key {
284            return None;
285        }
286        let i = lo;
287        let f = |off: usize| {
288            self.body[i * ENTRY + off..][..8]
289                .try_into()
290                .expect("8 bytes")
291        };
292        Some(Range {
293            int_min: i64::from_le_bytes(f(8)),
294            int_max: i64::from_le_bytes(f(16)),
295            dbl_min: f64::from_le_bytes(f(24)),
296            dbl_max: f64::from_le_bytes(f(32)),
297        })
298    }
299}
300
301/// Hash of an attribute key, in its own domain so that an attribute and a root
302/// column of the same name do not share an entry.
303///
304/// A collision between two attribute keys merges their ranges, which widens one
305/// and prunes less. Widening is the safe direction, which is why 64 bits is
306/// enough here and why there is no need to store the key text.
307pub fn attr_key(key: &str) -> u64 {
308    mix(crate::identity::hash64(key.as_bytes()) ^ 0xa77b_a77b_a77b_a77b)
309}
310
311/// Hash of a root-table column name. See [`attr_key`].
312pub fn field_key(name: &str) -> u64 {
313    mix(crate::identity::hash64(name.as_bytes()) ^ 0xf1e1_f1e1_f1e1_f1e1)
314}
315
316/// splitmix64's finalizer, as in [`crate::bloom`]: the domain constant above
317/// only separates the two spaces if every input bit reaches every output bit.
318fn mix(mut x: u64) -> u64 {
319    x ^= x >> 30;
320    x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
321    x ^= x >> 27;
322    x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
323    x ^ (x >> 31)
324}
325
326/// Build a block's [`ZONE_IDX`] over its root columns and every attribute table
327/// in it.
328///
329/// `tables[0]` is the root by the convention every signal's `finish` follows —
330/// it is also the table `query::Block` opens under the signal's own name, so
331/// the two agree by construction.
332pub fn index(tables: &[(&'static str, RecordBatch)]) -> Option<Vec<u8>> {
333    let mut b = Builder::default();
334    if let Some((_, root)) = tables.first() {
335        index_root(&mut b, root);
336    }
337    for (_, t) in tables {
338        if std::sync::Arc::ptr_eq(&t.schema(), &ATTRS) {
339            index_attrs(&mut b, t);
340        }
341    }
342    b.build()
343}
344
345/// Every numeric column of the root table, under [`field_key`].
346///
347/// The integer arms cast to `i64` exactly as `query::field_pred` does, wrapping
348/// included: a range built any other way would describe a different comparison
349/// than the one the scan performs, and disagreeing with the scan is the whole
350/// failure mode.
351fn index_root(b: &mut Builder, root: &RecordBatch) {
352    macro_rules! ints {
353        ($t:ty, $col:expr, $key:expr) => {{
354            let a = $col.as_primitive::<$t>();
355            for i in 0..a.len() {
356                if !a.is_null(i) {
357                    b.int($key, a.value(i) as i64);
358                }
359            }
360        }};
361    }
362
363    for (f, col) in root.schema().fields().iter().zip(root.columns()) {
364        let key = field_key(f.name());
365        match col.data_type() {
366            DataType::Timestamp(_, _) => ints!(TimestampNanosecondType, col, key),
367            DataType::Int64 => ints!(Int64Type, col, key),
368            DataType::Int32 => ints!(Int32Type, col, key),
369            DataType::UInt64 => ints!(UInt64Type, col, key),
370            DataType::UInt32 => ints!(UInt32Type, col, key),
371            DataType::UInt16 => ints!(UInt16Type, col, key),
372            DataType::UInt8 => ints!(UInt8Type, col, key),
373            DataType::Float64 => {
374                let a = col.as_primitive::<Float64Type>();
375                for i in 0..a.len() {
376                    if !a.is_null(i) {
377                        b.float(key, a.value(i));
378                    }
379                }
380            }
381            // Not orderable as a number, and therefore not prunable by one: a
382            // numeric term against a string, boolean or id column matches
383            // nothing at all (`field_pred` returns `None` and the scan clears
384            // its selection), so leaving the column out of the map is not just
385            // safe, it is the same answer arrived at earlier.
386            _ => {}
387        }
388    }
389}
390
391/// Every attribute key with a comparable value, under [`attr_key`]. Column
392/// positions match `attrs::index_table`, which walks the same schema.
393fn index_attrs(b: &mut Builder, t: &RecordBatch) {
394    let dict = t.column(1).as_dictionary::<UInt16Type>();
395    let names = dict.values().as_string::<i32>();
396    let codes = dict.keys().values();
397    let types = t.column(2).as_primitive::<UInt8Type>().values();
398    let strs = crate::attrs::str_column(t);
399    let ints = t.column(4).as_primitive::<Int64Type>();
400    let doubles = t.column(5).as_primitive::<Float64Type>();
401
402    const STR: u8 = AttrType::Str as u8;
403    const INT: u8 = AttrType::Int as u8;
404    const DOUBLE: u8 = AttrType::Double as u8;
405
406    // Hashing the key text once per *row* would be most of the cost of this
407    // pass, and the dictionary is a few dozen entries against a few hundred
408    // thousand rows.
409    let hashes: Vec<u64> = (0..names.len()).map(|i| attr_key(names.value(i))).collect();
410
411    for row in 0..t.num_rows() {
412        let key = hashes[codes[row] as usize];
413        match types[row] {
414            INT => b.int(key, ints.value(row)),
415            DOUBLE => b.float(key, doubles.value(row)),
416            STR => match strs.value(row).parse::<f64>() {
417                // The same parse `attr_matches` performs before an ordered
418                // comparison, so the interval covers exactly the rows the scan
419                // would compare numerically.
420                Ok(v) => b.float(key, v),
421                // And the rows it would compare lexicographically, which no
422                // interval covers.
423                Err(_) => b.any(key),
424            },
425            // Bool, Bytes, Slice, Map and Empty match no numeric term at all —
426            // `v.as_bool()` is `None` for a number and the rest are not
427            // filterable — so they contribute nothing and rule nothing out.
428            _ => {}
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use std::sync::Arc;
436
437    use arrow_array::{Float64Array, Int64Array, RecordBatch, StringArray, UInt64Array};
438    use arrow_schema::{Field, Schema};
439
440    use super::*;
441
442    fn map_of(b: &Builder) -> Vec<u8> {
443        b.build().expect("something to write")
444    }
445
446    fn probe(key: u64, op: Op, int: Option<i64>, float: Option<f64>) -> Probe {
447        Probe {
448            key,
449            op,
450            int,
451            float,
452        }
453    }
454
455    #[test]
456    fn a_range_answers_the_five_ordered_operators_and_nothing_else() {
457        let mut b = Builder::default();
458        let k = attr_key("http.status_code");
459        b.int(k, 200);
460        b.int(k, 404);
461        let bytes = map_of(&b);
462        let m = Map::open(&bytes).expect("readable");
463
464        let ask = |op, t: i64| probe(k, op, Some(t), Some(t as f64)).maybe(&m);
465        assert!(ask(Op::Eq, 200) && ask(Op::Eq, 300) && !ask(Op::Eq, 500));
466        assert!(ask(Op::Gte, 404) && !ask(Op::Gte, 405));
467        assert!(ask(Op::Gt, 403) && !ask(Op::Gt, 404));
468        assert!(ask(Op::Lte, 200) && !ask(Op::Lte, 199));
469        assert!(ask(Op::Lt, 201) && !ask(Op::Lt, 200));
470        // Ne and Contains are never pruned on, whatever the range says.
471        assert!(ask(Op::Ne, 200) && ask(Op::Contains, 999));
472    }
473
474    #[test]
475    fn a_key_the_block_never_saw_prunes_and_an_unreadable_file_does_not() {
476        let mut b = Builder::default();
477        b.int(attr_key("present"), 1);
478        let bytes = map_of(&b);
479        let m = Map::open(&bytes).expect("readable");
480        assert!(probe(attr_key("present"), Op::Gte, Some(0), Some(0.0)).maybe(&m));
481        assert!(!probe(attr_key("absent"), Op::Gte, Some(0), Some(0.0)).maybe(&m));
482
483        // Every way the file can be wrong reads as "no map", never as "skip".
484        assert!(Map::open(&[]).is_none());
485        assert!(Map::open(&bytes[..HEADER]).is_none());
486        let mut torn = bytes.clone();
487        torn.pop();
488        assert!(Map::open(&torn).is_none());
489        let mut flipped = bytes.clone();
490        *flipped.last_mut().expect("non-empty") ^= 0xff;
491        assert!(Map::open(&flipped).is_none(), "the crc has to catch this");
492        let mut version = bytes.clone();
493        version[4] = 2;
494        assert!(Map::open(&version).is_none());
495    }
496
497    #[test]
498    fn an_integer_past_two_to_the_fifty_three_keeps_its_own_number_line() {
499        // 2^53 + 1 is the first integer a double cannot hold. Folded into the
500        // double range it would round *down*, and a `gte` on the exact value
501        // would then prune the block that holds it.
502        let v = (1i64 << 53) + 1;
503        let mut b = Builder::default();
504        let k = attr_key("bytes");
505        b.int(k, v);
506        let bytes = map_of(&b);
507        let m = Map::open(&bytes).expect("readable");
508        assert!(probe(k, Op::Gte, Some(v), Some(v as f64)).maybe(&m));
509        assert!(!probe(k, Op::Gt, Some(v), Some(v as f64)).maybe(&m));
510    }
511
512    #[test]
513    fn a_fractional_scalar_cannot_reach_an_integer_only_key() {
514        let mut b = Builder::default();
515        let k = attr_key("retries");
516        b.int(k, 3);
517        let bytes = map_of(&b);
518        let m = Map::open(&bytes).expect("readable");
519        // `Value::as_i64` returns None for 3.5, exactly as the scan's INT arm
520        // does, so the int range is not consulted and the double range is empty.
521        assert!(!probe(k, Op::Eq, None, Some(3.5)).maybe(&m));
522        assert!(!probe(k, Op::Lt, None, Some(3.5)).maybe(&m));
523    }
524
525    #[test]
526    fn text_that_parses_is_a_number_and_text_that_does_not_gives_up_the_key() {
527        let attrs = |vals: Vec<&str>| {
528            let mut a = crate::attrs::AttrsBuilder::new("t");
529            for v in vals {
530                a.append(
531                    0,
532                    "code",
533                    Some(&mira_proto::common::v1::AnyValue {
534                        value: Some(mira_proto::common::v1::any_value::Value::StringValue(
535                            v.into(),
536                        )),
537                    }),
538                )
539                .expect("appends");
540            }
541            vec![("t", a.finish().expect("finishes"))]
542        };
543
544        let k = attr_key("code");
545        let numeric = index(&attrs(vec!["200", "503"])).expect("a map");
546        let m = Map::open(&numeric).expect("readable");
547        assert!(probe(k, Op::Gte, Some(500), Some(500.0)).maybe(&m));
548        assert!(!probe(k, Op::Gt, Some(503), Some(503.0)).maybe(&m));
549
550        // One value the scan would compare as text, and the key stops pruning —
551        // for that key only.
552        let mixed = index(&attrs(vec!["200", "unset"])).expect("a map");
553        let m = Map::open(&mixed).expect("readable");
554        assert!(probe(k, Op::Gt, Some(9999), Some(9999.0)).maybe(&m));
555    }
556
557    #[test]
558    fn the_root_tables_numeric_columns_are_in_it_and_the_others_are_not() {
559        let schema = Arc::new(Schema::new(vec![
560            Field::new("duration_nano", DataType::UInt64, false),
561            Field::new("count", DataType::Int64, true),
562            Field::new("ratio", DataType::Float64, false),
563            Field::new("body", DataType::Utf8, false),
564        ]));
565        let root = RecordBatch::try_new(
566            schema,
567            vec![
568                Arc::new(UInt64Array::from(vec![10u64, 2_000_000_000])),
569                // All null: nothing can match, and the empty range says so.
570                Arc::new(Int64Array::from(vec![None, None] as Vec<Option<i64>>)),
571                Arc::new(Float64Array::from(vec![0.25, f64::NAN])),
572                Arc::new(StringArray::from(vec!["a", "b"])),
573            ],
574        )
575        .expect("a batch");
576
577        let bytes = index(&[("root", root)]).expect("a map");
578        let m = Map::open(&bytes).expect("readable");
579
580        let d = field_key("duration_nano");
581        assert!(probe(d, Op::Gt, Some(1_000_000_000), Some(1e9)).maybe(&m));
582        assert!(!probe(d, Op::Gt, Some(2_000_000_000), Some(2e9)).maybe(&m));
583
584        // Present but empty: an all-null column matches nothing, and a column
585        // no numeric term can compare is not in the map at all. Both prune.
586        assert!(!probe(field_key("count"), Op::Gte, Some(0), Some(0.0)).maybe(&m));
587        assert!(!probe(field_key("body"), Op::Gte, Some(0), Some(0.0)).maybe(&m));
588
589        // NaN never matches an operator, so it is not in the interval either.
590        let r = field_key("ratio");
591        assert!(probe(r, Op::Lte, None, Some(0.25)).maybe(&m));
592        assert!(!probe(r, Op::Gt, None, Some(0.25)).maybe(&m));
593    }
594
595    #[test]
596    fn too_many_keys_writes_nothing_rather_than_a_map_nobody_wants() {
597        let mut b = Builder::default();
598        for i in 0..=MAX_KEYS {
599            b.int(attr_key(&format!("k{i}")), i as i64);
600        }
601        assert!(b.build().is_none(), "over the cap, so no file");
602        assert!(Builder::default().build().is_none(), "nothing to say");
603    }
604}