Skip to main content

mz_adapter/optimize/
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//! Row-wise shaping for `MetricSink` sources.
11//!
12//! A metric sink exports the rows of an existing collection into the in-process Prometheus metrics
13//! registry. The compute-side operator (`mz_compute::sink::metric_sink`) reads a canonical row
14//! shape and two planner-computed classification columns rather than parsing `metric_type` strings
15//! or validating metric names on its hot path. `shape_metric_sink_source` produces that shape.
16//!
17//! Only the pure per-row shaping lives here. The cross-row logic (dedup, collision detection,
18//! family-conflict counting) stays in the operator, because it needs the frontier-gated fold that
19//! a per-row `Map` in MIR can't express.
20
21use mz_expr::func::variadic::Coalesce;
22use mz_expr::{MirRelationExpr, MirScalarExpr, func};
23use mz_repr::{
24    ColumnName, Datum, GlobalId, RelationDesc, ReprRelationType, ReprScalarType, Row, SqlScalarType,
25};
26
27/// Matches Prometheus's metric name grammar: `[a-zA-Z_:][a-zA-Z0-9_:]*`.
28///
29/// Expressed in MIR (see `shape_metric_sink_source`) rather than parsed from a `&str` on the
30/// operator's hot path.
31// NOTE: `shape_metric_sink_source` (and this pattern) have no caller yet, hence the
32// `#[allow(dead_code)]`, but adding them alongside the compute operator
33// (`mz_compute::sink::metric_sink`) that reads the `metric_kind`/`name_valid`
34// column contract, so they live together.
35#[allow(dead_code)]
36const METRIC_NAME_PATTERN: &str = "^[a-zA-Z_:][a-zA-Z0-9_:]*$";
37
38/// Extends the metric sink's imported relation with the row-wise shaping the operator otherwise
39/// has to do in Rust: coalesces `labels`/`help` to their identity element, and adds two columns
40/// the operator reads instead of parsing strings on its hot path:
41///
42/// * `metric_kind` (`Int32`, nullable): `0` for `gauge`, `1` for `counter`, `NULL` for any other
43///   `metric_type`.
44/// * `name_valid` (`Bool`, nullable): whether `metric_name` matches the Prometheus metric-name
45///   grammar (see `METRIC_NAME_PATTERN`). The operator treats a `NULL` the same as `false`.
46///
47/// No row is dropped or filtered here: the operator still needs every row, including the ones
48/// this marks invalid, to count `skipped`/`null_values`. Only the pure per-row shaping moves to
49/// MIR. Dedup, collision detection, and family-conflict counting stay in the operator, because
50/// they need cross-row state (the frontier-gated fold) that a `Map` can't express.
51///
52/// TODO: A full move would also express the dedup/collision/family-conflict logic in MIR (e.g.
53/// via `Reduce` + `FirstValue`), collapsing the operator to a plain fold over the live set. That
54/// full move is deferred: the tiebreak fidelity that logic needs is easier to keep correct
55/// hand-written and unit-tested for now.
56#[allow(dead_code)]
57fn shape_metric_sink_source(
58    from_id: GlobalId,
59    from_desc: &RelationDesc,
60) -> (MirRelationExpr, RelationDesc) {
61    // Precondition: the source relation exposes the canonical metric-sink columns (`metric_name`,
62    // `metric_type`, `labels`, `value`, `help`). No in-tree caller enforces this yet (see the NOTE
63    // on `METRIC_NAME_PATTERN`); the SQL planner will, once the CREATE METRIC SINK planning path
64    // lands.
65    let get_idx = |name: &str| {
66        from_desc
67            .get_by_name(&ColumnName::from(name))
68            .expect("metric-sink source relation must expose the canonical columns")
69    };
70    let (metric_name_idx, metric_name_ct) = get_idx("metric_name");
71    let (metric_type_idx, metric_type_ct) = get_idx("metric_type");
72    let (labels_idx, labels_ct) = get_idx("labels");
73    let (value_idx, value_ct) = get_idx("value");
74    let (help_idx, help_ct) = get_idx("help");
75
76    let repr_typ = ReprRelationType::from(from_desc.typ());
77    let arity = repr_typ.column_types.len();
78    let labels_repr_type = ReprScalarType::from(&labels_ct.scalar_type);
79
80    let empty_map_row = {
81        let mut row = Row::default();
82        row.packer().push_dict_with(|_| {});
83        row
84    };
85    let labels_coalesced = MirScalarExpr::call_variadic(
86        Coalesce,
87        vec![
88            MirScalarExpr::column(labels_idx),
89            MirScalarExpr::literal_from_single_element_row(empty_map_row, labels_repr_type),
90        ],
91    );
92    let help_coalesced = MirScalarExpr::call_variadic(
93        Coalesce,
94        vec![
95            MirScalarExpr::column(help_idx),
96            MirScalarExpr::literal_ok(Datum::String(""), ReprScalarType::String),
97        ],
98    );
99
100    let metric_type_literal = |s: &'static str| {
101        MirScalarExpr::column(metric_type_idx).call_binary(
102            MirScalarExpr::literal_ok(Datum::String(s), ReprScalarType::String),
103            func::Eq,
104        )
105    };
106    let metric_kind = metric_type_literal("gauge").if_then_else(
107        MirScalarExpr::literal_ok(Datum::Int32(0), ReprScalarType::Int32),
108        metric_type_literal("counter").if_then_else(
109            MirScalarExpr::literal_ok(Datum::Int32(1), ReprScalarType::Int32),
110            MirScalarExpr::literal_null(ReprScalarType::Int32),
111        ),
112    );
113
114    // The regexp requires at least one leading character, so an empty name fails it without a
115    // separate `!= ""` check. `is_null().not()` keeps a null name concretely `false`, not `NULL`.
116    let name_valid = MirScalarExpr::column(metric_name_idx)
117        .call_is_null()
118        .not()
119        .and(MirScalarExpr::column(metric_name_idx).call_binary(
120            MirScalarExpr::literal_ok(Datum::String(METRIC_NAME_PATTERN), ReprScalarType::String),
121            func::IsRegexpMatchCaseSensitive,
122        ));
123
124    let shaped_expr = MirRelationExpr::global_get(from_id, repr_typ)
125        .map(vec![
126            labels_coalesced,
127            help_coalesced,
128            metric_kind,
129            name_valid,
130        ])
131        .project(vec![
132            metric_name_idx,
133            metric_type_idx,
134            arity, // coalesced labels
135            value_idx,
136            arity + 1, // coalesced help
137            arity + 2, // metric_kind
138            arity + 3, // name_valid
139        ]);
140
141    let mut labels_shaped_ct = labels_ct.clone();
142    labels_shaped_ct.nullable = false;
143    let mut help_shaped_ct = help_ct.clone();
144    help_shaped_ct.nullable = false;
145    let shaped_desc = RelationDesc::from_names_and_types([
146        ("metric_name", metric_name_ct.clone()),
147        ("metric_type", metric_type_ct.clone()),
148        ("labels", labels_shaped_ct),
149        ("value", value_ct.clone()),
150        ("help", help_shaped_ct),
151        ("metric_kind", SqlScalarType::Int32.nullable(true)),
152        ("name_valid", SqlScalarType::Bool.nullable(true)),
153    ]);
154
155    (shaped_expr, shaped_desc)
156}
157
158#[cfg(test)]
159mod tests {
160    use mz_expr::Eval;
161    use mz_repr::{RowArena, SqlColumnType};
162
163    use super::*;
164
165    /// The canonical metric-sink source shape, with `labels`/`help` nullable so the shaping's
166    /// coalesce is observable and an extra trailing column so column resolution is exercised by
167    /// name, not position.
168    fn source_desc() -> RelationDesc {
169        RelationDesc::builder()
170            .with_column("metric_name", SqlScalarType::String.nullable(true))
171            .with_column("metric_type", SqlScalarType::String.nullable(false))
172            .with_column(
173                "labels",
174                SqlScalarType::Map {
175                    value_type: Box::new(SqlScalarType::String),
176                    custom_id: None,
177                }
178                .nullable(true),
179            )
180            .with_column("value", SqlScalarType::Float64.nullable(true))
181            .with_column("help", SqlScalarType::String.nullable(true))
182            .with_column("extra", SqlScalarType::String.nullable(true))
183            .finish()
184    }
185
186    #[mz_ore::test]
187    fn shaped_desc_column_contract() {
188        let (_expr, desc) = shape_metric_sink_source(GlobalId::Transient(0), &source_desc());
189
190        let cols: Vec<(String, SqlColumnType)> = desc
191            .iter()
192            .map(|(name, ty)| (name.as_str().to_string(), ty.clone()))
193            .collect();
194
195        // Exactly the seven canonical columns, in order. The trailing `extra` source column is
196        // projected away.
197        let names: Vec<&str> = cols.iter().map(|(n, _)| n.as_str()).collect();
198        assert_eq!(
199            names,
200            vec![
201                "metric_name",
202                "metric_type",
203                "labels",
204                "value",
205                "help",
206                "metric_kind",
207                "name_valid",
208            ]
209        );
210
211        let by_name = |name: &str| {
212            cols.iter()
213                .find(|(n, _)| n == name)
214                .expect("column present in shaped desc")
215                .1
216                .clone()
217        };
218
219        // `labels`/`help` are coalesced to their identity element, so they are non-null.
220        assert!(!by_name("labels").nullable);
221        assert!(!by_name("help").nullable);
222
223        // `metric_name`/`value` stay nullable (no identity element).
224        assert!(by_name("metric_name").nullable);
225        assert!(by_name("value").nullable);
226
227        // The two classification columns the operator reads.
228        assert_eq!(by_name("metric_kind"), SqlScalarType::Int32.nullable(true));
229        assert_eq!(by_name("name_valid"), SqlScalarType::Bool.nullable(true));
230    }
231
232    #[mz_ore::test]
233    fn shaped_expr_projects_seven_columns() {
234        let (expr, _desc) = shape_metric_sink_source(GlobalId::Transient(0), &source_desc());
235
236        // The shaping is a `Map` of four new columns followed by a `Project` down to the seven
237        // canonical columns.
238        match &expr {
239            MirRelationExpr::Project { outputs, .. } => {
240                assert_eq!(outputs.len(), 7);
241            }
242            other => panic!("expected a Project at the root of the shaped expr, got {other:?}"),
243        }
244    }
245
246    /// The four scalars the shaping `Map` appends, in order:
247    /// `[labels_coalesced, help_coalesced, metric_kind, name_valid]`. Lets the two classification
248    /// scalars be evaluated directly against an input row.
249    fn shaped_map_scalars(desc: &RelationDesc) -> Vec<MirScalarExpr> {
250        let (expr, _desc) = shape_metric_sink_source(GlobalId::Transient(0), desc);
251        match expr {
252            MirRelationExpr::Project { input, .. } => match *input {
253                MirRelationExpr::Map { scalars, .. } => scalars,
254                other => panic!("expected a Map under the Project, got {other:?}"),
255            },
256            other => panic!("expected a Project at the root, got {other:?}"),
257        }
258    }
259
260    #[mz_ore::test]
261    fn metric_kind_classifies_type() {
262        let scalars = shaped_map_scalars(&source_desc());
263        let metric_kind = &scalars[2];
264        let arena = RowArena::new();
265        // Row layout matches `source_desc`: [metric_name, metric_type, labels, value, help, extra].
266        for (metric_type, expected) in [
267            ("gauge", Datum::Int32(0)),
268            ("counter", Datum::Int32(1)),
269            ("histogram", Datum::Null),
270            ("summary", Datum::Null),
271        ] {
272            let row = [
273                Datum::Null,
274                Datum::String(metric_type),
275                Datum::Null,
276                Datum::Null,
277                Datum::Null,
278                Datum::Null,
279            ];
280            assert_eq!(
281                metric_kind
282                    .eval(&row, &arena)
283                    .expect("metric_kind eval succeeds"),
284                expected,
285                "metric_type = {metric_type}",
286            );
287        }
288    }
289
290    #[mz_ore::test]
291    fn name_valid_matches_prometheus_grammar() {
292        let scalars = shaped_map_scalars(&source_desc());
293        let name_valid = &scalars[3];
294        let arena = RowArena::new();
295        for (metric_name, expected) in [
296            (Datum::String("http_requests_total"), Datum::True),
297            (Datum::String("with:colons_and_1_digit"), Datum::True),
298            (Datum::String("1_leading_digit"), Datum::False),
299            (Datum::String("has-a-dash"), Datum::False),
300            (Datum::String(""), Datum::False),
301            (Datum::Null, Datum::False),
302        ] {
303            let row = [
304                metric_name,
305                Datum::Null,
306                Datum::Null,
307                Datum::Null,
308                Datum::Null,
309                Datum::Null,
310            ];
311            assert_eq!(
312                name_valid
313                    .eval(&row, &arena)
314                    .expect("name_valid eval succeeds"),
315                expected,
316                "metric_name = {metric_name:?}",
317            );
318        }
319    }
320}