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