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