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