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 std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use mz_compute_types::plan::LirRelationExpr;
25use mz_compute_types::sinks::{ComputeSinkConnection, ComputeSinkDesc, MetricSinkConnection};
26use mz_expr::func::variadic::Coalesce;
27use mz_expr::{MirRelationExpr, MirScalarExpr, func};
28use mz_repr::explain::trace_plan;
29use mz_repr::{
30    ColumnName, Datum, GlobalId, RelationDesc, ReprRelationType, ReprScalarType, Row, SqlScalarType,
31};
32use mz_sql::optimizer_metrics::OptimizerMetrics;
33use mz_sql::plan::{HirRelationExpr, HirToMirConfig};
34use mz_transform::TransformCtx;
35use mz_transform::dataflow::DataflowMetainfo;
36use mz_transform::normalize_lets::normalize_lets;
37use mz_transform::typecheck::{SharedTypecheckingContext, empty_typechecking_context};
38use timely::progress::Antichain;
39
40use crate::optimize::dataflows::{
41    ComputeInstanceSnapshot, DataflowBuilder, ExprPrep, ExprPrepMaintained,
42};
43use crate::optimize::{
44    LirDataflowDescription, MirDataflowDescription, Optimize, OptimizerCatalog, OptimizerConfig,
45    OptimizerError, optimize_mir_local,
46};
47
48/// Matches Prometheus's metric name grammar: `[a-zA-Z_:][a-zA-Z0-9_:]*`.
49///
50/// Expressed in MIR (see `shape_metric_sink_source`) rather than parsed from a `&str` on the
51/// operator's hot path.
52const METRIC_NAME_PATTERN: &str = "^[a-zA-Z_:][a-zA-Z0-9_:]*$";
53
54/// Optimizer for metric sinks, both `CREATE METRIC SINK` and the coordinator-installed curated
55/// sinks.
56///
57/// The source is either an existing collection (like `CREATE INDEX`, no HIR to lower) or a planned
58/// query (like a materialized view), see [`MetricSinkFrom`]. Either way the row-wise shaping is
59/// appended in MIR and the dataflow exports a single `MetricSink`. Unlike a materialized view sink
60/// there is no persist shard, so there is no storage-metadata stage.
61pub struct Optimizer {
62    /// A representation typechecking context to use throughout the optimizer pipeline.
63    typecheck_ctx: SharedTypecheckingContext,
64    /// A snapshot of the catalog state.
65    catalog: Arc<dyn OptimizerCatalog>,
66    /// A snapshot of the cluster that will run the dataflow.
67    compute_instance: ComputeInstanceSnapshot,
68    /// A transient GlobalId for the shaped view built over the sink's source relation (see
69    /// `shape_metric_sink_source`).
70    view_id: GlobalId,
71    /// A durable GlobalId to be used with the exported metric sink.
72    sink_id: GlobalId,
73    /// Optimizer config.
74    config: OptimizerConfig,
75    /// Optimizer metrics.
76    metrics: OptimizerMetrics,
77    /// The time spent performing optimization so far.
78    duration: Duration,
79}
80
81impl Optimizer {
82    pub fn new(
83        catalog: Arc<dyn OptimizerCatalog>,
84        compute_instance: ComputeInstanceSnapshot,
85        view_id: GlobalId,
86        sink_id: GlobalId,
87        config: OptimizerConfig,
88        metrics: OptimizerMetrics,
89    ) -> Self {
90        Self {
91            typecheck_ctx: empty_typechecking_context(),
92            catalog,
93            compute_instance,
94            view_id,
95            sink_id,
96            config,
97            metrics,
98            duration: Default::default(),
99        }
100    }
101}
102
103/// A wrapper of metric sink parts needed to start the optimization process.
104pub struct MetricSink {
105    /// Names the assembled dataflow, for debugging.
106    debug_name: String,
107    /// The collection whose rows the sink exports.
108    from: MetricSinkFrom,
109    /// Prepended to every row's `metric_name` to form the published name. Validated as a valid start
110    /// of a Prometheus metric name before it reaches here (plan time for a user sink, install time
111    /// for a curated one).
112    prefix: String,
113    /// Value for the `sink` label on the sink's health gauges. `None` defaults to the sink's
114    /// `GlobalId`, which is what a user sink wants. A curated sink passes its stable name.
115    label: Option<String>,
116}
117
118impl MetricSink {
119    /// Construct a new [`MetricSink`]. Arguments are recorded as-is.
120    pub fn new(
121        debug_name: String,
122        from: MetricSinkFrom,
123        prefix: String,
124        label: Option<String>,
125    ) -> Self {
126        Self {
127            debug_name,
128            from,
129            prefix,
130            label,
131        }
132    }
133}
134
135/// Where a metric sink's rows come from.
136///
137/// Either way the source must expose the canonical metric-sink columns (see
138/// [`shape_metric_sink_source`]).
139pub enum MetricSinkFrom {
140    /// An existing catalog collection, as `CREATE METRIC SINK ... FROM <relation>` resolves to.
141    Id(GlobalId),
142    /// A planned query, as a coordinator-installed sink built from curated SQL uses. The query is
143    /// not a catalog item, so it is lowered and locally optimized here rather than imported.
144    Query {
145        expr: HirRelationExpr,
146        desc: RelationDesc,
147    },
148}
149
150/// The (sealed intermediate) result after embedding a [`MetricSink`] into a
151/// [`MirDataflowDescription`], inlining referenced views, and jointly optimizing the `MIR` plans.
152#[derive(Clone, Debug)]
153pub struct GlobalMirPlan {
154    df_desc: MirDataflowDescription,
155    df_meta: DataflowMetainfo,
156}
157
158impl GlobalMirPlan {
159    pub fn df_desc(&self) -> &MirDataflowDescription {
160        &self.df_desc
161    }
162}
163
164/// The (final) result after MIR ⇒ LIR lowering and optimizing the resulting
165/// `DataflowDescription` with `LIR` plans.
166#[derive(Clone, Debug)]
167pub struct GlobalLirPlan {
168    df_desc: LirDataflowDescription,
169    df_meta: DataflowMetainfo,
170}
171
172impl GlobalLirPlan {
173    pub fn df_desc(&self) -> &LirDataflowDescription {
174        &self.df_desc
175    }
176}
177
178impl Optimize<MetricSink> for Optimizer {
179    type To = GlobalMirPlan;
180
181    fn optimize(&mut self, metric_sink: MetricSink) -> Result<Self::To, OptimizerError> {
182        let time = Instant::now();
183
184        let mut df_builder = {
185            let compute = self.compute_instance.clone();
186            DataflowBuilder::new(&*self.catalog, compute).with_config(&self.config)
187        };
188        let mut df_desc = MirDataflowDescription::new(metric_sink.debug_name);
189        let mut df_meta = DataflowMetainfo::default();
190
191        let (source_expr, source_desc) = match metric_sink.from {
192            MetricSinkFrom::Id(from) => {
193                let from_desc = self
194                    .catalog
195                    .get_entry(&from)
196                    .relation_desc()
197                    .expect("can only create a metric sink on items with a valid description")
198                    .into_owned();
199                let repr_typ = ReprRelationType::from(from_desc.typ());
200                (MirRelationExpr::global_get(from, repr_typ), from_desc)
201            }
202            MetricSinkFrom::Query { expr, desc } => {
203                // HIR ⇒ MIR lowering and decorrelation. The result is inlined under the shaping
204                // below rather than becoming its own build, so the whole source is one view.
205                let expr = expr.lower(HirToMirConfig::from(&self.config), Some(&self.metrics))?;
206                (expr, desc)
207            }
208        };
209
210        // Push the pure row-wise shaping (coalesce identity elements, classify the metric kind,
211        // validate the metric name) into MIR, so the operator only does the cross-row logic
212        // (dedup/collision/family-conflict) that needs the fold. See `shape_metric_sink_source`.
213        let (shaped_expr, shaped_desc) =
214            shape_metric_sink_source(source_expr, &source_desc, &metric_sink.prefix);
215        let mut local_ctx = TransformCtx::local(
216            &self.config.features,
217            &self.typecheck_ctx,
218            &mut df_meta,
219            Some(&mut self.metrics),
220            Some(self.view_id),
221        );
222        let shaped_expr = optimize_mir_local(shaped_expr, &mut local_ctx)?;
223
224        // Imports the source's dependencies (the `Id` variant's collection, or the query's leaf
225        // collections) before inserting the shaped view that reads them.
226        df_builder.import_view_into_dataflow(
227            &self.view_id,
228            &shaped_expr,
229            &mut df_desc,
230            &self.config.features,
231        )?;
232        df_builder.maybe_reoptimize_imported_views(&mut df_desc, &self.config)?;
233
234        let sink_description = ComputeSinkDesc {
235            from: self.view_id,
236            from_desc: shaped_desc,
237            connection: ComputeSinkConnection::MetricSink(MetricSinkConnection {
238                label: metric_sink
239                    .label
240                    .unwrap_or_else(|| self.sink_id.to_string()),
241            }),
242            with_snapshot: true,
243            up_to: Antichain::new(),
244            non_null_assertions: Vec::new(),
245            refresh_schedule: None,
246            // Populated during LIR lowering.
247            from_arrangement: None,
248        };
249        df_desc.export_sink(self.sink_id, sink_description);
250
251        // Prepare expressions in the assembled dataflow.
252        let style = ExprPrepMaintained;
253        df_desc.visit_children(
254            |r| style.prep_relation_expr(r),
255            |s| style.prep_scalar_expr(s),
256        )?;
257
258        // Construct TransformCtx for global optimization.
259        let mut transform_ctx = TransformCtx::global(
260            &df_builder,
261            &mz_transform::EmptyStatisticsOracle,
262            &self.config.features,
263            &self.typecheck_ctx,
264            &mut df_meta,
265            Some(&mut self.metrics),
266        );
267        // Run global optimization.
268        mz_transform::optimize_dataflow(&mut df_desc, &mut transform_ctx, false)?;
269
270        self.duration += time.elapsed();
271
272        Ok(GlobalMirPlan { df_desc, df_meta })
273    }
274}
275
276impl Optimize<GlobalMirPlan> for Optimizer {
277    type To = GlobalLirPlan;
278
279    fn optimize(&mut self, plan: GlobalMirPlan) -> Result<Self::To, OptimizerError> {
280        let time = Instant::now();
281
282        let GlobalMirPlan {
283            mut df_desc,
284            df_meta,
285        } = plan;
286
287        // Ensure all expressions are normalized before finalizing.
288        for build in df_desc.objects_to_build.iter_mut() {
289            normalize_lets(&mut build.plan.0, &self.config.features)?
290        }
291
292        // Finalize the dataflow: MIR ⇒ LIR lowering and LIR ⇒ LIR transforms.
293        let df_desc = LirRelationExpr::finalize_dataflow(
294            df_desc,
295            &self.config.features,
296            Some(self.metrics.lowering()),
297        )?;
298
299        // Trace the pipeline output under `optimize`.
300        trace_plan(&df_desc);
301
302        self.duration += time.elapsed();
303        self.metrics
304            .observe_e2e_optimization_time("metric_sink", self.duration);
305
306        Ok(GlobalLirPlan { df_desc, df_meta })
307    }
308}
309
310impl GlobalLirPlan {
311    /// Unwraps the parts of the final result of the optimization pipeline.
312    pub fn unapply(self) -> (LirDataflowDescription, DataflowMetainfo) {
313        (self.df_desc, self.df_meta)
314    }
315}
316
317/// Extends the metric sink's source expression with the row-wise shaping the operator otherwise
318/// has to do in Rust: prepends the configured `prefix` to `metric_name` to form the published name,
319/// coalesces `labels`/`help` to their identity element, and adds two columns the operator reads
320/// instead of parsing strings on its hot path:
321///
322/// * `metric_kind` (`Int32`, nullable): `0` for `gauge`, `1` for `counter`, `NULL` for any other
323///   `metric_type`.
324/// * `name_valid` (`Bool`, nullable): whether the published name (`prefix + metric_name`) matches
325///   the Prometheus metric-name grammar (see `METRIC_NAME_PATTERN`). The operator treats a `NULL`
326///   the same as `false`. Validating the published name, not the bare `metric_name`, is what lets a
327///   row name start with a digit: the prefix supplies the valid leading character.
328///
329/// No row is dropped or filtered here: the operator still needs every row, including the ones
330/// this marks invalid, to count `skipped`/`null_values`. Only the pure per-row shaping moves to
331/// MIR. Dedup, collision detection, and family-conflict counting stay in the operator, because
332/// they need cross-row state (the frontier-gated fold) that a `Map` can't express.
333///
334/// TODO: A full move would also express the dedup/collision/family-conflict logic in MIR (e.g.
335/// via `Reduce` + `FirstValue`), collapsing the operator to a plain fold over the live set. That
336/// full move is deferred: the tiebreak fidelity that logic needs is easier to keep correct
337/// hand-written and unit-tested for now.
338fn shape_metric_sink_source(
339    source: MirRelationExpr,
340    source_desc: &RelationDesc,
341    prefix: &str,
342) -> (MirRelationExpr, RelationDesc) {
343    // Precondition: `source_desc` describes `source` and exposes the canonical metric-sink columns
344    // (`metric_name`, `metric_type`, `labels`, `value`, `help`).
345    // `mz_sql::plan::validate_metric_sink_desc` enforces this for both `CREATE METRIC SINK` and
346    // the coordinator-installed curated sinks, so a missing column here is a caller bug.
347    let get_idx = |name: &str| {
348        source_desc
349            .get_by_name(&ColumnName::from(name))
350            .expect("metric-sink source relation must expose the canonical columns")
351    };
352    let (metric_name_idx, metric_name_ct) = get_idx("metric_name");
353    let (metric_type_idx, metric_type_ct) = get_idx("metric_type");
354    let (labels_idx, labels_ct) = get_idx("labels");
355    let (value_idx, value_ct) = get_idx("value");
356    let (help_idx, help_ct) = get_idx("help");
357
358    let arity = source_desc.typ().columns().len();
359    // The mapped columns are appended at `arity + N` and the `Project` indexes into `source` by
360    // position, so `source` must have exactly the arity `source_desc` describes. Guaranteed by the
361    // callers (a trivial finishing over the planned query, or a direct `Get` of the source), but a
362    // mismatch would silently read the wrong columns, so assert it here.
363    mz_ore::soft_assert_eq_or_log!(source.arity(), arity);
364    let labels_repr_type = ReprScalarType::from(&labels_ct.scalar_type);
365
366    let empty_map_row = {
367        let mut row = Row::default();
368        row.packer().push_dict_with(|_| {});
369        row
370    };
371    let labels_coalesced = MirScalarExpr::call_variadic(
372        Coalesce,
373        vec![
374            MirScalarExpr::column(labels_idx),
375            MirScalarExpr::literal_from_single_element_row(empty_map_row, labels_repr_type),
376        ],
377    );
378    let help_coalesced = MirScalarExpr::call_variadic(
379        Coalesce,
380        vec![
381            MirScalarExpr::column(help_idx),
382            MirScalarExpr::literal_ok(Datum::String(""), ReprScalarType::String),
383        ],
384    );
385
386    let metric_type_literal = |s: &'static str| {
387        MirScalarExpr::column(metric_type_idx).call_binary(
388            MirScalarExpr::literal_ok(Datum::String(s), ReprScalarType::String),
389            func::Eq,
390        )
391    };
392    let metric_kind = metric_type_literal("gauge").if_then_else(
393        MirScalarExpr::literal_ok(Datum::Int32(0), ReprScalarType::Int32),
394        metric_type_literal("counter").if_then_else(
395            MirScalarExpr::literal_ok(Datum::Int32(1), ReprScalarType::Int32),
396            MirScalarExpr::literal_null(ReprScalarType::Int32),
397        ),
398    );
399
400    // The published name is `prefix + metric_name`. The prefix is validated to start with the
401    // reserved marker (see `validate_metric_sink_prefix`, run at plan time for a user sink and at
402    // install time for a curated one), so every published family lands in the
403    // `mz_metric_sink_` lane nothing else in the process registry writes. `TextConcat` (the `||`
404    // operator) propagates nulls, so a null `metric_name` stays null and is skipped, never
405    // published as the bare prefix.
406    let prefixed_name = MirScalarExpr::literal_ok(Datum::String(prefix), ReprScalarType::String)
407        .call_binary(
408            MirScalarExpr::column(metric_name_idx),
409            func::TextConcatBinary,
410        );
411
412    // The regexp requires at least one leading character, so an empty published name fails it
413    // without a separate `!= ""` check. `is_null().not()` keeps a null name concretely `false`,
414    // not `NULL`. The shaping map appends `prefixed_name` at `arity + 2`, and a map scalar may
415    // reference earlier-appended columns, so this reads that column instead of rebuilding the
416    // concat.
417    let published_name = MirScalarExpr::column(arity + 2);
418    let name_valid = published_name
419        .clone()
420        .call_is_null()
421        .not()
422        .and(published_name.call_binary(
423            MirScalarExpr::literal_ok(Datum::String(METRIC_NAME_PATTERN), ReprScalarType::String),
424            func::IsRegexpMatchCaseSensitive,
425        ));
426
427    let shaped_expr = source
428        .map(vec![
429            labels_coalesced,
430            help_coalesced,
431            prefixed_name,
432            metric_kind,
433            name_valid,
434        ])
435        .project(vec![
436            arity + 2, // prefixed metric_name
437            metric_type_idx,
438            arity, // coalesced labels
439            value_idx,
440            arity + 1, // coalesced help
441            arity + 3, // metric_kind
442            arity + 4, // name_valid
443        ]);
444
445    let mut labels_shaped_ct = labels_ct.clone();
446    labels_shaped_ct.nullable = false;
447    let mut help_shaped_ct = help_ct.clone();
448    help_shaped_ct.nullable = false;
449    let shaped_desc = RelationDesc::from_names_and_types([
450        ("metric_name", metric_name_ct.clone()),
451        ("metric_type", metric_type_ct.clone()),
452        ("labels", labels_shaped_ct),
453        ("value", value_ct.clone()),
454        ("help", help_shaped_ct),
455        ("metric_kind", SqlScalarType::Int32.nullable(true)),
456        ("name_valid", SqlScalarType::Bool.nullable(true)),
457    ]);
458
459    (shaped_expr, shaped_desc)
460}
461
462#[cfg(test)]
463mod tests {
464    use std::collections::BTreeMap;
465
466    use mz_catalog::memory::objects::{CatalogEntry, CatalogItem, Table, TableDataSource};
467    use mz_controller_types::ClusterId;
468    use mz_expr::Eval;
469    use mz_ore::metrics::MetricsRegistry;
470    use mz_repr::adt::mz_acl_item::PrivilegeMap;
471    use mz_repr::role_id::RoleId;
472    use mz_repr::{
473        CatalogItemId, RelationVersion, RelationVersionSelector, RowArena, SqlColumnType,
474        VersionedRelationDesc,
475    };
476    use mz_sql::names::{
477        FullItemName, ItemQualifiers, QualifiedItemName, RawDatabaseSpecifier,
478        ResolvedDatabaseSpecifier, ResolvedIds, SchemaId, SchemaSpecifier,
479    };
480    use mz_sql::session::vars::SystemVars;
481
482    use super::*;
483
484    /// The canonical metric-sink source shape, with `labels`/`help` nullable so the shaping's
485    /// coalesce is observable and an extra trailing column so column resolution is exercised by
486    /// name, not position.
487    fn source_desc() -> RelationDesc {
488        RelationDesc::builder()
489            .with_column("metric_name", SqlScalarType::String.nullable(true))
490            .with_column("metric_type", SqlScalarType::String.nullable(false))
491            .with_column(
492                "labels",
493                SqlScalarType::Map {
494                    value_type: Box::new(SqlScalarType::String),
495                    custom_id: None,
496                }
497                .nullable(true),
498            )
499            .with_column("value", SqlScalarType::Float64.nullable(true))
500            .with_column("help", SqlScalarType::String.nullable(true))
501            .with_column("extra", SqlScalarType::String.nullable(true))
502            .finish()
503    }
504
505    /// A bare `Get` of `TABLE_GID`, the source expression the `MetricSinkFrom::Id` path shapes.
506    fn source_get(desc: &RelationDesc) -> MirRelationExpr {
507        MirRelationExpr::global_get(TABLE_GID, ReprRelationType::from(desc.typ()))
508    }
509
510    #[mz_ore::test]
511    fn shaped_desc_column_contract() {
512        let (_expr, desc) =
513            shape_metric_sink_source(source_get(&source_desc()), &source_desc(), "app_");
514
515        let cols: Vec<(String, SqlColumnType)> = desc
516            .iter()
517            .map(|(name, ty)| (name.as_str().to_string(), ty.clone()))
518            .collect();
519
520        // Exactly the seven canonical columns, in order. The trailing `extra` source column is
521        // projected away.
522        let names: Vec<&str> = cols.iter().map(|(n, _)| n.as_str()).collect();
523        assert_eq!(
524            names,
525            vec![
526                "metric_name",
527                "metric_type",
528                "labels",
529                "value",
530                "help",
531                "metric_kind",
532                "name_valid",
533            ]
534        );
535
536        let by_name = |name: &str| {
537            cols.iter()
538                .find(|(n, _)| n == name)
539                .expect("column present in shaped desc")
540                .1
541                .clone()
542        };
543
544        // `labels`/`help` are coalesced to their identity element, so they are non-null.
545        assert!(!by_name("labels").nullable);
546        assert!(!by_name("help").nullable);
547
548        // `metric_name`/`value` stay nullable (no identity element).
549        assert!(by_name("metric_name").nullable);
550        assert!(by_name("value").nullable);
551
552        // The two classification columns the operator reads.
553        assert_eq!(by_name("metric_kind"), SqlScalarType::Int32.nullable(true));
554        assert_eq!(by_name("name_valid"), SqlScalarType::Bool.nullable(true));
555    }
556
557    #[mz_ore::test]
558    fn shaped_expr_projects_seven_columns() {
559        let (expr, _desc) =
560            shape_metric_sink_source(source_get(&source_desc()), &source_desc(), "app_");
561
562        // The shaping is a `Map` of five new columns followed by a `Project` down to the seven
563        // canonical columns.
564        match &expr {
565            MirRelationExpr::Project { outputs, .. } => {
566                assert_eq!(outputs.len(), 7);
567            }
568            other => panic!("expected a Project at the root of the shaped expr, got {other:?}"),
569        }
570    }
571
572    /// The five scalars the shaping `Map` appends, in order:
573    /// `[labels_coalesced, help_coalesced, prefixed_name, metric_kind, name_valid]`.
574    fn shaped_map_scalars(desc: &RelationDesc, prefix: &str) -> Vec<MirScalarExpr> {
575        let (expr, _desc) = shape_metric_sink_source(source_get(desc), desc, prefix);
576        match expr {
577            MirRelationExpr::Project { input, .. } => match *input {
578                MirRelationExpr::Map { scalars, .. } => scalars,
579                other => panic!("expected a Map under the Project, got {other:?}"),
580            },
581            other => panic!("expected a Project at the root, got {other:?}"),
582        }
583    }
584
585    /// Evaluate the shaping `Map`'s appended scalars in order, extending the row with each result.
586    /// A later scalar may reference an earlier appended column (`name_valid` reads the published-name
587    /// column), so they must be evaluated cumulatively rather than in isolation.
588    fn eval_shaped_row<'a>(
589        scalars: &'a [MirScalarExpr],
590        input: &[Datum<'a>],
591        arena: &'a RowArena,
592    ) -> Vec<Datum<'a>> {
593        let mut row = input.to_vec();
594        for scalar in scalars {
595            let datum = scalar.eval(&row, arena).expect("scalar eval succeeds");
596            row.push(datum);
597        }
598        row
599    }
600
601    #[mz_ore::test]
602    fn metric_kind_classifies_type() {
603        let scalars = shaped_map_scalars(&source_desc(), "app_");
604        let metric_kind = &scalars[3];
605        let arena = RowArena::new();
606        // Row layout matches `source_desc`: [metric_name, metric_type, labels, value, help, extra].
607        for (metric_type, expected) in [
608            ("gauge", Datum::Int32(0)),
609            ("counter", Datum::Int32(1)),
610            ("histogram", Datum::Null),
611            ("summary", Datum::Null),
612        ] {
613            let row = [
614                Datum::Null,
615                Datum::String(metric_type),
616                Datum::Null,
617                Datum::Null,
618                Datum::Null,
619                Datum::Null,
620            ];
621            assert_eq!(
622                metric_kind
623                    .eval(&row, &arena)
624                    .expect("metric_kind eval succeeds"),
625                expected,
626                "metric_type = {metric_type}",
627            );
628        }
629    }
630
631    #[mz_ore::test]
632    fn name_valid_matches_prometheus_grammar() {
633        // Validation is on the published name `prefix + metric_name`, not the bare `metric_name`.
634        // The plan-time-validated prefix supplies the valid leading character, so a row name may
635        // start with a digit (or be empty, publishing the bare prefix). A dash is invalid anywhere,
636        // and a null name stays null.
637        let scalars = shaped_map_scalars(&source_desc(), "app_");
638        let arena = RowArena::new();
639        for (metric_name, expected) in [
640            (Datum::String("http_requests_total"), Datum::True),
641            (Datum::String("with:colons_and_1_digit"), Datum::True),
642            (Datum::String("1_leading_digit"), Datum::True),
643            (Datum::String("has-a-dash"), Datum::False),
644            (Datum::String(""), Datum::True),
645            (Datum::Null, Datum::False),
646        ] {
647            let input = [
648                metric_name,
649                Datum::Null,
650                Datum::Null,
651                Datum::Null,
652                Datum::Null,
653                Datum::Null,
654            ];
655            // `name_valid` is the last appended scalar and reads the published-name column, so
656            // evaluate the whole appended row and read its final column.
657            let row = eval_shaped_row(&scalars, &input, &arena);
658            assert_eq!(
659                *row.last().expect("row has appended columns"),
660                expected,
661                "metric_name = {metric_name:?}",
662            );
663        }
664    }
665
666    /// The smallest catalog the optimizer needs: one table, at `TABLE_GID`, exposing the canonical
667    /// metric-sink columns.
668    #[derive(Debug)]
669    struct SingleTableCatalog {
670        entry: CatalogEntry,
671    }
672
673    const TABLE_ITEM_ID: CatalogItemId = CatalogItemId::User(1);
674    const TABLE_GID: GlobalId = GlobalId::User(1);
675    const SINK_GID: GlobalId = GlobalId::User(2);
676
677    impl SingleTableCatalog {
678        fn new() -> Self {
679            let table = Table {
680                create_sql: None,
681                desc: VersionedRelationDesc::new(source_desc()),
682                collections: BTreeMap::from([(RelationVersion::root(), TABLE_GID)]),
683                conn_id: None,
684                resolved_ids: ResolvedIds::empty(),
685                custom_logical_compaction_window: None,
686                is_retained_metrics_object: false,
687                data_source: TableDataSource::TableWrites {
688                    defaults: Vec::new(),
689                },
690            };
691            let entry = CatalogEntry {
692                item: CatalogItem::Table(table),
693                referenced_by: Vec::new(),
694                used_by: Vec::new(),
695                id: TABLE_ITEM_ID,
696                oid: 20_000,
697                name: QualifiedItemName {
698                    qualifiers: ItemQualifiers {
699                        database_spec: ResolvedDatabaseSpecifier::Ambient,
700                        schema_spec: SchemaSpecifier::Id(SchemaId::User(1)),
701                    },
702                    item: "t".to_string(),
703                },
704                owner_id: RoleId::User(1),
705                privileges: PrivilegeMap::default(),
706            };
707            Self { entry }
708        }
709    }
710
711    impl OptimizerCatalog for SingleTableCatalog {
712        fn get_entry(&self, _id: &GlobalId) -> mz_catalog::memory::objects::CatalogCollectionEntry {
713            mz_catalog::memory::objects::CatalogCollectionEntry {
714                entry: self.entry.clone(),
715                version: RelationVersionSelector::Latest,
716            }
717        }
718
719        fn get_entry_by_item_id(&self, _id: &CatalogItemId) -> &CatalogEntry {
720            &self.entry
721        }
722
723        fn resolve_full_name(
724            &self,
725            name: &QualifiedItemName,
726            _conn_id: Option<&mz_adapter_types::connection::ConnectionId>,
727        ) -> FullItemName {
728            FullItemName {
729                database: RawDatabaseSpecifier::Ambient,
730                schema: "public".to_string(),
731                item: name.item.clone(),
732            }
733        }
734
735        fn get_indexes_on(
736            &self,
737            _id: GlobalId,
738            _cluster: ClusterId,
739        ) -> Box<dyn Iterator<Item = (GlobalId, &mz_catalog::memory::objects::Index)> + '_>
740        {
741            Box::new(std::iter::empty())
742        }
743    }
744
745    const VIEW_GID: GlobalId = GlobalId::Transient(1);
746
747    /// Runs the whole pipeline over `from` and returns the assembled dataflow.
748    fn optimize_from(from: MetricSinkFrom, metric_label: Option<String>) -> LirDataflowDescription {
749        let catalog = Arc::new(SingleTableCatalog::new());
750        let cluster_id = ClusterId::user(1).expect("valid cluster id");
751        let compute_instance = ComputeInstanceSnapshot::new_without_collections(cluster_id);
752        let config = OptimizerConfig::from(&SystemVars::default());
753        let metrics = OptimizerMetrics::register_into(&MetricsRegistry::new(), Duration::MAX);
754
755        let mut optimizer = Optimizer::new(
756            catalog,
757            compute_instance,
758            VIEW_GID,
759            SINK_GID,
760            config,
761            metrics,
762        );
763
764        let global_mir_plan = optimizer
765            .optimize(MetricSink::new(
766                "metric-sink-test".to_string(),
767                from,
768                "app_".to_string(),
769                metric_label,
770            ))
771            .expect("MIR optimization succeeds");
772        let global_lir_plan = optimizer
773            .optimize(global_mir_plan)
774            .expect("LIR optimization succeeds");
775        let (df_desc, _df_meta) = global_lir_plan.unapply();
776        df_desc
777    }
778
779    /// Asserts the dataflow exports exactly one `MetricSink` over the shaped view, whose desc
780    /// carries the operator's column contract.
781    fn assert_one_shaped_metric_sink_export(df_desc: &LirDataflowDescription) {
782        assert!(df_desc.index_exports.is_empty());
783        let sink_exports: Vec<_> = df_desc.sink_exports.iter().collect();
784        assert_eq!(sink_exports.len(), 1);
785        let (sink_id, sink_desc) = sink_exports[0];
786        assert_eq!(*sink_id, SINK_GID);
787        assert!(matches!(
788            sink_desc.connection,
789            ComputeSinkConnection::MetricSink(_)
790        ));
791        assert_eq!(sink_desc.from, VIEW_GID);
792        let shaped_names: Vec<&str> = sink_desc
793            .from_desc
794            .iter_names()
795            .map(|n| n.as_str())
796            .collect();
797        assert_eq!(
798            shaped_names,
799            vec![
800                "metric_name",
801                "metric_type",
802                "labels",
803                "value",
804                "help",
805                "metric_kind",
806                "name_valid",
807            ]
808        );
809    }
810
811    /// The `sink` label carried by the export's connection.
812    fn sink_label(df_desc: &LirDataflowDescription) -> &str {
813        match &df_desc
814            .sink_exports
815            .values()
816            .next()
817            .expect("one export")
818            .connection
819        {
820            ComputeSinkConnection::MetricSink(conn) => &conn.label,
821            other => panic!("expected a metric sink connection, got {other:?}"),
822        }
823    }
824
825    #[mz_ore::test]
826    fn optimizer_exports_one_metric_sink() {
827        let df_desc = optimize_from(MetricSinkFrom::Id(TABLE_GID), None);
828        assert_one_shaped_metric_sink_export(&df_desc);
829        // The source collection is imported, not rebuilt: the only build is the shaped view.
830        assert!(df_desc.source_imports.contains_key(&TABLE_GID));
831        let build_ids: Vec<_> = df_desc.objects_to_build.iter().map(|b| b.id).collect();
832        assert_eq!(build_ids, vec![VIEW_GID]);
833    }
834
835    /// The `Query` source path (what a coordinator-installed curated sink takes) assembles the
836    /// same shape, with the query lowered under the shaping instead of a `Get` of a catalog item.
837    #[mz_ore::test]
838    fn optimizer_shapes_a_query_source() {
839        let desc = source_desc();
840        // The simplest query over the canonical columns. Building richer HIR by hand buys nothing:
841        // what is under test is that a query source is lowered and shaped, not the lowering itself.
842        let expr = HirRelationExpr::Get {
843            id: mz_expr::Id::Global(TABLE_GID),
844            typ: desc.typ().clone(),
845        };
846
847        let df_desc = optimize_from(
848            MetricSinkFrom::Query {
849                expr,
850                desc: desc.clone(),
851            },
852            None,
853        );
854        assert_one_shaped_metric_sink_export(&df_desc);
855        // The query's leaf collection is imported by the shaped view's dependency walk.
856        assert!(df_desc.source_imports.contains_key(&TABLE_GID));
857        let build_ids: Vec<_> = df_desc.objects_to_build.iter().map(|b| b.id).collect();
858        assert_eq!(build_ids, vec![VIEW_GID]);
859    }
860
861    /// With no explicit label a sink is tagged by its `GlobalId`, what a user's `CREATE METRIC
862    /// SINK` relies on. An explicit label (a curated sink's stable name) is used verbatim.
863    #[mz_ore::test]
864    fn metric_sink_label_defaults_to_sink_id_else_override() {
865        let df_desc = optimize_from(MetricSinkFrom::Id(TABLE_GID), None);
866        assert_eq!(sink_label(&df_desc), SINK_GID.to_string());
867
868        let df_desc = optimize_from(
869            MetricSinkFrom::Id(TABLE_GID),
870            Some("mz_curated".to_string()),
871        );
872        assert_eq!(sink_label(&df_desc), "mz_curated");
873    }
874}