Skip to main content

mz_compute/sink/
metric_sink.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Render arm for `MetricSinkConnection`.
11//!
12//! A metric sink funnels every row of its source collection to one worker, folds
13//! it into a [`SinkState`], and exposes that state to the process's Prometheus registry through
14//! a [`SinkCollector`]. `SinkState` is shared between the timely operator (the sole writer) and
15//! `SinkCollector::collect` (the reader, invoked from whatever thread scrapes the registry) via
16//! `Arc<Mutex<_>>`. Both sides only ever hold the lock across a short, synchronous section: the
17//! operator has no `await` points (it is a synchronous `builder_rc` operator), and the collector
18//! only clones out the data it needs to build `MetricFamily` protos before releasing the lock.
19//!
20//! The planner (`optimize::metric_sink::shape_metric_sink_source`) does the row-wise shaping:
21//! it coalesces `labels`/`help` to their identity element and computes the `metric_kind`
22//! and `name_valid` columns `extract_row` reads below, so this module no longer parses
23//! `metric_type` strings or validates `metric_name` itself. Dedup, collision detection, and
24//! family-conflict counting stay here because they need the cross-row state of the fold.
25
26use std::any::Any;
27use std::cell::RefCell;
28use std::collections::BTreeMap;
29use std::rc::Rc;
30use std::sync::{Arc, Mutex};
31
32use differential_dataflow::{Hashable, VecCollection};
33use mz_compute_types::sinks::{ComputeSinkDesc, MetricSinkConnection};
34use mz_ore::cast::{CastFrom, CastLossy};
35use mz_repr::{ColumnName, Datum, DatumVec, Diff, GlobalId, RelationDesc, Row, Timestamp};
36use mz_storage_types::controller::CollectionMetadata;
37use mz_timely_util::probe::{Handle, ProbeNotify};
38use prometheus::core::{Collector, Desc};
39use prometheus::proto::{
40    Counter as ProtoCounter, Gauge as ProtoGauge, LabelPair, Metric as ProtoMetric, MetricFamily,
41    MetricType,
42};
43use prometheus::{Gauge, Opts};
44use timely::dataflow::channels::pact::Exchange;
45use timely::dataflow::operators::generic::builder_rc::OperatorBuilder;
46use timely::progress::Antichain;
47
48use crate::render::StartSignal;
49use crate::render::errors::DataflowErrorSer;
50use crate::render::sinks::SinkRender;
51
52impl<'scope> SinkRender<'scope> for MetricSinkConnection {
53    fn render_sink(
54        &self,
55        compute_state: &mut crate::compute_state::ComputeState,
56        sink: &ComputeSinkDesc<CollectionMetadata>,
57        sink_id: GlobalId,
58        _as_of: Antichain<Timestamp>,
59        _start_signal: StartSignal,
60        sinked_collection: VecCollection<'scope, Timestamp, Row, Diff>,
61        err_collection: VecCollection<'scope, Timestamp, DataflowErrorSer, Diff>,
62        output_probe: &Handle<Timestamp>,
63    ) -> Option<Rc<dyn Any>> {
64        let cols = ColumnIndices::resolve(&sink.from_desc);
65
66        let scope = sinked_collection.scope();
67        let worker_id = scope.index();
68        // The registry is process-local, so every row must land on the same worker or the
69        // series would be split across processes. Which worker is chosen doesn't matter, only
70        // that all workers agree, so hash the sink's own id.
71        //
72        // Routing by metric key instead would spread the fold across workers, but each process
73        // has its own registry, so one metric family could then be split across processes' scrape
74        // outputs. Partition-by-key is a possible future refinement.
75        let active_worker_id = usize::cast_from(sink_id.hashed()) % scope.peers();
76
77        let ok_stream = sinked_collection
78            .inner
79            .probe_notify_with(vec![output_probe.clone()]);
80        let err_stream = err_collection.inner;
81
82        let state = Arc::new(Mutex::new(SinkState::default()));
83
84        // Only the active worker registers a collector. The `MetricsRegistry` is shared by every
85        // worker in the process.
86        //
87        // NOTE: re-rendering this sink while the previous instance's collector is still registered
88        // would collide on its `Desc` id, and registration would then soft-panic and publish no
89        // series until the old handle drops. This does not happen because the old handle always
90        // drops first: it lives in the collection's `sink_token`, which reconciliation nulls during
91        // worker-local cleanup before applying the replacement, and a normal drop tears the
92        // dataflow down before any re-create. A retained (compatible) sink is never re-rendered.
93        // So registration collides only on a genuine logic error, where the soft-panic is
94        // the intended backstop.
95        let drop_handle = (worker_id == active_worker_id).then(|| {
96            let collector = SinkCollector::new(&self.label, Arc::clone(&state));
97            compute_state
98                .metrics_registry
99                .register_collector_with_dropper(collector)
100        });
101
102        let mut op = OperatorBuilder::new(format!("MetricSink({sink_id})"), scope);
103        let mut ok_input = op.new_input(
104            ok_stream,
105            Exchange::new(move |_: &(Row, Timestamp, Diff)| u64::cast_from(active_worker_id)),
106        );
107        let mut err_input = op.new_input(
108            err_stream,
109            Exchange::new(move |_: &(DataflowErrorSer, Timestamp, Diff)| {
110                u64::cast_from(active_worker_id)
111            }),
112        );
113
114        // The frontier this worker reports to the controller. Every worker reports, not just the
115        // active one: the controller meets the per-worker frontiers, so a worker that never
116        // advanced would pin the input's since forever.
117        let sink_frontier = Rc::new(RefCell::new(Antichain::from_elem(Timestamp::MIN)));
118        let shared_frontier = Rc::clone(&sink_frontier);
119
120        op.build(move |_capabilities| {
121            // Recycled across activations: unpacking a row into `Datum`s otherwise allocates a
122            // fresh `Vec` per row on this hot path.
123            let mut datum_vec = DatumVec::new();
124            move |frontiers| {
125                // Combined ok+err input frontier. A timestamp is closed once neither input can
126                // still produce data at it, so folding a closed time observes all of its diffs.
127                let mut frontier = Antichain::new();
128                for f in frontiers {
129                    frontier.extend(f.frontier().iter().copied());
130                }
131                // NOTE: the reported frontier is published here, before the active worker folds
132                // through it below (`st.integrate` at the end of this closure). This is only safe
133                // because the active worker must complete that fold in the same activation that
134                // observed the frontier. An early return added on the active-worker path between
135                // here and `st.integrate` would advertise progress the sink has not folded,
136                // downgrading the input's since ahead of the data actually consumed.
137                shared_frontier.borrow_mut().clone_from(&frontier);
138
139                if worker_id != active_worker_id {
140                    // Drain so the operator isn't rescheduled forever. There is no state to
141                    // fold into on this worker.
142                    ok_input.for_each(|_, _| {});
143                    err_input.for_each(|_, _| {});
144                    return;
145                }
146
147                let mut st = state.lock().expect("sink state mutex poisoned");
148
149                // Buffer every incoming update under its timestamp. The input is not
150                // consolidated and timely does not guarantee that all diffs at a timestamp arrive
151                // in one activation, so nothing is folded into `working` until the timestamp is
152                // closed.
153                ok_input.for_each(|_, data| {
154                    for (row, time, diff) in data.drain(..) {
155                        let datums = datum_vec.borrow_with(&row);
156                        let (name, metric_kind, name_valid, labels, value, help) =
157                            extract_row(&cols, &datums);
158                        st.stage_ok(
159                            name,
160                            metric_kind,
161                            name_valid,
162                            &labels,
163                            value,
164                            help,
165                            time,
166                            diff.into_inner(),
167                        );
168                    }
169                });
170                err_input.for_each(|_, data| {
171                    for (_err, time, diff) in data.drain(..) {
172                        st.stage_err(time, diff.into_inner());
173                    }
174                });
175
176                st.integrate(&frontier);
177                st.frontier_ms = frontier
178                    .as_option()
179                    .map(|t| u64::from(*t))
180                    .unwrap_or(u64::MAX);
181                st.publish_if_healthy();
182            }
183        });
184
185        // Report frontier updates to the `ComputeState`. A metric sink writes to the metrics
186        // registry rather than to a collection, so its "write" frontier is the input frontier it
187        // has folded through.
188        let collection = compute_state.expect_collection_mut(sink_id);
189        collection.sink_write_frontier = Some(sink_frontier);
190
191        Some(Rc::new(drop_handle))
192    }
193}
194
195/// Column indices resolved once from the sink's source relation.
196///
197/// The source relation exposes `metric_name`, `labels`, `value`, and `help` of the required types,
198/// and `shape_metric_sink_source` adds the `metric_kind` and `name_valid` columns this reads.
199/// `resolve` panics if a column is missing. No tree caller enforces this column contract yet; the
200/// SQL planner will. `metric_name`, `value`, and the values of the `labels` map may still be
201/// `Datum::Null` (a `map[text=>text]` has no per-value nullability); the columns themselves are
202/// non-null by construction. Column position within the row is unconstrained.
203struct ColumnIndices {
204    metric_name: usize,
205    labels: usize,
206    value: usize,
207    help: usize,
208    metric_kind: usize,
209    name_valid: usize,
210}
211
212impl ColumnIndices {
213    fn resolve(desc: &RelationDesc) -> Self {
214        let idx = |name: &str| {
215            desc.get_by_name(&ColumnName::from(name))
216                .expect("column existence validated by the SQL planner")
217                .0
218        };
219        ColumnIndices {
220            metric_name: idx("metric_name"),
221            labels: idx("labels"),
222            value: idx("value"),
223            help: idx("help"),
224            metric_kind: idx("metric_kind"),
225            name_valid: idx("name_valid"),
226        }
227    }
228}
229
230/// Extracts `(metric_name, metric_kind, name_valid, sorted labels, value, help)` from one shaped
231/// source row.
232///
233/// The planner already did the row-wise shaping.
234///
235/// A null label value stays `None` rather than being coerced to a string. Neither a null nor a
236/// genuine empty-string value is a representable Prometheus label (an empty value reads as absent),
237/// so a row carrying either is skipped downstream instead of published.
238///
239/// Strings borrow from `datums`, so the caller must own what it needs
240/// (see `SinkState::stage_ok`) before the row backing `datums` is dropped.
241fn extract_row<'a>(
242    cols: &ColumnIndices,
243    datums: &[Datum<'a>],
244) -> (
245    &'a str,
246    Option<MetricKind>,
247    bool,
248    Vec<(&'a str, Option<&'a str>)>,
249    Option<f64>,
250    &'a str,
251) {
252    let metric_name = match datums[cols.metric_name] {
253        Datum::Null => "",
254        d => d.unwrap_str(),
255    };
256    let metric_kind = MetricKind::from_datum(datums[cols.metric_kind]);
257    let name_valid = matches!(datums[cols.name_valid], Datum::True);
258    let mut labels: Vec<(&str, Option<&str>)> = datums[cols.labels]
259        .unwrap_map()
260        .iter()
261        .map(|(k, v)| (k, (!v.is_null()).then(|| v.unwrap_str())))
262        .collect();
263    labels.sort();
264    let value = match datums[cols.value] {
265        Datum::Null => None,
266        d => Some(d.unwrap_float64()),
267    };
268    let help = datums[cols.help].unwrap_str();
269    (metric_name, metric_kind, name_valid, labels, value, help)
270}
271
272/// Full identity of one source row: metric name, sorted labels, value, metric kind, name
273/// validity, and help.
274///
275/// A null value is its own distinct row identity, not a stand-in for any particular number,
276/// so it is kept apart from every `Some(_)` identity rather than coerced to
277/// one. A null label value is likewise distinct from a `''` value, so `{a => NULL}` and
278/// `{a => ''}` retract only against their own inserts even though both are unpublishable.
279/// The name and labels lead the tuple so that a `BTreeMap<RowKey, _>` keeps all rows of one
280/// `(metric_name, labels)` series adjacent.
281///
282/// `metric_kind` is the planner's classification (`None` for any unsupported `metric_type`), not
283/// the raw string. Two source rows that differ only in which unsupported type they carry (e.g.
284/// `"histogram"` vs. `"summary"`) now share one identity instead of two. That only affects the
285/// granularity of the `skipped` count for rows that are never published either way.
286type RowKey = (
287    String,
288    Vec<(String, Option<String>)>,
289    Option<u64>,
290    Option<MetricKind>,
291    bool,
292    String,
293);
294
295/// Key into [`SinkState::published`]: a metric name paired with its sorted label vector.
296type PublishedKey = (String, Vec<(String, String)>);
297/// Value in [`SinkState::published`]: the series' value, kind, and help string.
298type PublishedValue = (f64, MetricKind, String);
299
300/// Working and published metric state for one metric sink.
301///
302/// Because the input is not consolidated and a timestamp's diffs may span several operator
303/// activations, incoming updates are buffered by timestamp in `pending_ok`/`pending_err` and only
304/// folded into `working` once the input frontier has closed that timestamp. `working` accumulates
305/// a signed multiplicity per full row identity, and a row is live iff its accumulated diff is
306/// positive.
307///
308/// `published` is what the collector exposes and is rebuilt from the live set of
309/// `working`.
310#[derive(Default)]
311struct SinkState {
312    /// Ok-collection updates awaiting their timestamp closing, accumulated per identity.
313    pending_ok: BTreeMap<Timestamp, BTreeMap<RowKey, i64>>,
314    /// Err-collection diffs awaiting their timestamp closing.
315    pending_err: BTreeMap<Timestamp, i64>,
316    /// Accumulated multiplicity per row identity over all closed timestamps.
317    working: BTreeMap<RowKey, i64>,
318    published: BTreeMap<PublishedKey, PublishedValue>,
319    /// Net count of live errors on the sink's input. Can rise and fall as errors are
320    /// retracted. Publication is frozen while this is nonzero.
321    errors: i64,
322    frontier_ms: u64,
323    skipped: u64,
324    conflicts: u64,
325    collisions: u64,
326    /// Count of live `(metric_name, labels)` groups whose only live rows carry a null `value`,
327    /// so the series is currently absent from `published` (a gap) rather than published as some
328    /// number.
329    null_values: u64,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
333enum MetricKind {
334    Gauge,
335    Counter,
336}
337
338impl MetricKind {
339    /// Recovers the classification the planner's `metric_kind` column already computed (`0` =
340    /// gauge, `1` = counter).
341    fn from_datum(d: Datum) -> Option<Self> {
342        match d {
343            Datum::Int32(0) => Some(MetricKind::Gauge),
344            Datum::Int32(1) => Some(MetricKind::Counter),
345            _ => None,
346        }
347    }
348
349    fn proto_type(self) -> MetricType {
350        match self {
351            MetricKind::Gauge => MetricType::GAUGE,
352            MetricKind::Counter => MetricType::COUNTER,
353        }
354    }
355}
356
357/// Matches Prometheus's label name grammar: `[a-zA-Z_][a-zA-Z0-9_]*`.
358///
359/// Unlike the metric-name grammar (`name_valid`, computed in MIR), this stays in Rust: it
360/// applies to every key of the `labels` map, an unbounded per-row collection that doesn't fit a
361/// scalar `Map` expression.
362fn is_valid_label_name(name: &str) -> bool {
363    let mut chars = name.chars();
364    match chars.next() {
365        Some(c) if c.is_ascii_alphabetic() || c == '_' => {
366            chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
367        }
368        _ => false,
369    }
370}
371
372/// Whether a label value can be published as-is. A null (`None`) has no value to encode, and an
373/// empty string reads as absent to Prometheus, so `{a => ''}` would fold into `{}`. Both make the
374/// row that carries them unpublishable.
375fn is_publishable_label_value(value: Option<&str>) -> bool {
376    matches!(value, Some(v) if !v.is_empty())
377}
378
379impl SinkState {
380    /// Buffers one ok-collection update under its timestamp.
381    ///
382    /// `diff` follows differential dataflow sign conventions and is accumulated per full row
383    /// identity, so an un-consolidated input carrying the same identity twice sums rather than
384    /// being mistaken for a second live row. Takes borrowed strings (see `extract_row`) and owns
385    /// them only here, once, at the point the identity is committed to `pending_ok`.
386    fn stage_ok(
387        &mut self,
388        metric_name: &str,
389        metric_kind: Option<MetricKind>,
390        name_valid: bool,
391        labels: &[(&str, Option<&str>)],
392        value: Option<f64>,
393        help: &str,
394        time: Timestamp,
395        diff: i64,
396    ) {
397        let key = (
398            metric_name.to_string(),
399            labels
400                .iter()
401                .map(|&(k, v)| (k.to_string(), v.map(str::to_string)))
402                .collect(),
403            value.map(f64::to_bits),
404            metric_kind,
405            name_valid,
406            help.to_string(),
407        );
408        *self
409            .pending_ok
410            .entry(time)
411            .or_default()
412            .entry(key)
413            .or_default() += diff;
414    }
415
416    /// Buffers one err-collection diff under its timestamp.
417    fn stage_err(&mut self, time: Timestamp, diff: i64) {
418        *self.pending_err.entry(time).or_default() += diff;
419    }
420
421    /// Folds every buffered timestamp the `frontier` has closed into `working` and `errors`.
422    ///
423    /// A timestamp is closed once the combined ok+err frontier can no longer produce data at it,
424    /// which guarantees all of its diffs are already buffered. Accumulated entries that reach a
425    /// multiplicity of zero are dropped. `skipped` is recomputed here from the live set, so it
426    /// counts the input rows currently dropped for an unsupported type or invalid name.
427    fn integrate(&mut self, frontier: &Antichain<Timestamp>) {
428        let closed_ok: Vec<Timestamp> = self
429            .pending_ok
430            .keys()
431            .filter(|t| !frontier.less_equal(t))
432            .copied()
433            .collect();
434        for time in closed_ok {
435            let rows = self.pending_ok.remove(&time).expect("key from keys()");
436            for (key, diff) in rows {
437                *self.working.entry(key).or_default() += diff;
438            }
439        }
440
441        let closed_err: Vec<Timestamp> = self
442            .pending_err
443            .keys()
444            .filter(|t| !frontier.less_equal(t))
445            .copied()
446            .collect();
447        for time in closed_err {
448            self.errors += self.pending_err.remove(&time).expect("key from keys()");
449        }
450
451        self.working.retain(|_, acc| *acc != 0);
452        self.skipped = count_skipped(&self.working);
453    }
454
455    /// Rebuilds `published` and the `collisions`/`conflicts` counts from the live set of
456    /// `working`, but only while the dataflow is free of live errors. While `errors > 0`,
457    /// publication stays frozen at the last healthy snapshot. `working` keeps integrating closed
458    /// timestamps in the meantime, so the next healthy publish reflects everything that happened
459    /// during the freeze.
460    ///
461    // NOTE: this is a full O(n) rebuild over the entire live set on every healthy activation, not an
462    // incremental update. Consider revisiting with incremental maintenance if a sink's series
463    // count grows large enough for the per-activation scan to matter.
464    fn publish_if_healthy(&mut self) {
465        if self.errors == 0 {
466            let (published, collisions, null_values) = rebuild_published(&self.working);
467            self.published = published;
468            self.collisions = collisions;
469            self.null_values = null_values;
470            self.conflicts = count_conflicts(&self.published);
471        }
472    }
473}
474
475/// Counts live working rows dropped for an unsupported `metric_type`, an invalid Prometheus
476/// metric or label name, or a null or empty label value.
477fn count_skipped(working: &BTreeMap<RowKey, i64>) -> u64 {
478    let mut skipped = 0u64;
479    for ((_name, labels, _bits, metric_kind, name_valid, _help), acc) in working {
480        if *acc <= 0 {
481            continue;
482        }
483        let unsupported = metric_kind.is_none();
484        let invalid = !name_valid
485            || !labels
486                .iter()
487                .all(|(k, v)| is_publishable_label_value(v.as_deref()) && is_valid_label_name(k));
488        if unsupported || invalid {
489            skipped += 1;
490        }
491    }
492    skipped
493}
494
495/// Collapses the live, representable rows of `working` into one published entry per
496/// `(metric_name, labels)` series and counts colliding and null-suppressed series.
497///
498/// A row whose label set is not representable (an invalid label name, or a null or empty label
499/// value) is dropped here and counted by [`count_skipped`] instead.
500///
501/// A series collides when more than one distinct live non-null value exists for its
502/// `(metric_name, labels)`: a genuine conflict of two source rows, unlike an ordinary
503/// value update whose old row is retracted and new row inserted within the same closed
504/// timestamp, which leaves a single live value. When a series collides, the winner is chosen
505/// deterministically as the row with the numerically smallest value, breaking ties by metric type
506/// then help. A null `value` carries no number to compare or publish: a series whose live rows
507/// are all null-valued is absent from `published` (a gap) and counted in the returned
508/// `null_values` instead of `collisions`. A series with at least one live non-null value publishes
509/// normally and is not counted in `null_values`, even if null-valued rows are also live for it.
510fn rebuild_published(
511    working: &BTreeMap<RowKey, i64>,
512) -> (BTreeMap<PublishedKey, PublishedValue>, u64, u64) {
513    // `working` orders rows by `(name, labels, ...)`, so all rows of one series are adjacent.
514    let mut grouped: BTreeMap<PublishedKey, Vec<(Option<f64>, MetricKind, String)>> =
515        BTreeMap::new();
516    for ((name, labels, bits, metric_kind, name_valid, help), acc) in working {
517        if *acc <= 0 {
518            continue;
519        }
520        let Some(kind) = metric_kind else {
521            continue;
522        };
523        // A null or empty label value has no representable Prometheus label (an empty value reads
524        // as absent, folding `{a => ''}` into `{}`), so the whole row is unpublishable. Drop it
525        // rather than emit a series the source never had.
526        let Some(labels) = labels
527            .iter()
528            .map(|(k, v)| {
529                is_publishable_label_value(v.as_deref()).then(|| {
530                    (
531                        k.clone(),
532                        v.as_ref().expect("publishable is non-null").clone(),
533                    )
534                })
535            })
536            .collect::<Option<Vec<_>>>()
537        else {
538            continue;
539        };
540        if !name_valid || !labels.iter().all(|(k, _)| is_valid_label_name(k)) {
541            continue;
542        }
543        grouped.entry((name.clone(), labels)).or_default().push((
544            bits.map(f64::from_bits),
545            *kind,
546            help.clone(),
547        ));
548    }
549
550    let mut published = BTreeMap::new();
551    let mut collisions = 0u64;
552    let mut null_values = 0u64;
553    for (key, candidates) in grouped {
554        let mut non_null: Vec<PublishedValue> = candidates
555            .into_iter()
556            .filter_map(|(value, kind, help)| value.map(|v| (v, kind, help)))
557            .collect();
558        if non_null.is_empty() {
559            null_values += 1;
560            continue;
561        }
562        let mut distinct: Vec<u64> = non_null.iter().map(|(v, _, _)| v.to_bits()).collect();
563        distinct.sort_unstable();
564        distinct.dedup();
565        if distinct.len() > 1 {
566            collisions += 1;
567        }
568        non_null.sort_by(|a, b| {
569            a.0.total_cmp(&b.0)
570                .then(a.1.cmp(&b.1))
571                .then_with(|| a.2.cmp(&b.2))
572        });
573        let winner = non_null
574            .into_iter()
575            .next()
576            .expect("checked non-empty above");
577        published.insert(key, winner);
578    }
579    (published, collisions, null_values)
580}
581
582/// Counts published series whose own `metric_type`/`help` disagree with their family's winning
583/// type/help. See [`build_families`] for how the winner is chosen.
584fn count_conflicts(published: &BTreeMap<PublishedKey, PublishedValue>) -> u64 {
585    let mut conflicts = 0u64;
586    let mut winner: Option<(&str, MetricKind, &str)> = None;
587    for ((name, _labels), (_value, kind, help)) in published {
588        winner = match winner {
589            Some((n, k, h)) if n == name.as_str() => {
590                if k != *kind || h != help.as_str() {
591                    conflicts += 1;
592                }
593                Some((n, k, h))
594            }
595            _ => Some((name.as_str(), *kind, help.as_str())),
596        };
597    }
598    conflicts
599}
600
601/// Groups `published` by metric name into one `MetricFamily` per name, since Prometheus requires
602/// a single type and help string per family. Within a group, the entry with the
603/// lexicographically smallest label vector wins the family's type and help string;
604/// `BTreeMap`'s `(name, labels)` key ordering already sorts each group that way, so the
605/// first entry seen for a given name is that winner.
606///
607/// `MetricFamily.type_` and `Metric.{gauge,counter}` are protobuf wrapper types
608/// (`EnumOrUnknown`/`MessageField`).
609fn build_families(published: &BTreeMap<PublishedKey, PublishedValue>) -> Vec<MetricFamily> {
610    let mut families = Vec::new();
611    let mut group_name: Option<&str> = None;
612    let mut family: Option<MetricFamily> = None;
613    let mut family_kind = MetricKind::Gauge;
614
615    for ((name, labels), (value, kind, help)) in published {
616        if group_name != Some(name.as_str()) {
617            if let Some(f) = family.take() {
618                families.push(f);
619            }
620            let mut mf = MetricFamily::new();
621            mf.name = Some(name.clone());
622            mf.help = Some(help.clone());
623            mf.type_ = Some(kind.proto_type().into());
624            family = Some(mf);
625            family_kind = *kind;
626            group_name = Some(name.as_str());
627        }
628
629        let mut metric = ProtoMetric::new();
630        metric.label = labels
631            .iter()
632            .map(|(k, v)| {
633                let mut lp = LabelPair::new();
634                lp.name = Some(k.clone());
635                lp.value = Some(v.clone());
636                lp
637            })
638            .collect();
639        match family_kind {
640            MetricKind::Gauge => {
641                let mut g = ProtoGauge::new();
642                g.value = Some(*value);
643                metric.gauge = Some(g).into();
644            }
645            MetricKind::Counter => {
646                let mut c = ProtoCounter::new();
647                c.value = Some(*value);
648                metric.counter = Some(c).into();
649            }
650        }
651        family
652            .as_mut()
653            .expect("initialized above for the first entry of every group")
654            .metric
655            .push(metric);
656    }
657    if let Some(f) = family.take() {
658        families.push(f);
659    }
660    families
661}
662
663/// A `prometheus::core::Collector` that exposes a metric sink's [`SinkState`].
664///
665/// The companion gauges (`mz_compute_metric_sink_*`) are declared statically, each carrying a `sink`
666/// const label so that per-sink series get distinct `Desc` ids on registration. The user-defined
667/// series are entirely dynamic: their names come from the sink's source query, so they are built
668/// directly as [`MetricFamily`] protos in `collect` and are not declared via `desc`. Prometheus's
669/// registry only uses `desc` for registration-time collision detection, not to validate the
670/// output of `collect`, so this is safe.
671#[derive(Clone)]
672struct SinkCollector {
673    state: Arc<Mutex<SinkState>>,
674    frontier_gauge: Gauge,
675    errors_gauge: Gauge,
676    skipped_gauge: Gauge,
677    conflicts_gauge: Gauge,
678    collisions_gauge: Gauge,
679    null_values_gauge: Gauge,
680}
681
682impl SinkCollector {
683    fn new(label: &str, state: Arc<Mutex<SinkState>>) -> Self {
684        let gauge = |name: &str, help: &str| {
685            Gauge::with_opts(Opts::new(name, help).const_label("sink", label))
686                .expect("static metric sink companion gauge options are valid")
687        };
688        SinkCollector {
689            state,
690            frontier_gauge: gauge(
691                "mz_compute_metric_sink_frontier_ms",
692                "The metric sink's input frontier, in milliseconds since the epoch.",
693            ),
694            errors_gauge: gauge(
695                "mz_compute_metric_sink_errors",
696                "The number of live errors on the metric sink's input.",
697            ),
698            skipped_gauge: gauge(
699                "mz_compute_metric_sink_skipped",
700                "The number of input rows skipped for an unsupported metric type, an invalid name, or a null or empty label value.",
701            ),
702            conflicts_gauge: gauge(
703                "mz_compute_metric_sink_conflicts",
704                "The number of published series whose type or help disagree with their family's chosen type or help.",
705            ),
706            collisions_gauge: gauge(
707                "mz_compute_metric_sink_collisions",
708                "The number of series with more than one distinct live value for the same metric name and labels.",
709            ),
710            null_values_gauge: gauge(
711                "mz_compute_metric_sink_null_values",
712                "The number of series currently suppressed because their value is null.",
713            ),
714        }
715    }
716}
717
718impl Collector for SinkCollector {
719    fn desc(&self) -> Vec<&Desc> {
720        let mut descs = Vec::with_capacity(6);
721        descs.extend(self.frontier_gauge.desc());
722        descs.extend(self.errors_gauge.desc());
723        descs.extend(self.skipped_gauge.desc());
724        descs.extend(self.conflicts_gauge.desc());
725        descs.extend(self.collisions_gauge.desc());
726        descs.extend(self.null_values_gauge.desc());
727        descs
728    }
729
730    fn collect(&self) -> Vec<MetricFamily> {
731        let mut families = {
732            let state = self.state.lock().expect("sink state mutex poisoned");
733            self.frontier_gauge.set(f64::cast_lossy(state.frontier_ms));
734            self.errors_gauge.set(f64::cast_lossy(state.errors));
735            self.skipped_gauge.set(f64::cast_lossy(state.skipped));
736            self.conflicts_gauge.set(f64::cast_lossy(state.conflicts));
737            self.collisions_gauge.set(f64::cast_lossy(state.collisions));
738            self.null_values_gauge
739                .set(f64::cast_lossy(state.null_values));
740            build_families(&state.published)
741        };
742
743        families.extend(self.frontier_gauge.collect());
744        families.extend(self.errors_gauge.collect());
745        families.extend(self.skipped_gauge.collect());
746        families.extend(self.conflicts_gauge.collect());
747        families.extend(self.collisions_gauge.collect());
748        families.extend(self.null_values_gauge.collect());
749        families
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    /// A frontier that has closed every timestamp strictly below `bound`.
758    fn frontier(bound: u64) -> Antichain<Timestamp> {
759        Antichain::from_elem(Timestamp::from(bound))
760    }
761
762    fn label_a() -> Vec<(String, String)> {
763        vec![("a".into(), "1".into())]
764    }
765
766    /// `label_a()`, borrowed: what `stage_ok` now takes (see `extract_row`).
767    const LABEL_A: &[(&str, Option<&str>)] = &[("a", Some("1"))];
768
769    fn key_m() -> PublishedKey {
770        ("m".into(), label_a())
771    }
772
773    /// Mirrors the shaped relation `shape_metric_sink_source` builds: `labels`/`help` are
774    /// non-null by construction, `metric_name`/`value` stay nullable, and `metric_kind`/
775    /// `name_valid` are the planner's computed classification columns.
776    fn shaped_desc() -> RelationDesc {
777        use mz_repr::SqlScalarType;
778
779        RelationDesc::builder()
780            .with_column("metric_name", SqlScalarType::String.nullable(true))
781            .with_column(
782                "labels",
783                SqlScalarType::Map {
784                    value_type: Box::new(SqlScalarType::String),
785                    custom_id: None,
786                }
787                .nullable(false),
788            )
789            .with_column("value", SqlScalarType::Float64.nullable(true))
790            .with_column("help", SqlScalarType::String.nullable(false))
791            .with_column("metric_kind", SqlScalarType::Int32.nullable(true))
792            .with_column("name_valid", SqlScalarType::Bool.nullable(true))
793            .finish()
794    }
795
796    /// Stages one gauge update for the `(m, {a:1})` series.
797    fn stage_m(st: &mut SinkState, value: f64, time: u64, diff: i64) {
798        st.stage_ok(
799            "m",
800            Some(MetricKind::Gauge),
801            true,
802            LABEL_A,
803            Some(value),
804            "h",
805            Timestamp::from(time),
806            diff,
807        );
808    }
809
810    /// Stages one gauge update with a null `value` for the `(m, {a:1})` series.
811    fn stage_m_null(st: &mut SinkState, time: u64, diff: i64) {
812        st.stage_ok(
813            "m",
814            Some(MetricKind::Gauge),
815            true,
816            LABEL_A,
817            None,
818            "h",
819            Timestamp::from(time),
820            diff,
821        );
822    }
823
824    #[mz_ore::test]
825    fn fold_and_publish() {
826        let mut st = SinkState::default();
827        stage_m(&mut st, 2.0, 0, 1);
828        st.integrate(&frontier(1));
829        st.publish_if_healthy();
830        assert_eq!(st.published.len(), 1);
831        assert_eq!(st.published[&key_m()].0, 2.0);
832
833        // Unsupported type (metric_kind = None) is skipped and counted.
834        st.stage_ok("h1", None, true, &[], Some(1.0), "h", Timestamp::from(1), 1);
835        st.integrate(&frontier(2));
836        assert_eq!(st.skipped, 1);
837
838        // Error freezes publication. The update at time 2 retracts the old value and inserts the
839        // new one, both fold into `working` while frozen.
840        st.errors = 1;
841        stage_m(&mut st, 2.0, 2, -1);
842        stage_m(&mut st, 9.0, 2, 1);
843        st.integrate(&frontier(3));
844        st.publish_if_healthy();
845        assert_eq!(st.published[&key_m()].0, 2.0);
846
847        // Recovery republishes the integrated value.
848        st.errors = 0;
849        st.publish_if_healthy();
850        assert_eq!(st.published[&key_m()].0, 9.0);
851        assert_eq!(st.collisions, 0);
852    }
853
854    #[mz_ore::test]
855    fn value_update_split_across_activations_no_collision() {
856        let mut st = SinkState::default();
857        // Establish the series at value 5.
858        stage_m(&mut st, 5.0, 0, 1);
859        st.integrate(&frontier(1));
860        st.publish_if_healthy();
861        assert_eq!(st.published[&key_m()].0, 5.0);
862
863        // A value update 5 -> 9 at time 1 arrives insert-first, split across two activations. The
864        // timestamp stays open until both diffs are buffered.
865        stage_m(&mut st, 9.0, 1, 1);
866        st.integrate(&frontier(1));
867        st.publish_if_healthy();
868        assert_eq!(st.collisions, 0);
869        stage_m(&mut st, 5.0, 1, -1);
870
871        // Close the timestamp: the series is present at value 9 with no collision.
872        st.integrate(&frontier(2));
873        st.publish_if_healthy();
874        assert_eq!(st.published[&key_m()].0, 9.0);
875        assert_eq!(st.collisions, 0);
876    }
877
878    #[mz_ore::test]
879    fn duplicate_multiplicity_consolidates() {
880        let mut st = SinkState::default();
881        // The same identity at multiplicity 2 consolidates to a single live row.
882        stage_m(&mut st, 5.0, 0, 1);
883        stage_m(&mut st, 5.0, 0, 1);
884        st.integrate(&frontier(1));
885        st.publish_if_healthy();
886        assert_eq!(st.published[&key_m()].0, 5.0);
887        assert_eq!(st.collisions, 0);
888
889        // A second, distinct live value for the same series is a genuine collision.
890        stage_m(&mut st, 7.0, 1, 1);
891        st.integrate(&frontier(2));
892        st.publish_if_healthy();
893        assert_eq!(st.collisions, 1);
894        // The smallest value wins deterministically.
895        assert_eq!(st.published[&key_m()].0, 5.0);
896    }
897
898    #[mz_ore::test]
899    fn no_publish_before_time_closed() {
900        let mut st = SinkState::default();
901        // An update at time 5 must not appear while the frontier still allows data at time 5.
902        stage_m(&mut st, 2.0, 5, 1);
903        st.integrate(&frontier(5));
904        st.publish_if_healthy();
905        assert!(st.published.is_empty());
906
907        // Once the frontier advances past time 5, the update publishes.
908        st.integrate(&frontier(6));
909        st.publish_if_healthy();
910        assert_eq!(st.published[&key_m()].0, 2.0);
911    }
912
913    #[mz_ore::test]
914    fn null_value_gaps_series() {
915        let mut st = SinkState::default();
916        // A null-valued row for (m,{a}) at a closed time: no series, counted in null_values.
917        stage_m_null(&mut st, 1, 1);
918        st.integrate(&frontier(2));
919        st.publish_if_healthy();
920        assert!(!st.published.contains_key(&key_m()));
921        assert_eq!(st.null_values, 1);
922
923        // A later non-null value republishes the series (gap closes) and clears the count, even
924        // though the null-valued row is still live alongside it.
925        stage_m(&mut st, 5.0, 3, 1);
926        st.integrate(&frontier(4));
927        st.publish_if_healthy();
928        assert_eq!(st.published[&key_m()].0, 5.0);
929        assert_eq!(st.null_values, 0);
930    }
931
932    #[mz_ore::test]
933    fn null_labels_become_empty() {
934        let mut st = SinkState::default();
935        // An empty label vector (the shaped relation's `{}` for a source row with no labels)
936        // keys and publishes correctly.
937        st.stage_ok(
938            "m",
939            Some(MetricKind::Gauge),
940            true,
941            &[],
942            Some(1.0),
943            "h",
944            Timestamp::from(1),
945            1,
946        );
947        st.integrate(&frontier(2));
948        st.publish_if_healthy();
949        assert_eq!(st.published[&("m".into(), vec![])].0, 1.0);
950    }
951
952    #[mz_ore::test]
953    fn extract_row_normalizes_null_datums() {
954        let desc = shaped_desc();
955        let cols = ColumnIndices::resolve(&desc);
956
957        let mut row = Row::default();
958        {
959            let mut packer = row.packer();
960            packer.push(Datum::Null); // metric_name
961            packer.push_dict_with(|_| {}); // labels: always non-null by construction
962            packer.push(Datum::Null); // value
963            packer.push(Datum::String("")); // help: always non-null by construction
964            packer.push(Datum::Null); // metric_kind: defensively treated as unsupported
965            packer.push(Datum::Null); // name_valid: defensively treated as invalid
966        }
967
968        let datums: Vec<Datum> = row.iter().collect();
969        let (name, metric_kind, name_valid, labels, value, help) = extract_row(&cols, &datums);
970        assert_eq!(name, "");
971        assert_eq!(metric_kind, None);
972        assert!(!name_valid);
973        assert_eq!(labels, Vec::new());
974        assert_eq!(value, None);
975        assert_eq!(help, "");
976    }
977
978    /// A null map value must survive extraction as `None`; unwrapping it as a string panicked the
979    /// worker and took down the replica.
980    #[mz_ore::test]
981    fn extract_row_keeps_null_label_values() {
982        let desc = shaped_desc();
983        let cols = ColumnIndices::resolve(&desc);
984
985        let mut row = Row::default();
986        {
987            let mut packer = row.packer();
988            packer.push(Datum::String("m"));
989            packer.push_dict_with(|row| {
990                row.push(Datum::String("bad"));
991                row.push(Datum::Null);
992                row.push(Datum::String("good"));
993                row.push(Datum::String("1"));
994            });
995            packer.push(Datum::Float64(1.0.into()));
996            packer.push(Datum::String("h"));
997            packer.push(Datum::Int32(0));
998            packer.push(Datum::True);
999        }
1000
1001        let datums: Vec<Datum> = row.iter().collect();
1002        let (_name, _metric_kind, _name_valid, labels, _value, _help) = extract_row(&cols, &datums);
1003        assert_eq!(labels, vec![("bad", None), ("good", Some("1"))]);
1004    }
1005
1006    #[mz_ore::test]
1007    fn null_or_empty_label_value_skips_row() {
1008        let mut st = SinkState::default();
1009        // A null label value has no representable label set: publishes nothing, counts as skipped.
1010        st.stage_ok(
1011            "m",
1012            Some(MetricKind::Gauge),
1013            true,
1014            &[("a", None)],
1015            Some(1.0),
1016            "h",
1017            Timestamp::from(1),
1018            1,
1019        );
1020        st.integrate(&frontier(2));
1021        st.publish_if_healthy();
1022        assert!(st.published.is_empty());
1023        assert_eq!(st.skipped, 1);
1024
1025        // An empty label value folds to `{}` in Prometheus, so it is skipped too rather than
1026        // published as a bare `{}` series. It is a distinct identity from the null row, so both
1027        // stay live and count: `skipped` reaches 2, not 1.
1028        st.stage_ok(
1029            "m",
1030            Some(MetricKind::Gauge),
1031            true,
1032            &[("a", Some(""))],
1033            Some(1.0),
1034            "h",
1035            Timestamp::from(3),
1036            1,
1037        );
1038        st.integrate(&frontier(4));
1039        st.publish_if_healthy();
1040        assert!(st.published.is_empty());
1041        assert_eq!(st.skipped, 2);
1042    }
1043
1044    fn pkey(name: &str, labels: &[(&str, &str)]) -> PublishedKey {
1045        (
1046            name.to_string(),
1047            labels
1048                .iter()
1049                .map(|&(k, v)| (k.to_string(), v.to_string()))
1050                .collect(),
1051        )
1052    }
1053
1054    #[mz_ore::test]
1055    fn build_families_groups_by_name_and_kind() {
1056        let published: BTreeMap<PublishedKey, PublishedValue> = BTreeMap::from([
1057            (
1058                pkey("http_requests", &[("code", "200")]),
1059                (5.0, MetricKind::Counter, "requests".to_string()),
1060            ),
1061            (
1062                pkey("http_requests", &[("code", "500")]),
1063                (2.0, MetricKind::Counter, "requests".to_string()),
1064            ),
1065            (
1066                pkey("temp_celsius", &[]),
1067                (21.5, MetricKind::Gauge, "temperature".to_string()),
1068            ),
1069        ]);
1070
1071        let families = build_families(&published);
1072
1073        // One family per metric name, in `BTreeMap` (name) order.
1074        assert_eq!(families.len(), 2);
1075
1076        let requests = &families[0];
1077        assert_eq!(requests.name(), "http_requests");
1078        assert_eq!(requests.help(), "requests");
1079        let metrics = requests.get_metric();
1080        assert_eq!(metrics.len(), 2);
1081        // Metrics keep the `BTreeMap` label order, and land in the counter oneof.
1082        assert_eq!(metrics[0].get_label()[0].value(), "200");
1083        assert_eq!(metrics[0].get_counter().value(), 5.0);
1084        assert_eq!(metrics[1].get_label()[0].value(), "500");
1085        assert_eq!(metrics[1].get_counter().value(), 2.0);
1086
1087        let temp = &families[1];
1088        assert_eq!(temp.name(), "temp_celsius");
1089        let temp_metrics = temp.get_metric();
1090        assert_eq!(temp_metrics.len(), 1);
1091        assert_eq!(temp_metrics[0].get_gauge().value(), 21.5);
1092    }
1093
1094    #[mz_ore::test]
1095    fn count_conflicts_flags_type_and_help_disagreement() {
1096        // The family winner is the smallest-label entry. Here `m`'s winner is the `[a=1]` gauge with
1097        // help `h1`; the other two disagree on kind, then help.
1098        let published: BTreeMap<PublishedKey, PublishedValue> = BTreeMap::from([
1099            (
1100                pkey("m", &[("a", "1")]),
1101                (1.0, MetricKind::Gauge, "h1".to_string()),
1102            ),
1103            (
1104                pkey("m", &[("b", "2")]),
1105                (2.0, MetricKind::Counter, "h1".to_string()),
1106            ),
1107            (
1108                pkey("m", &[("c", "3")]),
1109                (3.0, MetricKind::Gauge, "h2".to_string()),
1110            ),
1111            (
1112                pkey("other", &[]),
1113                (1.0, MetricKind::Gauge, "h".to_string()),
1114            ),
1115        ]);
1116        assert_eq!(count_conflicts(&published), 2);
1117
1118        // A family whose entries all agree has no conflicts.
1119        let consistent: BTreeMap<PublishedKey, PublishedValue> = BTreeMap::from([
1120            (
1121                pkey("m", &[("a", "1")]),
1122                (1.0, MetricKind::Gauge, "h".to_string()),
1123            ),
1124            (
1125                pkey("m", &[("b", "2")]),
1126                (2.0, MetricKind::Gauge, "h".to_string()),
1127            ),
1128        ]);
1129        assert_eq!(count_conflicts(&consistent), 0);
1130    }
1131
1132    #[mz_ore::test]
1133    fn err_stream_freezes_and_recovers() {
1134        let mut st = SinkState::default();
1135        // Establish a healthy value.
1136        stage_m(&mut st, 5.0, 0, 1);
1137        st.integrate(&frontier(1));
1138        st.publish_if_healthy();
1139        assert_eq!(st.published[&key_m()].0, 5.0);
1140
1141        // An error appears, buffered through `stage_err` (not by setting `errors` directly). A value
1142        // update lands in the same window.
1143        st.stage_err(Timestamp::from(1), 1);
1144        stage_m(&mut st, 5.0, 1, -1);
1145        stage_m(&mut st, 9.0, 1, 1);
1146        st.integrate(&frontier(2));
1147        assert_eq!(st.errors, 1);
1148        st.publish_if_healthy();
1149        // Publication frozen at the last healthy value while erroring.
1150        assert_eq!(st.published[&key_m()].0, 5.0);
1151
1152        // The error is retracted; net errors returns to 0 and publication recovers to the value
1153        // integrated during the freeze.
1154        st.stage_err(Timestamp::from(2), -1);
1155        st.integrate(&frontier(3));
1156        assert_eq!(st.errors, 0);
1157        st.publish_if_healthy();
1158        assert_eq!(st.published[&key_m()].0, 9.0);
1159    }
1160
1161    /// Every companion gauge carries the label the collector was built with. A curated sink passes
1162    /// its stable name here, so its health series stay identifiable across boots even though the
1163    /// sink's `GlobalId` is transient.
1164    #[mz_ore::test]
1165    fn collector_labels_gauges_with_sink_label() {
1166        let state = Arc::new(Mutex::new(SinkState::default()));
1167        let collector = SinkCollector::new("mz_curated_example", state);
1168
1169        let families = collector.collect();
1170        // The point is that every emitted family carries the label, so guard only that there is
1171        // something to check. Pinning the exact count churns on every added gauge without telling
1172        // the next reader whether they broke labelling or just added a family.
1173        assert!(!families.is_empty());
1174        for family in &families {
1175            for metric in family.get_metric() {
1176                let sink = metric
1177                    .get_label()
1178                    .iter()
1179                    .find(|l| l.name() == "sink")
1180                    .expect("sink label present");
1181                assert_eq!(sink.value(), "mz_curated_example");
1182            }
1183        }
1184    }
1185}