Skip to main content

mira_core/
metrics.rs

1//! OTLP metrics -> Arrow. The widest of the three signals, and the one where
2//! the layout choice is worth the most.
3//!
4//! # Why four point tables instead of one
5//!
6//! OTLP has five metric types carrying four incompatible point shapes. The
7//! obvious layout is one wide `data_points` table with every column any point
8//! type might need and nulls everywhere else. Measured on 300,000 points in the
9//! usual mix (90% number, 8% histogram, 1% exponential, 1% summary):
10//!
11//! ```text
12//! one wide table   170.6 B/point
13//! four split tables 73.0 B/point   2.34x
14//! ```
15//!
16//! Nulls are not free in Arrow. A histogram's `bucket_counts` list column still
17//! costs an offset entry on every one of the 270,000 number points that will
18//! never have buckets, and the validity bitmaps stack up column by column. The
19//! four tables also let a "graph this counter" query touch `number_dp` alone.
20//!
21//! Two more measurements shaped the histogram tables. Flattening `bucket_counts`
22//! into a child table costs 1.47x what the `List<UInt64>` column costs, because a
23//! child row pays a 4-byte parent id per bucket where the list pays one 4-byte
24//! offset per point. And interning `explicit_bounds` into a side table takes
25//! `hist_dp` from 410 to 246 B/row — every point of a histogram repeats the same
26//! boundaries, which is what makes it the same histogram.
27//!
28//! # One id space for points
29//!
30//! `dp_attrs` and `exemplars` both key on a data point, and a point lives in one
31//! of four tables. Rather than a discriminant column saying which, the four
32//! tables draw `id` from a single counter, so a point id names exactly one row
33//! in exactly one table. Attribute filtering — which is 68% of a metrics block
34//! by size, measured — is then one semi-join instead of four.
35//!
36//! Ids stay ascending within each table, so the join is a binary search rather
37//! than the direct index the logs and spans tables allow. Points of one metric
38//! arrive together, so in practice the ids being searched are a contiguous run.
39
40use std::collections::HashMap;
41use std::sync::Arc;
42
43use arrow_array::builder::{
44    BooleanBuilder, FixedSizeBinaryBuilder, Float64Builder, Int32Builder, Int64Builder,
45    ListBuilder, StringBuilder, TimestampNanosecondBuilder, UInt8Builder, UInt16Builder,
46    UInt32Builder, UInt64Builder,
47};
48use arrow_array::{ArrayRef, RecordBatch};
49
50use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
51use mira_proto::metrics::v1::exponential_histogram_data_point::Buckets;
52use mira_proto::metrics::v1::metric::Data;
53use mira_proto::metrics::v1::{
54    Exemplar, ExponentialHistogramDataPoint, HistogramDataPoint, Metric, NumberDataPoint,
55    SummaryDataPoint, exemplar, number_data_point,
56};
57
58use crate::attrs::{AttrsBuilder, DictColumn, ResourceScope, resource_kv, scope_kv};
59use crate::error::Result;
60use crate::logs::{append_fixed, nanos};
61use crate::schema::{
62    EXEMPLARS, EXP_HIST_DP, HIST_BOUNDS, HIST_DP, METRICS, MetricKind, NUMBER_DP, SUMMARY_DP,
63};
64use crate::signal::{Sealed, Sidecars, SignalBuilder};
65
66/// The five columns every point table starts with. Grouped so that the four
67/// tables cannot drift apart, which would make a temporal filter four functions.
68struct DpHead {
69    id: UInt32Builder,
70    metric_id: UInt32Builder,
71    start: TimestampNanosecondBuilder,
72    time: TimestampNanosecondBuilder,
73    flags: UInt32Builder,
74}
75
76impl DpHead {
77    fn new() -> Self {
78        Self {
79            id: UInt32Builder::new(),
80            metric_id: UInt32Builder::new(),
81            start: TimestampNanosecondBuilder::new(),
82            time: TimestampNanosecondBuilder::new(),
83            flags: UInt32Builder::new(),
84        }
85    }
86
87    fn append(&mut self, id: u32, metric_id: u32, start: u64, time: u64, flags: u32) {
88        self.id.append_value(id);
89        self.metric_id.append_value(metric_id);
90        // `nanos` reads an unrepresentable timestamp as the wire's own "unset",
91        // so the null branch already here covers a broken clock too.
92        let start = nanos(start);
93        if start != 0 {
94            self.start.append_value(start);
95        } else {
96            self.start.append_null();
97        }
98        self.time.append_value(nanos(time));
99        self.flags.append_value(flags);
100    }
101
102    fn finish(&self) -> Vec<ArrayRef> {
103        vec![
104            Arc::new(self.id.finish_cloned()),
105            Arc::new(self.metric_id.finish_cloned()),
106            Arc::new(self.start.finish_cloned()),
107            Arc::new(self.time.finish_cloned()),
108            Arc::new(self.flags.finish_cloned()),
109        ]
110    }
111}
112
113/// `count`/`sum`/`min`/`max`, shared by the three aggregating point types.
114struct Stats {
115    count: UInt64Builder,
116    sum: Float64Builder,
117    min: Float64Builder,
118    max: Float64Builder,
119}
120
121impl Stats {
122    fn new() -> Self {
123        Self {
124            count: UInt64Builder::new(),
125            sum: Float64Builder::new(),
126            min: Float64Builder::new(),
127            max: Float64Builder::new(),
128        }
129    }
130
131    fn append(&mut self, count: u64, sum: Option<f64>, min: Option<f64>, max: Option<f64>) {
132        self.count.append_value(count);
133        self.sum.append_option(sum);
134        self.min.append_option(min);
135        self.max.append_option(max);
136    }
137
138    fn finish(&self) -> Vec<ArrayRef> {
139        vec![
140            Arc::new(self.count.finish_cloned()),
141            Arc::new(self.sum.finish_cloned()),
142            Arc::new(self.min.finish_cloned()),
143            Arc::new(self.max.finish_cloned()),
144        ]
145    }
146}
147
148pub struct MetricsBuilder {
149    // metrics descriptors
150    m_id: UInt32Builder,
151    m_name: DictColumn,
152    m_description: StringBuilder,
153    m_unit: DictColumn,
154    m_kind: UInt8Builder,
155    m_temporality: UInt8Builder,
156    m_monotonic: BooleanBuilder,
157    m_resource_id: UInt16Builder,
158    m_scope_id: UInt16Builder,
159    metric_attrs: AttrsBuilder,
160    next_metric_id: u32,
161
162    num: DpHead,
163    num_int: Int64Builder,
164    num_double: Float64Builder,
165
166    hist: DpHead,
167    hist_stats: Stats,
168    hist_counts: ListBuilder<UInt64Builder>,
169    hist_bounds_id: UInt32Builder,
170
171    /// `explicit_bounds` interned by their bit patterns. f64 has no `Hash` and
172    /// `-0.0 == 0.0` while their bits differ, so the key is the raw bits: two
173    /// bound arrays share a row only if they are byte-identical, which is the
174    /// conservative direction (a missed intern costs space, a wrong one would
175    /// mislabel every bucket).
176    bounds_index: HashMap<Vec<u64>, u32>,
177    bounds_id: UInt32Builder,
178    bounds_values: ListBuilder<Float64Builder>,
179    next_bounds_id: u32,
180
181    exp: DpHead,
182    exp_stats: Stats,
183    exp_scale: Int32Builder,
184    exp_zero_count: UInt64Builder,
185    exp_zero_threshold: Float64Builder,
186    exp_pos_offset: Int32Builder,
187    exp_pos_counts: ListBuilder<UInt64Builder>,
188    exp_neg_offset: Int32Builder,
189    exp_neg_counts: ListBuilder<UInt64Builder>,
190
191    summ: DpHead,
192    summ_count: UInt64Builder,
193    summ_sum: Float64Builder,
194    summ_quantile: ListBuilder<Float64Builder>,
195    summ_value: ListBuilder<Float64Builder>,
196
197    dp_attrs: AttrsBuilder,
198    /// One counter across all four point tables — see the module header.
199    next_dp_id: u32,
200
201    ex_id: UInt32Builder,
202    ex_parent: UInt32Builder,
203    ex_time: TimestampNanosecondBuilder,
204    ex_int: Int64Builder,
205    ex_double: Float64Builder,
206    ex_trace_id: FixedSizeBinaryBuilder,
207    ex_span_id: FixedSizeBinaryBuilder,
208    exemplar_attrs: AttrsBuilder,
209    next_exemplar_id: u32,
210
211    rs: ResourceScope,
212    /// List elements written so far, for `approx_bytes`. Tracked rather than
213    /// measured because `ListBuilder::values` needs `&mut self`.
214    list_values: usize,
215    min_ts: i64,
216    max_ts: i64,
217}
218
219impl Default for MetricsBuilder {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225impl MetricsBuilder {
226    pub fn new() -> Self {
227        Self {
228            m_id: UInt32Builder::new(),
229            m_name: DictColumn::new("metrics.name"),
230            m_description: StringBuilder::new(),
231            m_unit: DictColumn::new("metrics.unit"),
232            m_kind: UInt8Builder::new(),
233            m_temporality: UInt8Builder::new(),
234            m_monotonic: BooleanBuilder::new(),
235            m_resource_id: UInt16Builder::new(),
236            m_scope_id: UInt16Builder::new(),
237            metric_attrs: AttrsBuilder::new("metric_attrs.key"),
238            next_metric_id: 0,
239
240            num: DpHead::new(),
241            num_int: Int64Builder::new(),
242            num_double: Float64Builder::new(),
243
244            hist: DpHead::new(),
245            hist_stats: Stats::new(),
246            hist_counts: ListBuilder::new(UInt64Builder::new()),
247            hist_bounds_id: UInt32Builder::new(),
248
249            bounds_index: HashMap::new(),
250            bounds_id: UInt32Builder::new(),
251            bounds_values: ListBuilder::new(Float64Builder::new()),
252            next_bounds_id: 0,
253
254            exp: DpHead::new(),
255            exp_stats: Stats::new(),
256            exp_scale: Int32Builder::new(),
257            exp_zero_count: UInt64Builder::new(),
258            exp_zero_threshold: Float64Builder::new(),
259            exp_pos_offset: Int32Builder::new(),
260            exp_pos_counts: ListBuilder::new(UInt64Builder::new()),
261            exp_neg_offset: Int32Builder::new(),
262            exp_neg_counts: ListBuilder::new(UInt64Builder::new()),
263
264            summ: DpHead::new(),
265            summ_count: UInt64Builder::new(),
266            summ_sum: Float64Builder::new(),
267            summ_quantile: ListBuilder::new(Float64Builder::new()),
268            summ_value: ListBuilder::new(Float64Builder::new()),
269
270            dp_attrs: AttrsBuilder::new("dp_attrs.key"),
271            next_dp_id: 0,
272
273            ex_id: UInt32Builder::new(),
274            ex_parent: UInt32Builder::new(),
275            ex_time: TimestampNanosecondBuilder::new(),
276            ex_int: Int64Builder::new(),
277            ex_double: Float64Builder::new(),
278            ex_trace_id: FixedSizeBinaryBuilder::new(16),
279            ex_span_id: FixedSizeBinaryBuilder::new(8),
280            exemplar_attrs: AttrsBuilder::new("exemplar_attrs.key"),
281            next_exemplar_id: 0,
282
283            rs: ResourceScope::new(),
284            list_values: 0,
285            min_ts: i64::MAX,
286            max_ts: i64::MIN,
287        }
288    }
289
290    /// Data points, across all four tables. This is the row count the flusher
291    /// reports and the number that matters — descriptors are a rounding error.
292    pub fn num_rows(&self) -> usize {
293        self.next_dp_id as usize
294    }
295
296    pub fn is_empty(&self) -> bool {
297        self.next_metric_id == 0
298    }
299
300    pub fn approx_bytes(&self) -> usize {
301        self.next_dp_id as usize * 48
302            + self.next_metric_id as usize * 32
303            + self.next_exemplar_id as usize * 56
304            + self.list_values * 8
305            + (self.dp_attrs.len()
306                + self.metric_attrs.len()
307                + self.exemplar_attrs.len()
308                + self.rs.len())
309                * 48
310            + self.m_description.values_slice().len()
311            + self.dp_attrs.heap_bytes()
312            + self.metric_attrs.heap_bytes()
313            + self.exemplar_attrs.heap_bytes()
314            + self.rs.heap_bytes()
315    }
316
317    pub fn has_headroom_for(&self, req: &ExportMetricsServiceRequest) -> bool {
318        let (mut resources, mut scopes) = (0usize, 0usize);
319        let (mut res_kv, mut sc_kv) = (0usize, 0usize);
320        let (mut names, mut meta_kv, mut dp_kv, mut ex_kv) = (0usize, 0usize, 0usize, 0usize);
321        for rm in &req.resource_metrics {
322            resources += 1;
323            res_kv += resource_kv(rm.resource.as_ref());
324            for sm in &rm.scope_metrics {
325                scopes += 1;
326                sc_kv += scope_kv(sm.scope.as_ref());
327                names += sm.metrics.len();
328                for m in &sm.metrics {
329                    meta_kv += m.metadata.len();
330                    for_each_point(m, |attrs, exemplars| {
331                        dp_kv += attrs;
332                        ex_kv += exemplars;
333                    });
334                }
335            }
336        }
337        self.rs.has_headroom(resources, scopes, res_kv, sc_kv)
338            && self.metric_attrs.has_headroom(meta_kv)
339            && self.dp_attrs.has_headroom(dp_kv)
340            && self.exemplar_attrs.has_headroom(ex_kv)
341            // Name and unit both draw from `names` because a metric contributes
342            // at most one new entry to each.
343            && self.m_name.has_headroom(names)
344            && self.m_unit.has_headroom(names)
345    }
346
347    pub fn append_request(&mut self, req: &ExportMetricsServiceRequest) -> Result<usize> {
348        let mut added = 0;
349        for rm in &req.resource_metrics {
350            let rid = self.rs.resource(rm.resource.as_ref())?;
351            for sm in &rm.scope_metrics {
352                let sid = self.rs.scope(sm.scope.as_ref())?;
353                for m in &sm.metrics {
354                    added += self.append_metric(m, rid, sid)?;
355                }
356            }
357        }
358        Ok(added)
359    }
360
361    fn append_metric(&mut self, m: &Metric, rid: u16, sid: u16) -> Result<usize> {
362        // Both dictionaries before anything else is written, for the reason in
363        // `AttrsBuilder::append`.
364        self.m_name.append(&m.name)?;
365        self.m_unit.append(&m.unit)?;
366
367        let mid = self.next_metric_id;
368        self.next_metric_id += 1;
369
370        // Temporality and monotonicity live on the wrapper message, not the
371        // point, and only Sum has both. Flattening them onto the descriptor is
372        // what lets the point tables be four columns narrower.
373        let (kind, temporality, monotonic) = match &m.data {
374            None => (MetricKind::Unset, 0, false),
375            Some(Data::Gauge(_)) => (MetricKind::Gauge, 0, false),
376            Some(Data::Sum(s)) => (
377                MetricKind::Sum,
378                clamp_u8(s.aggregation_temporality, 2),
379                s.is_monotonic,
380            ),
381            Some(Data::Histogram(h)) => (
382                MetricKind::Histogram,
383                clamp_u8(h.aggregation_temporality, 2),
384                false,
385            ),
386            Some(Data::ExponentialHistogram(h)) => (
387                MetricKind::ExponentialHistogram,
388                clamp_u8(h.aggregation_temporality, 2),
389                false,
390            ),
391            Some(Data::Summary(_)) => (MetricKind::Summary, 0, false),
392        };
393
394        self.m_id.append_value(mid);
395        if m.description.is_empty() {
396            self.m_description.append_null();
397        } else {
398            self.m_description.append_value(&m.description);
399        }
400        self.m_kind.append_value(kind as u8);
401        self.m_temporality.append_value(temporality);
402        self.m_monotonic.append_value(monotonic);
403        self.m_resource_id.append_value(rid);
404        self.m_scope_id.append_value(sid);
405        self.metric_attrs.append_all(mid, &m.metadata)?;
406
407        let mut added = 0;
408        match &m.data {
409            None => {}
410            Some(Data::Gauge(g)) => {
411                for p in &g.data_points {
412                    self.append_number(p, mid)?;
413                    added += 1;
414                }
415            }
416            Some(Data::Sum(s)) => {
417                for p in &s.data_points {
418                    self.append_number(p, mid)?;
419                    added += 1;
420                }
421            }
422            Some(Data::Histogram(h)) => {
423                for p in &h.data_points {
424                    self.append_hist(p, mid)?;
425                    added += 1;
426                }
427            }
428            Some(Data::ExponentialHistogram(h)) => {
429                for p in &h.data_points {
430                    self.append_exp_hist(p, mid)?;
431                    added += 1;
432                }
433            }
434            Some(Data::Summary(s)) => {
435                for p in &s.data_points {
436                    self.append_summary(p, mid)?;
437                    added += 1;
438                }
439            }
440        }
441        Ok(added)
442    }
443
444    /// Claim the next point id and fold its timestamp into the block's range.
445    ///
446    /// Only `time_unix_nano` widens the range. See [`crate::schema::NUMBER_DP`]
447    /// for why `start_time_unix_nano` must not.
448    fn next_point(&mut self, time: u64) -> u32 {
449        let id = self.next_dp_id;
450        self.next_dp_id += 1;
451        // Same guard for a missing clock and an unrepresentable one: a point past
452        // 2^63 would wrap negative, and a negative `min_ts` publishes a block
453        // directory `block::parse_dir_name` refuses. See [`crate::logs::nanos`].
454        let time = nanos(time);
455        if time != 0 {
456            self.min_ts = self.min_ts.min(time);
457            self.max_ts = self.max_ts.max(time);
458        }
459        id
460    }
461
462    fn append_number(&mut self, p: &NumberDataPoint, mid: u32) -> Result<()> {
463        let id = self.next_point(p.time_unix_nano);
464        self.num
465            .append(id, mid, p.start_time_unix_nano, p.time_unix_nano, p.flags);
466        match p.value {
467            // sfixed64 stays an Int64. Routing a counter through f64 would
468            // silently drop its low bits past 2^53, which is a number real
469            // request counters reach.
470            Some(number_data_point::Value::AsInt(i)) => {
471                self.num_int.append_value(i);
472                self.num_double.append_null();
473            }
474            Some(number_data_point::Value::AsDouble(d)) => {
475                self.num_int.append_null();
476                self.num_double.append_value(d);
477            }
478            None => {
479                self.num_int.append_null();
480                self.num_double.append_null();
481            }
482        }
483        self.dp_attrs.append_all(id, &p.attributes)?;
484        self.append_exemplars(id, &p.exemplars)
485    }
486
487    fn append_hist(&mut self, p: &HistogramDataPoint, mid: u32) -> Result<()> {
488        let id = self.next_point(p.time_unix_nano);
489        self.hist
490            .append(id, mid, p.start_time_unix_nano, p.time_unix_nano, p.flags);
491        self.hist_stats.append(p.count, p.sum, p.min, p.max);
492        self.hist_counts
493            .append_value(p.bucket_counts.iter().copied().map(Some));
494        self.list_values += p.bucket_counts.len();
495
496        if p.explicit_bounds.is_empty() {
497            self.hist_bounds_id.append_null();
498        } else {
499            let key: Vec<u64> = p.explicit_bounds.iter().map(|b| b.to_bits()).collect();
500            let bid = match self.bounds_index.get(&key) {
501                Some(&b) => b,
502                None => {
503                    let b = self.next_bounds_id;
504                    self.next_bounds_id += 1;
505                    self.bounds_id.append_value(b);
506                    self.bounds_values
507                        .append_value(p.explicit_bounds.iter().copied().map(Some));
508                    self.list_values += p.explicit_bounds.len();
509                    self.bounds_index.insert(key, b);
510                    b
511                }
512            };
513            self.hist_bounds_id.append_value(bid);
514        }
515
516        self.dp_attrs.append_all(id, &p.attributes)?;
517        self.append_exemplars(id, &p.exemplars)
518    }
519
520    fn append_exp_hist(&mut self, p: &ExponentialHistogramDataPoint, mid: u32) -> Result<()> {
521        let id = self.next_point(p.time_unix_nano);
522        self.exp
523            .append(id, mid, p.start_time_unix_nano, p.time_unix_nano, p.flags);
524        self.exp_stats.append(p.count, p.sum, p.min, p.max);
525        self.exp_scale.append_value(p.scale);
526        self.exp_zero_count.append_value(p.zero_count);
527        self.exp_zero_threshold.append_value(p.zero_threshold);
528        let buckets = |b: &Option<Buckets>,
529                       off: &mut Int32Builder,
530                       counts: &mut ListBuilder<UInt64Builder>,
531                       total: &mut usize| {
532            match b {
533                Some(b) => {
534                    off.append_value(b.offset);
535                    counts.append_value(b.bucket_counts.iter().copied().map(Some));
536                    *total += b.bucket_counts.len();
537                }
538                None => {
539                    off.append_value(0);
540                    counts.append_null();
541                }
542            }
543        };
544        buckets(
545            &p.positive,
546            &mut self.exp_pos_offset,
547            &mut self.exp_pos_counts,
548            &mut self.list_values,
549        );
550        buckets(
551            &p.negative,
552            &mut self.exp_neg_offset,
553            &mut self.exp_neg_counts,
554            &mut self.list_values,
555        );
556        self.dp_attrs.append_all(id, &p.attributes)?;
557        self.append_exemplars(id, &p.exemplars)
558    }
559
560    fn append_summary(&mut self, p: &SummaryDataPoint, mid: u32) -> Result<()> {
561        let id = self.next_point(p.time_unix_nano);
562        self.summ
563            .append(id, mid, p.start_time_unix_nano, p.time_unix_nano, p.flags);
564        self.summ_count.append_value(p.count);
565        self.summ_sum.append_value(p.sum);
566        self.summ_quantile
567            .append_value(p.quantile_values.iter().map(|q| Some(q.quantile)));
568        self.summ_value
569            .append_value(p.quantile_values.iter().map(|q| Some(q.value)));
570        self.list_values += p.quantile_values.len() * 2;
571        // Summary has no exemplars on the wire — it predates them.
572        self.dp_attrs.append_all(id, &p.attributes)
573    }
574
575    fn append_exemplars(&mut self, dp_id: u32, exemplars: &[Exemplar]) -> Result<()> {
576        for e in exemplars {
577            let eid = self.next_exemplar_id;
578            self.next_exemplar_id += 1;
579            self.ex_id.append_value(eid);
580            self.ex_parent.append_value(dp_id);
581            self.ex_time.append_value(nanos(e.time_unix_nano));
582            match e.value {
583                Some(exemplar::Value::AsInt(i)) => {
584                    self.ex_int.append_value(i);
585                    self.ex_double.append_null();
586                }
587                Some(exemplar::Value::AsDouble(d)) => {
588                    self.ex_int.append_null();
589                    self.ex_double.append_value(d);
590                }
591                None => {
592                    self.ex_int.append_null();
593                    self.ex_double.append_null();
594                }
595            }
596            append_fixed(&mut self.ex_trace_id, &e.trace_id, 16)?;
597            append_fixed(&mut self.ex_span_id, &e.span_id, 8)?;
598            self.exemplar_attrs
599                .append_all(eid, &e.filtered_attributes)?;
600        }
601        Ok(())
602    }
603
604    /// Seal and reset, including on the error path — see
605    /// [`SignalBuilder::finish`].
606    pub fn finish(&mut self) -> Result<Sealed> {
607        let out = self.seal(Sidecars::Build);
608        *self = Self::new();
609        out
610    }
611
612    fn seal(&self, sidecars: Sidecars) -> Result<Sealed> {
613        let cols: Vec<ArrayRef> = vec![
614            Arc::new(self.m_id.finish_cloned()),
615            self.m_name.finish(),
616            Arc::new(self.m_description.finish_cloned()),
617            self.m_unit.finish(),
618            Arc::new(self.m_kind.finish_cloned()),
619            Arc::new(self.m_temporality.finish_cloned()),
620            Arc::new(self.m_monotonic.finish_cloned()),
621            Arc::new(self.m_resource_id.finish_cloned()),
622            Arc::new(self.m_scope_id.finish_cloned()),
623        ];
624        let metrics = RecordBatch::try_new(METRICS.clone(), cols)?;
625
626        let mut number = self.num.finish();
627        number.push(Arc::new(self.num_int.finish_cloned()));
628        number.push(Arc::new(self.num_double.finish_cloned()));
629
630        let mut hist = self.hist.finish();
631        hist.extend(self.hist_stats.finish());
632        hist.push(Arc::new(self.hist_counts.finish_cloned()));
633        hist.push(Arc::new(self.hist_bounds_id.finish_cloned()));
634
635        let cols: Vec<ArrayRef> = vec![
636            Arc::new(self.bounds_id.finish_cloned()),
637            Arc::new(self.bounds_values.finish_cloned()),
638        ];
639        let bounds = RecordBatch::try_new(HIST_BOUNDS.clone(), cols)?;
640
641        let mut exp = self.exp.finish();
642        exp.extend(self.exp_stats.finish());
643        exp.push(Arc::new(self.exp_scale.finish_cloned()));
644        exp.push(Arc::new(self.exp_zero_count.finish_cloned()));
645        exp.push(Arc::new(self.exp_zero_threshold.finish_cloned()));
646        exp.push(Arc::new(self.exp_pos_offset.finish_cloned()));
647        exp.push(Arc::new(self.exp_pos_counts.finish_cloned()));
648        exp.push(Arc::new(self.exp_neg_offset.finish_cloned()));
649        exp.push(Arc::new(self.exp_neg_counts.finish_cloned()));
650
651        let mut summ = self.summ.finish();
652        summ.push(Arc::new(self.summ_count.finish_cloned()));
653        summ.push(Arc::new(self.summ_sum.finish_cloned()));
654        summ.push(Arc::new(self.summ_quantile.finish_cloned()));
655        summ.push(Arc::new(self.summ_value.finish_cloned()));
656
657        let cols: Vec<ArrayRef> = vec![
658            Arc::new(self.ex_id.finish_cloned()),
659            Arc::new(self.ex_parent.finish_cloned()),
660            Arc::new(self.ex_time.finish_cloned()),
661            Arc::new(self.ex_int.finish_cloned()),
662            Arc::new(self.ex_double.finish_cloned()),
663            Arc::new(self.ex_trace_id.finish_cloned()),
664            Arc::new(self.ex_span_id.finish_cloned()),
665        ];
666        let exemplars = RecordBatch::try_new(EXEMPLARS.clone(), cols)?;
667
668        // Order matches `schema::METRICS_BLOCK_TABLES`, pinned by a test.
669        let mut tables = vec![
670            ("metrics", metrics),
671            ("metric_attrs", self.metric_attrs.finish()?),
672            (
673                "number_dp",
674                RecordBatch::try_new(NUMBER_DP.clone(), number)?,
675            ),
676            ("hist_dp", RecordBatch::try_new(HIST_DP.clone(), hist)?),
677            ("hist_bounds", bounds),
678            (
679                "exp_hist_dp",
680                RecordBatch::try_new(EXP_HIST_DP.clone(), exp)?,
681            ),
682            (
683                "summary_dp",
684                RecordBatch::try_new(SUMMARY_DP.clone(), summ)?,
685            ),
686            ("dp_attrs", self.dp_attrs.finish()?),
687            ("exemplars", exemplars),
688            ("exemplar_attrs", self.exemplar_attrs.finish()?),
689        ];
690        tables.extend(self.rs.finish()?);
691        Ok(Sealed::with(
692            sidecars,
693            self.next_dp_id as usize,
694            tables,
695            self.min_ts,
696            self.max_ts,
697        ))
698    }
699}
700
701impl SignalBuilder for MetricsBuilder {
702    type Request = ExportMetricsServiceRequest;
703    const SIGNAL: &'static str = "metrics";
704
705    fn has_headroom_for(&self, req: &Self::Request) -> bool {
706        MetricsBuilder::has_headroom_for(self, req)
707    }
708    fn append_request(&mut self, req: &Self::Request) -> Result<usize> {
709        MetricsBuilder::append_request(self, req)
710    }
711    fn approx_bytes(&self) -> usize {
712        MetricsBuilder::approx_bytes(self)
713    }
714    fn is_empty(&self) -> bool {
715        MetricsBuilder::is_empty(self)
716    }
717    fn finish(&mut self) -> Result<Sealed> {
718        MetricsBuilder::finish(self)
719    }
720    fn snapshot(&self) -> Result<Sealed> {
721        self.seal(Sidecars::Skip)
722    }
723}
724
725/// Visit `(attribute_count, exemplar_attribute_count)` for every point of `m`,
726/// whichever of the five shapes it has. Exists so `has_headroom_for` does not
727/// repeat the five-arm match that `append_metric` already has.
728fn for_each_point(m: &Metric, mut f: impl FnMut(usize, usize)) {
729    let ex = |e: &[Exemplar]| e.iter().map(|x| x.filtered_attributes.len()).sum::<usize>();
730    match &m.data {
731        None => {}
732        Some(Data::Gauge(g)) => {
733            for p in &g.data_points {
734                f(p.attributes.len(), ex(&p.exemplars));
735            }
736        }
737        Some(Data::Sum(s)) => {
738            for p in &s.data_points {
739                f(p.attributes.len(), ex(&p.exemplars));
740            }
741        }
742        Some(Data::Histogram(h)) => {
743            for p in &h.data_points {
744                f(p.attributes.len(), ex(&p.exemplars));
745            }
746        }
747        Some(Data::ExponentialHistogram(h)) => {
748            for p in &h.data_points {
749                f(p.attributes.len(), ex(&p.exemplars));
750            }
751        }
752        Some(Data::Summary(s)) => {
753            for p in &s.data_points {
754                f(p.attributes.len(), 0);
755            }
756        }
757    }
758}
759
760/// Enums arrive as `i32` and a client can send anything. Out of range becomes
761/// the zero variant, which every OTLP enum defines as "unspecified".
762fn clamp_u8(v: i32, max: u8) -> u8 {
763    u8::try_from(v).unwrap_or(0).min(max)
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use arrow_array::cast::AsArray;
770    use arrow_array::types::{Float64Type, Int64Type, UInt8Type, UInt32Type, UInt64Type};
771    use arrow_array::{Array, RecordBatch, TimestampNanosecondArray};
772    use mira_proto::metrics::v1::summary_data_point::ValueAtQuantile;
773    use mira_proto::metrics::v1::{
774        ExponentialHistogram, Gauge, Histogram, ResourceMetrics, ScopeMetrics, Sum, Summary,
775    };
776
777    /// One `ExportMetricsServiceRequest` carrying `metrics` under one resource
778    /// and one scope. Every shape test below differs only in that list.
779    fn request(metrics: Vec<Metric>) -> ExportMetricsServiceRequest {
780        ExportMetricsServiceRequest {
781            resource_metrics: vec![ResourceMetrics {
782                scope_metrics: vec![ScopeMetrics {
783                    metrics,
784                    ..Default::default()
785                }],
786                ..Default::default()
787            }],
788        }
789    }
790
791    /// An instrument that was registered and never recorded into sends a
792    /// descriptor with no `data`, and the collector forwards it. It has to
793    /// survive as a descriptor row with no points: dropping it loses the name,
794    /// unit and description that make the instrument discoverable before its
795    /// first sample, which is exactly when someone is looking for it.
796    #[test]
797    fn a_metric_with_no_data_is_a_descriptor_and_no_points() {
798        let req = ExportMetricsServiceRequest {
799            resource_metrics: vec![ResourceMetrics {
800                scope_metrics: vec![ScopeMetrics {
801                    metrics: vec![
802                        Metric {
803                            name: "queue.depth".into(),
804                            description: "items awaiting a worker".into(),
805                            data: None,
806                            ..Default::default()
807                        },
808                        Metric {
809                            name: "http.server.duration".into(),
810                            data: Some(Data::Gauge(Gauge {
811                                data_points: vec![NumberDataPoint {
812                                    time_unix_nano: 1_000,
813                                    exemplars: vec![Exemplar {
814                                        time_unix_nano: 1_000,
815                                        value: Some(exemplar::Value::AsInt(7)),
816                                        ..Default::default()
817                                    }],
818                                    ..Default::default()
819                                }],
820                            })),
821                            ..Default::default()
822                        },
823                    ],
824                    ..Default::default()
825                }],
826                ..Default::default()
827            }],
828        };
829
830        let mut b = MetricsBuilder::new();
831        // Through the trait, because that is how the flusher asks — and the
832        // headroom walk has to survive the data-less metric too.
833        assert!(SignalBuilder::has_headroom_for(&b, &req));
834        assert_eq!(b.append_request(&req).unwrap(), 1, "one point, two metrics");
835        assert_eq!(b.num_rows(), 1, "points, not descriptors");
836
837        let sealed = b.finish().unwrap();
838        let metrics = sealed.table("metrics").unwrap();
839        assert_eq!(metrics.num_rows(), 2);
840        let kind = metrics.column_by_name("kind").unwrap();
841        assert_eq!(
842            kind.as_primitive::<UInt8Type>().values(),
843            &[MetricKind::Unset as u8, MetricKind::Gauge as u8]
844        );
845        let desc = metrics.column_by_name("description").unwrap();
846        assert_eq!(desc.as_string::<i32>().value(0), "items awaiting a worker");
847        assert!(desc.is_null(1), "an empty description is absent, not \"\"");
848
849        // An integer exemplar lands in `int`, leaving `double` null: the two
850        // columns are how the reader recovers which arm of the union it was.
851        let ex = sealed.table("exemplars").unwrap();
852        let int = ex.column_by_name("int").unwrap();
853        assert_eq!(int.as_primitive::<Int64Type>().value(0), 7);
854        assert!(ex.column_by_name("double").unwrap().is_null(0));
855    }
856
857    /// A point past 2^63 wrapped negative into `min_ts`, and a negative `min_ts`
858    /// publishes a block directory `block::parse_dir_name` refuses — invisible
859    /// to every query and to every retention sweep, after the export was acked.
860    #[test]
861    fn point_times_past_i64_do_not_wrap_the_block_range() {
862        let point = |start: u64, time: u64| NumberDataPoint {
863            start_time_unix_nano: start,
864            time_unix_nano: time,
865            exemplars: vec![Exemplar {
866                time_unix_nano: u64::MAX,
867                ..Default::default()
868            }],
869            ..Default::default()
870        };
871
872        let mut b = MetricsBuilder::new();
873        b.append_request(&ExportMetricsServiceRequest {
874            resource_metrics: vec![ResourceMetrics {
875                scope_metrics: vec![ScopeMetrics {
876                    metrics: vec![Metric {
877                        name: "process.memory".into(),
878                        data: Some(Data::Gauge(Gauge {
879                            data_points: vec![point(u64::MAX, u64::MAX), point(u64::MAX, 2_000)],
880                        })),
881                        ..Default::default()
882                    }],
883                    ..Default::default()
884                }],
885                ..Default::default()
886            }],
887        })
888        .unwrap();
889
890        let sealed = b.finish().unwrap();
891        assert_eq!(
892            sealed.num_rows, 2,
893            "malformed points are stored, not dropped"
894        );
895        assert_eq!((sealed.min_ts, sealed.max_ts), (2_000, 2_000));
896
897        let ts = |t: &str, c: &str| {
898            sealed
899                .table(t)
900                .unwrap()
901                .column_by_name(c)
902                .unwrap()
903                .as_any()
904                .downcast_ref::<TimestampNanosecondArray>()
905                .unwrap()
906                .clone()
907        };
908        // An unrepresentable process start is as absent as a zero one.
909        assert!(ts("number_dp", "start_time_unix_nano").is_null(0));
910        assert_eq!(ts("number_dp", "time_unix_nano").values(), &[0, 2_000]);
911        assert_eq!(ts("exemplars", "time_unix_nano").values(), &[0, 0]);
912    }
913
914    /// Five metric types, four point tables, one id space.
915    ///
916    /// Two invariants ride on this and neither is visible in a single-shape
917    /// test. A point routed to the wrong table is a point no chart ever finds
918    /// again, because the read path resolves a value by *which* table the row
919    /// sits in. And `dp_attrs` and `exemplars` carry no discriminant column —
920    /// they key on a point id alone — so if two of the four tables ever issued
921    /// the same id, one point's attributes and exemplars would silently attach
922    /// to another metric's point. The shapes below are the ones a real
923    /// collector sends: every value oneof including the unset one, a histogram
924    /// with no `sum` and one with no bounds at all, an exponential histogram
925    /// with only positive buckets, and a descriptor whose `data` is missing.
926    #[test]
927    fn every_otlp_metric_shape_lands_in_the_table_written_for_it() {
928        let num = |t: u64, v: Option<number_data_point::Value>| NumberDataPoint {
929            time_unix_nano: t,
930            value: v,
931            ..Default::default()
932        };
933        let bounds = vec![1.0, 2.0];
934        let hist = |sum: Option<f64>, explicit_bounds: Vec<f64>| HistogramDataPoint {
935            time_unix_nano: 3_000,
936            count: 6,
937            sum,
938            bucket_counts: vec![1, 2, 3],
939            explicit_bounds,
940            ..Default::default()
941        };
942        let req = request(vec![
943            // A descriptor an exporter registered and never wrote a point to.
944            // It still names a metric, so the row is kept — and a description
945            // is stored where an absent one is null rather than "".
946            Metric {
947                name: "declared.only".into(),
948                description: "registered by an exporter that never fired".into(),
949                data: None,
950                ..Default::default()
951            },
952            Metric {
953                name: "gauge".into(),
954                data: Some(Data::Gauge(Gauge {
955                    data_points: vec![
956                        num(1_000, Some(number_data_point::Value::AsInt(7))),
957                        num(1_001, Some(number_data_point::Value::AsDouble(0.5))),
958                        // OTLP allows a point with neither: both columns null,
959                        // and the row is still stored so the gap is visible.
960                        num(1_002, None),
961                    ],
962                })),
963                ..Default::default()
964            },
965            Metric {
966                name: "counter".into(),
967                unit: "1".into(),
968                data: Some(Data::Sum(Sum {
969                    is_monotonic: true,
970                    // Out of range on the wire. Every OTLP enum defines zero as
971                    // "unspecified", so a client sending nonsense gets that
972                    // rather than a rejected export.
973                    aggregation_temporality: -3,
974                    data_points: vec![NumberDataPoint {
975                        time_unix_nano: 2_000,
976                        value: Some(number_data_point::Value::AsInt(11)),
977                        exemplars: vec![
978                            Exemplar {
979                                time_unix_nano: 2_000,
980                                value: Some(exemplar::Value::AsInt(11)),
981                                trace_id: vec![1u8; 16].into(),
982                                span_id: vec![2u8; 8].into(),
983                                ..Default::default()
984                            },
985                            Exemplar {
986                                time_unix_nano: 2_001,
987                                value: Some(exemplar::Value::AsDouble(1.5)),
988                                ..Default::default()
989                            },
990                            Exemplar {
991                                time_unix_nano: 2_002,
992                                value: None,
993                                ..Default::default()
994                            },
995                        ],
996                        ..Default::default()
997                    }],
998                })),
999                ..Default::default()
1000            },
1001            Metric {
1002                name: "hist".into(),
1003                data: Some(Data::Histogram(Histogram {
1004                    aggregation_temporality: 99,
1005                    data_points: vec![
1006                        hist(Some(4.5), bounds.clone()),
1007                        // Same boundaries: one `hist_bounds` row, two points.
1008                        // That interning is what takes the table from 410 to
1009                        // 246 bytes a row.
1010                        hist(None, bounds),
1011                        // A histogram with no boundaries is a single implicit
1012                        // bucket, so there is nothing to intern and `bounds_id`
1013                        // is null rather than pointing at an empty list.
1014                        hist(Some(1.0), Vec::new()),
1015                    ],
1016                })),
1017                ..Default::default()
1018            },
1019            Metric {
1020                name: "exp".into(),
1021                data: Some(Data::ExponentialHistogram(ExponentialHistogram {
1022                    aggregation_temporality: 1,
1023                    data_points: vec![ExponentialHistogramDataPoint {
1024                        time_unix_nano: 4_000,
1025                        count: 3,
1026                        scale: -2,
1027                        zero_count: 1,
1028                        zero_threshold: 0.25,
1029                        positive: Some(Buckets {
1030                            offset: 5,
1031                            bucket_counts: vec![1, 1],
1032                        }),
1033                        // Nothing negative was observed, which is the common
1034                        // case and must not cost a bucket list.
1035                        negative: None,
1036                        ..Default::default()
1037                    }],
1038                })),
1039                ..Default::default()
1040            },
1041            Metric {
1042                name: "summ".into(),
1043                data: Some(Data::Summary(Summary {
1044                    data_points: vec![SummaryDataPoint {
1045                        time_unix_nano: 5_000,
1046                        count: 2,
1047                        sum: 3.0,
1048                        quantile_values: vec![
1049                            ValueAtQuantile {
1050                                quantile: 0.5,
1051                                value: 1.0,
1052                            },
1053                            ValueAtQuantile {
1054                                quantile: 0.99,
1055                                value: 2.0,
1056                            },
1057                        ],
1058                        ..Default::default()
1059                    }],
1060                })),
1061                ..Default::default()
1062            },
1063        ]);
1064
1065        let mut b = MetricsBuilder::new();
1066        assert!(b.is_empty(), "a fresh builder holds no descriptors");
1067        // Headroom walks the same five shapes `append_metric` does, through a
1068        // second match that has drifted from it before. Asked of an empty
1069        // builder about a request this small the answer can only be yes; what
1070        // is being checked is that every arm of that walk survives the ask.
1071        assert!(SignalBuilder::has_headroom_for(&b, &req));
1072        assert_eq!(SignalBuilder::append_request(&mut b, &req).unwrap(), 9);
1073        // `num_rows` is points, not descriptors: it is what the flusher sizes a
1074        // block by, and seven descriptors would be a rounding error against it.
1075        assert_eq!(b.num_rows(), 9);
1076        assert!(!b.is_empty());
1077        assert!(b.approx_bytes() > 0);
1078
1079        let sealed = SignalBuilder::finish(&mut b).unwrap();
1080        assert_eq!(sealed.num_rows, 9);
1081        let table = |n: &str| sealed.table(n).expect(n).clone();
1082        let rows = |n: &str| table(n).num_rows();
1083        assert_eq!(
1084            [
1085                rows("number_dp"),
1086                rows("hist_dp"),
1087                rows("exp_hist_dp"),
1088                rows("summary_dp")
1089            ],
1090            [4, 3, 1, 1],
1091            "each point type in the table written for its shape"
1092        );
1093
1094        // The id space. Every point id appears exactly once across the four
1095        // tables and they are dense from zero, which is what lets `dp_attrs`
1096        // and `exemplars` key on the id with no table discriminant.
1097        let mut ids: Vec<u32> = Vec::new();
1098        for t in ["number_dp", "hist_dp", "exp_hist_dp", "summary_dp"] {
1099            let b = table(t);
1100            let col = b.column_by_name("id").unwrap();
1101            ids.extend(col.as_primitive::<UInt32Type>().values().iter().copied());
1102        }
1103        ids.sort_unstable();
1104        assert_eq!(ids, (0..9).collect::<Vec<u32>>());
1105
1106        let m = table("metrics");
1107        let u8s = |b: &RecordBatch, c: &str| {
1108            b.column_by_name(c)
1109                .unwrap()
1110                .as_primitive::<UInt8Type>()
1111                .values()
1112                .to_vec()
1113        };
1114        assert_eq!(
1115            u8s(&m, "kind"),
1116            [
1117                MetricKind::Unset as u8,
1118                MetricKind::Gauge as u8,
1119                MetricKind::Sum as u8,
1120                MetricKind::Histogram as u8,
1121                MetricKind::ExponentialHistogram as u8,
1122                MetricKind::Summary as u8,
1123            ]
1124        );
1125        // Temporality lives on the wrapper, not the point, and the two out of
1126        // range values clamp in opposite directions: negative to the
1127        // "unspecified" zero, too-large down to the highest defined variant.
1128        assert_eq!(u8s(&m, "temporality"), [0, 0, 0, 2, 1, 0]);
1129        let mono = m.column_by_name("is_monotonic").unwrap().as_boolean();
1130        assert_eq!(
1131            (0..6).map(|i| mono.value(i)).collect::<Vec<bool>>(),
1132            [false, false, true, false, false, false],
1133            "only a Sum can be monotonic"
1134        );
1135        let desc = m.column_by_name("description").unwrap().as_string::<i32>();
1136        assert_eq!(desc.value(0), "registered by an exporter that never fired");
1137        assert!(
1138            (1..6).all(|i| desc.is_null(i)),
1139            "an empty description is absent, not an empty string"
1140        );
1141
1142        // Numbers keep the type they arrived as: an sfixed64 counter past 2^53
1143        // read back through a double loses the low bits that made it worth
1144        // charting, so `int` and `double` are separate nullable columns and
1145        // exactly one is set per point.
1146        let n = table("number_dp");
1147        let ints = n.column_by_name("int").unwrap().as_primitive::<Int64Type>();
1148        let dbls = n
1149            .column_by_name("double")
1150            .unwrap()
1151            .as_primitive::<Float64Type>();
1152        assert_eq!(
1153            (0..4)
1154                .map(|i| (ints.is_null(i), dbls.is_null(i)))
1155                .collect::<Vec<_>>(),
1156            [(false, true), (true, false), (true, true), (false, true)]
1157        );
1158
1159        // Identical boundaries intern to one row; a point with none at all
1160        // points at nothing rather than at an empty list.
1161        let h = table("hist_dp");
1162        assert_eq!(table("hist_bounds").num_rows(), 1);
1163        let bid = h
1164            .column_by_name("bounds_id")
1165            .unwrap()
1166            .as_primitive::<UInt32Type>();
1167        assert_eq!((bid.value(0), bid.value(1)), (0, 0));
1168        assert!(bid.is_null(2));
1169        let hsum = h
1170            .column_by_name("sum")
1171            .unwrap()
1172            .as_primitive::<Float64Type>();
1173        assert!(hsum.is_null(1), "a histogram may report count and no sum");
1174
1175        // A missing bucket side is null, not an empty list: "we saw nothing
1176        // negative" and "we did not record the negative side" are different
1177        // answers and a reader has to be able to tell them apart.
1178        let e = table("exp_hist_dp");
1179        assert!(e.column_by_name("positive_counts").unwrap().is_valid(0));
1180        assert!(e.column_by_name("negative_counts").unwrap().is_null(0));
1181        assert_eq!(
1182            e.column_by_name("zero_count")
1183                .unwrap()
1184                .as_primitive::<UInt64Type>()
1185                .value(0),
1186            1
1187        );
1188
1189        // Summary predates exemplars, so its three exemplars are the counter's.
1190        let ex = table("exemplars");
1191        assert_eq!(ex.num_rows(), 3);
1192        let exi = ex
1193            .column_by_name("int")
1194            .unwrap()
1195            .as_primitive::<Int64Type>();
1196        let exd = ex
1197            .column_by_name("double")
1198            .unwrap()
1199            .as_primitive::<Float64Type>();
1200        assert_eq!(
1201            (0..3)
1202                .map(|i| (exi.is_null(i), exd.is_null(i)))
1203                .collect::<Vec<_>>(),
1204            [(false, true), (true, false), (true, true)]
1205        );
1206        assert_eq!(exi.value(0), 11);
1207        // An exemplar with no ids is null in both, not sixteen zero bytes —
1208        // otherwise "no trace" would render as a trace id a caller can search.
1209        let tid = ex.column_by_name("trace_id").unwrap();
1210        assert!(tid.is_valid(0) && tid.is_null(1));
1211
1212        // Quantiles are two parallel lists, one row per point.
1213        let s = table("summary_dp");
1214        assert_eq!(
1215            s.column_by_name("quantile")
1216                .unwrap()
1217                .as_list::<i32>()
1218                .value(0)
1219                .len(),
1220            2
1221        );
1222    }
1223
1224    /// A failed append must not leave a builder that seals into a block whose
1225    /// columns disagree in length.
1226    ///
1227    /// `append_metric` writes the two dictionary columns before anything else,
1228    /// so an overflow on the second one leaves `name` a row ahead of every
1229    /// other column of the descriptor table. Arrow refuses to build that batch,
1230    /// which is the right answer — the wrong one would be a published block
1231    /// where row *n* of `name` describes row *n* of nothing. `finish` resets
1232    /// even on that path, so the next block is clean rather than permanently
1233    /// poisoned.
1234    #[test]
1235    fn a_torn_append_fails_the_seal_instead_of_publishing_a_ragged_block() {
1236        let mut b = MetricsBuilder::new();
1237        // 65,536 distinct units, which is the dictionary's whole key space.
1238        // Driven straight at the column rather than through 65,536 `Metric`
1239        // messages: the state under test is the full dictionary, and building
1240        // the protobufs to reach it would cost seconds for nothing.
1241        for i in 0..crate::schema::DICT_CAP {
1242            b.m_unit.append(&format!("u{i}")).unwrap();
1243        }
1244        assert!(!b.m_unit.has_headroom(1), "the unit dictionary is full");
1245
1246        let one = |unit: &str| Metric {
1247            name: "requests".into(),
1248            unit: unit.into(),
1249            data: Some(Data::Gauge(Gauge {
1250                data_points: vec![NumberDataPoint {
1251                    time_unix_nano: 1_000,
1252                    value: Some(number_data_point::Value::AsInt(1)),
1253                    ..Default::default()
1254                }],
1255            })),
1256            ..Default::default()
1257        };
1258        let err = b.append_metric(&one("brand-new"), 0, 0).unwrap_err();
1259        assert!(
1260            matches!(err, crate::error::Error::DictionaryFull("metrics.unit")),
1261            "{err}"
1262        );
1263        // `name` took the extra row; nothing else did.
1264        let Err(err) = b.finish() else {
1265            panic!("a ragged descriptor table sealed");
1266        };
1267        assert!(matches!(err, crate::error::Error::Arrow(_)), "{err}");
1268
1269        // And the reset happened anyway, so the next block is a clean one.
1270        assert!(b.is_empty());
1271        b.append_request(&request(vec![one("ms")])).unwrap();
1272        let sealed = b.finish().unwrap();
1273        assert_eq!(sealed.num_rows, 1);
1274        assert_eq!(sealed.table("metrics").unwrap().num_rows(), 1);
1275    }
1276}