Skip to main content

mz_adapter/coord/
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//! Coordinator-installed metric sinks, the curated counterpart to `CREATE METRIC SINK`.
11//!
12//! A curated metric sink is a [`CURATED`] entry rendered on every replica, publishing its series
13//! into that replica's process-local Prometheus registry. Unlike a user's `CREATE METRIC SINK` it
14//! is not a catalog item: it gets a transient [`GlobalId`], targets one replica rather than a
15//! cluster, and is re-created from the static list on every boot. Modelling the curated set this
16//! way keeps it out of the catalog, so adding or removing a definition needs no builtin migration.
17//!
18//! Every replica means every replica of every cluster, user clusters included. Each definition is
19//! therefore a dataflow, with its arrangements, on customer compute, charged to that customer's
20//! cluster, and the cost scales with `CURATED`. `coord::introspection` already accepts this for its
21//! subscribes.
22//!
23//! `install_metric_sinks` installs every definition on a newly created replica
24//! (`bootstrap_metric_sinks` covers the replicas already present at startup), and
25//! `drop_metric_sinks` drops them before a replica is dropped. This mirrors
26//! [`crate::coord::introspection`], which installs introspection subscribes on the same triggers.
27
28use std::collections::{BTreeMap, BTreeSet};
29
30use anyhow::bail;
31use mz_catalog::memory::objects::CatalogItem;
32use mz_cluster_client::ReplicaId;
33use mz_controller_types::ClusterId;
34use mz_ore::collections::CollectionExt;
35use mz_ore::{instrument, soft_panic_or_log};
36use mz_repr::optimize::OverrideFrom;
37use mz_repr::{CatalogItemId, GlobalId, RelationDesc};
38use mz_sql::catalog::SessionCatalog;
39use mz_sql::plan::{
40    HirRelationExpr, METRIC_SINK_CURATED_PREFIX_MARKER, Params, Plan, SubscribeFrom, SubscribePlan,
41    validate_metric_sink_desc, validate_metric_sink_prefix,
42};
43use mz_sql::session::user::{MZ_SYSTEM_ROLE_ID, RoleMetadata};
44use mz_sql::session::vars::ENABLE_METRIC_SINK;
45use tracing::{Span, info};
46
47use crate::catalog::Catalog;
48use crate::coord::{
49    Coordinator, Message, MetricSinkFinish, MetricSinkOptimize, MetricSinkStage, PlanValidity,
50    StageResult, Staged,
51};
52use crate::optimize::Optimize;
53use crate::optimize::dataflows::dataflow_import_id_bundle;
54use crate::{AdapterError, ExecuteResponse, optimize};
55
56/// A curated metric sink: SQL producing the canonical metric-sink columns, plus the name it is
57/// known by in logs.
58#[derive(Debug)]
59pub(super) struct CuratedMetricSink {
60    /// Stable identifier for the definition: used in logs, as the [`Coordinator::metric_sinks`] key,
61    /// and as the `sink` label on the health gauges (the `GlobalId` is transient, the name is not).
62    /// Must be unique within [`CURATED`].
63    name: &'static str,
64    /// A `SELECT` producing the canonical metric-sink columns (`metric_name`, `metric_type`,
65    /// `labels`, `value`, `help`), the contract `mz_sql::plan::validate_metric_sink_desc` checks.
66    ///
67    /// The query must read only introspection relations. A catalog-backed relation would put
68    /// envd's write frontier on the sink's emission path, which is exactly the coupling these
69    /// sinks exist to avoid: the sink would stall whenever envd did, taking the freshness signal
70    /// with it.
71    source_sql: &'static str,
72    /// Prepended to every row's `metric_name` to form the published name, exactly as a user's
73    /// `CREATE METRIC SINK ... WITH (PREFIX = ...)`. Every definition in [`CURATED`] uses
74    /// [`METRIC_SINK_CURATED_PREFIX_MARKER`], which user sinks are barred from, so nothing a user
75    /// publishes can collide with a curated family.
76    prefix: &'static str,
77}
78
79/// The curated metric sinks, installed on every replica.
80///
81/// Sources take their measurements from the raw `..._raw` logging relations, never from a derived
82/// view that re-aggregates them, like `mz_dataflow_arrangement_sizes`: those churn even on static
83/// data, and a per-dataflow size metric off the derived view measured ~30x the raw form. Cheap
84/// mapping views over the same logs, `mz_dataflow_operator_dataflows` and `mz_compute_exports`,
85/// carry no aggregation and are read freely.
86///
87/// Every family sums across workers, so a series carries no `worker_id`, and a multi-process replica
88/// reports one number per grouping key rather than one per process. The size families emit one series
89/// per dataflow, the errors family one per export.
90const CURATED: &[CuratedMetricSink] = &[
91    CuratedMetricSink {
92        name: "mz_metric_arrangement_sizes",
93        prefix: METRIC_SINK_CURATED_PREFIX_MARKER,
94        // Key on the export id from `mz_compute_exports`, not `mz_dataflow_global_ids`: a
95        // materialized view builds under a transient view id and appears only as an export, so a
96        // global-id label names a `t<N>` that maps to no catalog object and churns on every
97        // re-render.
98        //
99        // Logs are per operator, so map operator -> dataflow -> export id. `min(export_id)`
100        // collapses a multi-export dataflow to one series (lexicographic, so `min('u10', 'u2')` is
101        // `'u10'`: arbitrary but stable). The group-size hint stops that `min` from rendering the
102        // 8-level hierarchy, which would otherwise show up as tuning advice for the sink's own
103        // dataflow in `mz_expected_group_size_advice`.
104        //
105        // Transient exports (subscribes, peeks, metric sinks) and operators with no worker-0
106        // mapping fall through to an `unattributable` sentinel, so their bytes still count without a
107        // churning `t<N>` label growing series without bound.
108        source_sql: "
109WITH ex AS (
110    SELECT dataflow_id, min(export_id) AS export_id
111    FROM mz_introspection.mz_compute_exports
112    WHERE export_id NOT LIKE 't%'
113    GROUP BY dataflow_id OPTIONS (AGGREGATE INPUT GROUP SIZE = 1)
114)
115SELECT 'arrangement_size_bytes'::text AS metric_name, 'gauge'::text AS metric_type,
116       map_build(LIST[ROW('id', COALESCE(ex.export_id, 'unattributable'))])::map[text=>text] AS labels,
117       count(*)::double precision AS value, 'arrangement heap size in bytes'::text AS help
118FROM mz_introspection.mz_arrangement_heap_size_raw r
119LEFT JOIN mz_introspection.mz_dataflow_operator_dataflows dod ON r.operator_id = dod.id
120LEFT JOIN ex ON ex.dataflow_id = dod.dataflow_id
121GROUP BY COALESCE(ex.export_id, 'unattributable')
122UNION ALL
123SELECT 'arrangement_records'::text AS metric_name, 'gauge'::text AS metric_type,
124       map_build(LIST[ROW('id', COALESCE(ex.export_id, 'unattributable'))])::map[text=>text] AS labels,
125       count(*)::double precision AS value, 'number of records in arrangement heaps'::text AS help
126FROM mz_introspection.mz_arrangement_records_raw r
127LEFT JOIN mz_introspection.mz_dataflow_operator_dataflows dod ON r.operator_id = dod.id
128LEFT JOIN ex ON ex.dataflow_id = dod.dataflow_id
129GROUP BY COALESCE(ex.export_id, 'unattributable')
130UNION ALL
131SELECT 'arrangement_batches'::text AS metric_name, 'gauge'::text AS metric_type,
132       map_build(LIST[ROW('id', COALESCE(ex.export_id, 'unattributable'))])::map[text=>text] AS labels,
133       count(*)::double precision AS value, 'number of batches in arrangements'::text AS help
134FROM mz_introspection.mz_arrangement_batches_raw r
135LEFT JOIN mz_introspection.mz_dataflow_operator_dataflows dod ON r.operator_id = dod.id
136LEFT JOIN ex ON ex.dataflow_id = dod.dataflow_id
137GROUP BY COALESCE(ex.export_id, 'unattributable')",
138    },
139    CuratedMetricSink {
140        name: "mz_metric_dataflow_errors",
141        prefix: METRIC_SINK_CURATED_PREFIX_MARKER,
142        // Raw log, not the `mz_compute_error_counts` view: the view joins the storage-managed
143        // `mz_internal.mz_compute_dependencies`, which `ensure_reads_only_logs` rejects. `count` is
144        // per-worker, so sum per export. `HAVING` drops the healthy ones.
145        //
146        // NOTE: direct errors only. The raw log attributes an error to the export that raised it. The
147        // view also forwards counts onto index-reuse exports, so a broken reuse-index reads 0 here and
148        // its errors show under the underlying export, undercounting against the view.
149        source_sql: "
150SELECT 'dataflow_error_count'::text AS metric_name, 'gauge'::text AS metric_type,
151       map_build(LIST[ROW('id', export_id::text)])::map[text=>text] AS labels,
152       sum(count)::double precision AS value, 'count of errors in the dataflow'::text AS help
153FROM mz_introspection.mz_compute_error_counts_raw
154GROUP BY export_id
155HAVING sum(count) > 0",
156    },
157];
158
159/// A [`CuratedMetricSink`] installed on one replica.
160#[derive(Debug)]
161pub(super) struct InstalledMetricSink {
162    /// The cluster the replica belongs to, needed to drop the sink's compute collection.
163    cluster_id: ClusterId,
164    /// The transient id of the sink's compute export.
165    sink_id: GlobalId,
166}
167
168/// A [`CuratedMetricSink`] planned once and shared across the replicas it installs on. See
169/// [`Coordinator::plan_metric_sink`].
170#[derive(Clone, Debug)]
171pub(super) struct PlannedMetricSink {
172    /// The shaped source query.
173    expr: HirRelationExpr,
174    /// The shape `expr` produces.
175    desc: RelationDesc,
176    /// The catalog items the source reads.
177    dependencies: BTreeSet<CatalogItemId>,
178}
179
180impl Coordinator {
181    /// Installs the curated metric sinks on all existing replicas.
182    pub(super) async fn bootstrap_metric_sinks(&mut self) {
183        for (cluster_id, replica_id) in self.all_cluster_replicas() {
184            self.install_metric_sinks(cluster_id, replica_id).await;
185        }
186    }
187
188    /// Installs the curated metric sinks on the given replica.
189    ///
190    /// Turning `enable_metric_sink` off stops installing on replicas created from then on. It does
191    /// not tear down what is already installed: those keep running until their replica is dropped
192    /// or envd restarts. A replica that merely reconnects re-renders them from the controller's
193    /// command history, so a replica restart does not clear them either.
194    pub(super) async fn install_metric_sinks(
195        &mut self,
196        cluster_id: ClusterId,
197        replica_id: ReplicaId,
198    ) {
199        if !ENABLE_METRIC_SINK.enabled(self.catalog().system_config()) {
200            return;
201        }
202
203        // TODO: Skip replicas created with introspection disabled. Their logging dataflows never
204        // run, so a `source_sql` reading introspection relations there never advances. That is not
205        // just wasted work: the sink publishes its input frontier as its write frontier, so a
206        // never-advancing input stalls the sink's frontier at its as-of and pins the read holds it
207        // takes on those collections for the replica's whole life (replica-local, released on
208        // drop). `coord::introspection` installs subscribes on the same triggers and has the same
209        // gap.
210        for definition in CURATED {
211            self.install_metric_sink(cluster_id, replica_id, definition)
212                .await;
213        }
214    }
215
216    async fn install_metric_sink(
217        &mut self,
218        cluster_id: ClusterId,
219        replica_id: ReplicaId,
220        definition: &'static CuratedMetricSink,
221    ) {
222        // Cheap duplicate check before planning: if the definition is already installed on this
223        // replica, there is nothing to do. `metric_sink_finish` keeps a backstop for a double
224        // install still in flight (not yet recorded here).
225        if self
226            .metric_sinks
227            .contains_key(&(replica_id, definition.name))
228        {
229            return;
230        }
231
232        let Some(planned) = self.plan_metric_sink(definition) else {
233            return;
234        };
235
236        let (_, sink_id) = self.allocate_transient_id();
237        // Logged only once the definition is known good, so an abandoned install leaves no
238        // misleading "installing" line.
239        info!(%sink_id, %replica_id, name = definition.name, "installing metric sink");
240
241        let validity = PlanValidity::new(
242            &self.catalog,
243            planned.dependencies.clone(),
244            Some(cluster_id),
245            Some(replica_id),
246            RoleMetadata::new(MZ_SYSTEM_ROLE_ID),
247        );
248        let stage = MetricSinkStage::Optimize(MetricSinkOptimize {
249            validity,
250            definition,
251            sink_id,
252            expr: planned.expr.clone(),
253            desc: planned.desc.clone(),
254            cluster_id,
255            replica_id,
256        });
257        self.sequence_staged((), Span::current(), stage).await;
258    }
259
260    /// Plans a curated definition once, caching the result in [`Coordinator::metric_sink_plans`].
261    ///
262    /// The plan depends only on the catalog, never on the replica, so it is shared across every
263    /// replica the definition installs on rather than re-planned per replica. Curated sources read
264    /// only builtins (enforced by [`ensure_reads_only_logs`]), which do not change while envd runs,
265    /// so a cached plan stays valid for envd's lifetime. Returns `None` for an invalid definition,
266    /// having soft-panicked.
267    fn plan_metric_sink(
268        &mut self,
269        definition: &'static CuratedMetricSink,
270    ) -> Option<PlannedMetricSink> {
271        if let Some(planned) = self.metric_sink_plans.get(definition.name) {
272            return Some(planned.clone());
273        }
274
275        // A user sink's prefix is validated at plan time; a curated one has no such gate, so enforce
276        // the same contract here. A failure is a bug in our own definition, hence
277        // `soft_panic_or_log!`. User-vs-curated collisions need no check: the curated prefix is
278        // reserved against user sinks in `validate_user_metric_sink_prefix`.
279        //
280        // NOTE: curated definitions are not checked against each other; they stay disjoint by
281        // publishing distinct `metric_name`s under the shared curated prefix.
282        if let Err(err) = validate_metric_sink_prefix(definition.prefix) {
283            soft_panic_or_log!(
284                "invalid curated metric sink prefix (name={}): {err}",
285                definition.name
286            );
287            return None;
288        }
289
290        let catalog = self.catalog().for_system_session();
291        let (expr, desc, dependencies) = match definition.plan_source(&catalog) {
292            Ok(planned) => planned,
293            Err(err) => {
294                soft_panic_or_log!(
295                    "invalid curated metric sink (name={}): {err}",
296                    definition.name
297                );
298                return None;
299            }
300        };
301
302        // Enforce the introspection-only contract before any optimization work, against what the
303        // definition reads rather than how the optimizer imports it.
304        if let Err(err) = ensure_reads_only_logs(&self.catalog, &dependencies) {
305            soft_panic_or_log!(
306                "invalid curated metric sink (name={}): {err}",
307                definition.name
308            );
309            return None;
310        }
311
312        let planned = PlannedMetricSink {
313            expr,
314            desc,
315            dependencies,
316        };
317        self.metric_sink_plans
318            .insert(definition.name, planned.clone());
319        Some(planned)
320    }
321
322    #[instrument]
323    fn metric_sink_optimize(
324        &self,
325        stage: MetricSinkOptimize,
326    ) -> Result<StageResult<Box<MetricSinkStage>>, AdapterError> {
327        let MetricSinkOptimize {
328            mut validity,
329            definition,
330            sink_id,
331            expr,
332            desc,
333            cluster_id,
334            replica_id,
335        } = stage;
336
337        let compute_instance = self
338            .instance_snapshot(cluster_id)
339            .expect("compute instance exists");
340        // A transient id for the view the optimizer builds to shape the source rows, scoped to this
341        // dataflow. See `optimize::metric_sink::shape_metric_sink_source`.
342        let (_, view_id) = self.allocate_transient_id();
343
344        let optimizer_config = optimize::OptimizerConfig::from(self.catalog().system_config())
345            .override_from(&self.catalog.get_cluster(cluster_id).config.features())
346            .override_from(&self.cluster_scoped_optimizer_overrides(cluster_id));
347
348        let mut optimizer = optimize::metric_sink::Optimizer::new(
349            self.owned_catalog(),
350            compute_instance,
351            view_id,
352            sink_id,
353            optimizer_config,
354            self.optimizer_metrics(),
355        );
356        let catalog = self.owned_catalog();
357
358        let span = Span::current();
359        Ok(StageResult::Handle(mz_ore::task::spawn_blocking(
360            || "optimize metric sink",
361            move || {
362                span.in_scope(|| {
363                    let metric_sink = optimize::metric_sink::MetricSink::new(
364                        format!("metric-sink-{}-{replica_id}", definition.name),
365                        optimize::metric_sink::MetricSinkFrom::Query { expr, desc },
366                        definition.prefix.to_string(),
367                        Some(definition.name.to_string()),
368                    );
369
370                    // Both steps run inside one closure so either failure hits the same log.
371                    // `sequence_staged` has no session to report to for a coordinator-driven
372                    // install, so an error would otherwise vanish.
373                    let global_lir_plan = (|| {
374                        // MIR ⇒ MIR optimization (global)
375                        let global_mir_plan = optimizer.catch_unwind_optimize(metric_sink)?;
376                        // The optimizer imports indexes the SQL never named. Fold them into
377                        // validity so one dropped before the finish stage fails the recheck rather
378                        // than shipping a dataflow that imports a gone collection.
379                        let id_bundle =
380                            dataflow_import_id_bundle(global_mir_plan.df_desc(), cluster_id);
381                        let item_ids = id_bundle.iter().map(|id| catalog.resolve_item_id(&id));
382                        validity.extend_dependencies(&catalog, item_ids);
383                        // MIR ⇒ LIR lowering and LIR ⇒ LIR optimization (global)
384                        optimizer.catch_unwind_optimize(global_mir_plan)
385                    })()
386                    .inspect_err(|err| {
387                        soft_panic_or_log!(
388                            "curated metric sink failed to optimize (name={}): {err}",
389                            definition.name
390                        )
391                    })?;
392
393                    let stage = MetricSinkStage::Finish(MetricSinkFinish {
394                        validity,
395                        definition,
396                        sink_id,
397                        global_lir_plan,
398                        cluster_id,
399                        replica_id,
400                    });
401                    Ok(Box::new(stage))
402                })
403            },
404        )))
405    }
406
407    #[instrument]
408    async fn metric_sink_finish(
409        &mut self,
410        stage: MetricSinkFinish,
411    ) -> Result<StageResult<Box<MetricSinkStage>>, AdapterError> {
412        let MetricSinkFinish {
413            validity: _,
414            definition,
415            sink_id,
416            global_lir_plan,
417            cluster_id,
418            replica_id,
419        } = stage;
420
421        // `sequence_staged` rechecked validity before this stage ran, so the replica still exists.
422        // The coordinator handles one message at a time, so no replica drop runs between that check
423        // and the ship below.
424
425        // The metainfo is dropped rather than persisted: a curated sink is not a catalog item, so
426        // there is nothing for `mz_optimizer_notices` to hang its notices off.
427        let (mut df_desc, _df_meta) = global_lir_plan.unapply();
428
429        let id_bundle = dataflow_import_id_bundle(&df_desc, cluster_id);
430
431        // Backstop for the introspection-only contract; the real gate is `ensure_reads_only_logs`
432        // at install time. A log-only source imports only compute collections, so this should never
433        // fire, but a storage import would couple the sink's frontier to envd.
434        if !id_bundle.storage_ids.is_empty() {
435            soft_panic_or_log!(
436                "curated metric sink reads non-introspection relations (name={}): {:?}",
437                definition.name,
438                id_bundle.storage_ids
439            );
440            return Ok(StageResult::Response(ExecuteResponse::CreatedMetricSink));
441        }
442
443        // Hold a read on the imports across shipping, so their since cannot advance past the as-of
444        // just picked. Compute takes its own holds during `create_dataflow`.
445        let read_holds = self.acquire_read_holds(&id_bundle);
446        df_desc.set_as_of(read_holds.least_valid_read());
447
448        // Record the install just before shipping. A failed plan or optimize returns earlier, so it
449        // leaves no entry behind. `drop_metric_sinks` reads this entry to release the sink's
450        // instance-global collection state on replica drop. Recording before the ship is safe because
451        // the coordinator runs one message at a time with no await between the two, so no replica drop
452        // sees an entry whose dataflow has not shipped.
453        let install = InstalledMetricSink {
454            cluster_id,
455            sink_id,
456        };
457        if let Some(previous) = self
458            .metric_sinks
459            .insert((replica_id, definition.name), install)
460        {
461            // The key is already taken. `curated_names_are_unique` rules out two definitions
462            // colliding, so the reachable cause is `install_metric_sinks` running twice for one
463            // replica. Restore the first install and abandon this one: shipping both would leak the
464            // first's collection (now unreachable to `drop_metric_sinks`) and register a second
465            // collector under the same `sink` label.
466            self.metric_sinks
467                .insert((replica_id, definition.name), previous);
468            soft_panic_or_log!(
469                "metric sink installed twice (name={}, replica_id={replica_id})",
470                definition.name
471            );
472            return Ok(StageResult::Response(ExecuteResponse::CreatedMetricSink));
473        }
474
475        self.ship_dataflow(df_desc, cluster_id, Some(replica_id))
476            .await;
477
478        drop(read_holds);
479        // Nobody is waiting on this: `StagedContext for ()` drops the result. Reuses the
480        // `CREATE METRIC SINK` response rather than adding a variant no client ever sees.
481        Ok(StageResult::Response(ExecuteResponse::CreatedMetricSink))
482    }
483
484    /// Drops the curated metric sinks installed on the given replica.
485    ///
486    /// Called before the replica itself is dropped. Dropping the replica would tear the sink
487    /// dataflows down anyway, but the controller's collection state for them is instance-global,
488    /// so it has to be released explicitly.
489    pub(super) fn drop_metric_sinks(&mut self, replica_id: ReplicaId) {
490        for (name, cluster_id, sink_id) in metric_sinks_on_replica(&self.metric_sinks, replica_id) {
491            info!(%sink_id, %replica_id, name, "dropping metric sink");
492            self.metric_sinks.remove(&(replica_id, name));
493
494            // The entry exists only for a shipped dataflow, so its collection is present and this
495            // drop succeeds. Result ignored: a failure during replica teardown is not worth a panic.
496            let _ = self
497                .controller
498                .compute
499                .drop_collections(cluster_id, vec![sink_id]);
500        }
501    }
502}
503
504/// The registry entries installed on `replica_id`, as `(name, cluster, sink)` in key order.
505///
506/// The map is keyed replica-first, so a replica's installs are one contiguous range.
507fn metric_sinks_on_replica(
508    metric_sinks: &BTreeMap<(ReplicaId, &'static str), InstalledMetricSink>,
509    replica_id: ReplicaId,
510) -> Vec<(&'static str, ClusterId, GlobalId)> {
511    metric_sinks
512        .range((replica_id, "")..)
513        .take_while(|((id, _), _)| *id == replica_id)
514        .map(|((_, name), install)| (*name, install.cluster_id, install.sink_id))
515        .collect()
516}
517
518/// Enforces the introspection-only contract from [`CuratedMetricSink::source_sql`]: every relation
519/// the definition reads, walking views transitively, must be a log collection.
520///
521/// Checked here against what the definition reads rather than by import kind after optimization: the
522/// import split (storage vs index) depends on which indexes the target cluster happens to have, so
523/// it gives the same definition different verdicts on different clusters.
524fn ensure_reads_only_logs(
525    catalog: &Catalog,
526    dependencies: &BTreeSet<CatalogItemId>,
527) -> Result<(), anyhow::Error> {
528    let mut to_visit: Vec<_> = dependencies.iter().copied().collect();
529    let mut visited = BTreeSet::new();
530    while let Some(id) = to_visit.pop() {
531        if !visited.insert(id) {
532            continue;
533        }
534        let entry = catalog.get_entry(&id);
535        match entry.item() {
536            // The only data leaf allowed.
537            CatalogItem::Log(_) => {}
538            // Allowed only if everything it reads is, so walk its dependencies.
539            CatalogItem::View(_) => to_visit.extend(entry.uses()),
540            // No data dependency; a view over logs still references these.
541            CatalogItem::Type(_) | CatalogItem::Func(_) => {}
542            _ => bail!(
543                "curated metric sink reads {}, which is not an introspection log relation \
544                 (only logs and views over logs are allowed)",
545                catalog.resolve_full_name(entry.name(), None)
546            ),
547        }
548    }
549    Ok(())
550}
551
552impl CuratedMetricSink {
553    /// Plans `source_sql` against a session-less catalog, returning the query, its output shape,
554    /// and the catalog items it reads.
555    fn plan_source(
556        &self,
557        catalog: &dyn SessionCatalog,
558    ) -> Result<(HirRelationExpr, RelationDesc, BTreeSet<CatalogItemId>), anyhow::Error> {
559        // A definition is a single statement. Reject the count explicitly for a clear error.
560        let statements = mz_sql::parse::parse(self.source_sql)?;
561        if statements.len() != 1 {
562            bail!(
563                "source SQL must be exactly one statement, got {}",
564                statements.len()
565            );
566        }
567
568        // A metric sink's source is a continuously maintained dataflow, like a SUBSCRIBE, so plan it
569        // as one. A maintained lifetime folds any finishing into the expression (an ORDER BY over a
570        // maintained collection is dropped, a LIMIT becomes a TopK) rather than leaving it beside the
571        // query, so `MetricSinkFrom::Query` gets a self-contained expression whose arity matches its
572        // `desc`. This mirrors `coord::introspection`, which plans its specs as subscribes too.
573        let subscribe_sql = format!("SUBSCRIBE ({})", self.source_sql);
574        let parsed = mz_sql::parse::parse(&subscribe_sql)?.into_element();
575        let (stmt, resolved_ids) = mz_sql::names::resolve(catalog, parsed.ast)?;
576        let (plan, sql_impl_ids) =
577            mz_sql::plan::plan(None, catalog, stmt, &Params::empty(), &resolved_ids)?;
578        let Plan::Subscribe(SubscribePlan {
579            from: SubscribeFrom::Query { expr, desc },
580            ..
581        }) = plan
582        else {
583            bail!("source SQL must be a single SELECT");
584        };
585        validate_metric_sink_desc(&desc)?;
586
587        // Fold in ids from SQL-implemented function bodies. `plan` keeps them out of `resolved_ids`
588        // since a one-shot statement doesn't depend on a function's body, but a metric sink inlines
589        // that body into its dataflow, so the body's reads are real imports the gate must check.
590        let dependencies = resolved_ids
591            .items()
592            .chain(sql_impl_ids.items())
593            .copied()
594            .collect();
595        Ok((expr, desc, dependencies))
596    }
597}
598
599impl Staged for MetricSinkStage {
600    type Ctx = ();
601
602    fn validity(&mut self) -> &mut PlanValidity {
603        match self {
604            Self::Optimize(stage) => &mut stage.validity,
605            Self::Finish(stage) => &mut stage.validity,
606        }
607    }
608
609    async fn stage(
610        self,
611        coord: &mut Coordinator,
612        _ctx: &mut (),
613    ) -> Result<StageResult<Box<Self>>, AdapterError> {
614        match self {
615            Self::Optimize(stage) => coord.metric_sink_optimize(stage),
616            Self::Finish(stage) => coord.metric_sink_finish(stage).await,
617        }
618    }
619
620    fn message(self, _ctx: (), span: Span) -> Message {
621        Message::MetricSinkStageReady { span, stage: self }
622    }
623
624    fn cancel_enabled(&self) -> bool {
625        false
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use std::collections::{BTreeMap, BTreeSet};
632
633    use mz_catalog::memory::objects::CatalogItem;
634    use mz_cluster_client::ReplicaId;
635    use mz_controller_types::ClusterId;
636    use mz_repr::GlobalId;
637    use mz_sql::plan::{
638        METRIC_SINK_CURATED_PREFIX_MARKER, validate_metric_sink_prefix,
639        validate_user_metric_sink_prefix,
640    };
641
642    use crate::catalog::Catalog;
643    use crate::coord::metric_sink::{
644        CURATED, CuratedMetricSink, InstalledMetricSink, ensure_reads_only_logs,
645        metric_sinks_on_replica,
646    };
647
648    /// `drop_metric_sinks` relies on this range scan returning exactly one replica's installs, with
649    /// no bleed into a neighbouring replica's contiguous range.
650    #[mz_ore::test]
651    fn metric_sinks_on_replica_scans_one_replica() {
652        let cluster = ClusterId::user(1).expect("valid cluster id");
653        let install = |sink_id| InstalledMetricSink {
654            cluster_id: cluster,
655            sink_id: GlobalId::Transient(sink_id),
656        };
657        let r = ReplicaId::User;
658
659        let mut sinks = BTreeMap::new();
660        sinks.insert((r(1), "a"), install(10));
661        sinks.insert((r(2), "a"), install(20));
662        sinks.insert((r(2), "b"), install(21));
663        sinks.insert((r(2), "c"), install(22));
664        sinks.insert((r(4), "a"), install(40));
665
666        // A replica with several installs: all of them, in key order, and nothing from r(1)/r(4).
667        assert_eq!(
668            metric_sinks_on_replica(&sinks, r(2)),
669            vec![
670                ("a", cluster, GlobalId::Transient(20)),
671                ("b", cluster, GlobalId::Transient(21)),
672                ("c", cluster, GlobalId::Transient(22)),
673            ]
674        );
675        // First and last replicas in the map: the scan stops at each boundary.
676        assert_eq!(
677            metric_sinks_on_replica(&sinks, r(1)),
678            vec![("a", cluster, GlobalId::Transient(10))]
679        );
680        assert_eq!(
681            metric_sinks_on_replica(&sinks, r(4)),
682            vec![("a", cluster, GlobalId::Transient(40))]
683        );
684        // A replica with no installs, whether ordered between present ones (the r(3) gap) or past
685        // the end, returns nothing rather than the next replica's range.
686        assert!(metric_sinks_on_replica(&sinks, r(3)).is_empty());
687        assert!(metric_sinks_on_replica(&sinks, r(5)).is_empty());
688    }
689
690    #[mz_ore::test]
691    fn curated_prefixes_are_valid() {
692        for definition in CURATED {
693            validate_metric_sink_prefix(definition.prefix).unwrap_or_else(|err| {
694                panic!(
695                    "curated metric sink {:?} has an invalid prefix {:?}: {err}",
696                    definition.name, definition.prefix
697                )
698            });
699        }
700    }
701
702    #[mz_ore::test]
703    fn curated_prefixes_are_reserved_against_user_sinks() {
704        for definition in CURATED {
705            assert!(
706                definition
707                    .prefix
708                    .starts_with(METRIC_SINK_CURATED_PREFIX_MARKER),
709                "curated metric sink {:?} does not use the reserved curated prefix: {:?}",
710                definition.name,
711                definition.prefix
712            );
713            assert!(
714                validate_user_metric_sink_prefix(definition.prefix).is_err(),
715                "a user could claim curated metric sink {:?}'s prefix {:?}",
716                definition.name,
717                definition.prefix
718            );
719        }
720    }
721
722    /// The registry is keyed on the name, so a duplicate would make one definition's install
723    /// unreachable to teardown and both collectors collide on the `sink` label. Guarded at runtime
724    /// (`metric_sink_finish`) too, but caught here at build time before it can ship.
725    #[mz_ore::test]
726    fn curated_names_are_unique() {
727        let mut seen = BTreeSet::new();
728        for definition in CURATED {
729            assert!(
730                seen.insert(definition.name),
731                "duplicate curated metric sink name {:?}",
732                definition.name
733            );
734        }
735    }
736
737    /// Runs the two checks `plan_metric_sink` does at boot, where a failure soft-panics (a hard panic
738    /// under debug assertions, so a CI boot crash-loop). The gate is the load-bearing one: a source
739    /// can plan cleanly yet still read a storage-backed relation transitively (a builtin view joining
740    /// in an introspection source), which only `ensure_reads_only_logs` catches.
741    #[mz_ore::test(tokio::test)]
742    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
743    async fn curated_definitions_plan() {
744        Catalog::with_debug(|catalog| async move {
745            let session_catalog = catalog.for_system_session();
746            for definition in CURATED {
747                let (_, _, dependencies) =
748                    definition
749                        .plan_source(&session_catalog)
750                        .unwrap_or_else(|err| {
751                            panic!(
752                                "curated metric sink {:?} does not plan: {err}",
753                                definition.name
754                            )
755                        });
756                ensure_reads_only_logs(&catalog, &dependencies).unwrap_or_else(|err| {
757                    panic!(
758                        "curated metric sink {:?} reads a non-introspection relation: {err}",
759                        definition.name
760                    )
761                });
762            }
763        })
764        .await
765    }
766
767    /// The five canonical columns, no finishing: the shape a definition must produce.
768    const VALID_SOURCE: &str = "SELECT 'n'::text AS metric_name, 'gauge'::text AS metric_type, \
769        NULL::map[text=>text] AS labels, NULL::double AS value, 'h'::text AS help";
770
771    /// `VALID_SOURCE` with an ORDER BY appended. Maintained-lifetime planning folds it away rather
772    /// than rejecting it, since ordering has no meaning for a continuously-consumed collection.
773    const ORDERED_SOURCE: &str = "SELECT 'n'::text AS metric_name, 'gauge'::text AS metric_type, \
774        NULL::map[text=>text] AS labels, NULL::double AS value, 'h'::text AS help ORDER BY 1";
775
776    /// `plan_source` accepts the canonical column contract (including a source with a finishing,
777    /// which maintained-lifetime planning folds in) and rejects a source missing the columns.
778    #[mz_ore::test(tokio::test)]
779    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
780    async fn plan_source_enforces_the_metric_sink_contract() {
781        Catalog::with_debug(|catalog| async move {
782            let session_catalog = catalog.for_system_session();
783            let plan = |source_sql: &'static str| {
784                CuratedMetricSink {
785                    name: "test",
786                    source_sql,
787                    prefix: "mz_metric_sink_test_",
788                }
789                .plan_source(&session_catalog)
790            };
791
792            assert!(plan(VALID_SOURCE).is_ok());
793
794            // An ORDER BY is folded away by maintained-lifetime planning, not rejected.
795            assert!(plan(ORDERED_SOURCE).is_ok());
796
797            // Missing the canonical columns: rejected by `validate_metric_sink_desc`.
798            assert!(plan("SELECT 1 AS foo").is_err());
799
800            // Not exactly one statement: rejected by the explicit count guard.
801            assert!(plan("").is_err());
802            assert!(plan("SELECT 1; SELECT 2").is_err());
803        })
804        .await
805    }
806
807    /// A SQL-implemented builtin hides its reads: `pg_get_viewdef`'s body reads
808    /// `mz_catalog.mz_views`, which the dataflow imports but the statement's resolved ids omit.
809    /// `plan_source` must surface those reads so the gate rejects them.
810    #[mz_ore::test(tokio::test)]
811    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
812    async fn ensure_reads_only_logs_sees_sql_impl_function_reads() {
813        Catalog::with_debug(|catalog| async move {
814            let session_catalog = catalog.for_system_session();
815            let (_, _, dependencies) = CuratedMetricSink {
816                name: "test",
817                source_sql: "SELECT pg_get_viewdef('x') AS metric_name, 'gauge'::text AS metric_type, \
818                    NULL::map[text=>text] AS labels, NULL::double AS value, 'h'::text AS help",
819                prefix: "mz_metric_sink_test_",
820            }
821            .plan_source(&session_catalog)
822            .expect("plans against the system catalog");
823            assert!(ensure_reads_only_logs(&catalog, &dependencies).is_err());
824        })
825        .await
826    }
827
828    /// The introspection-only contract: a log dependency is accepted, a storage-backed one is
829    /// rejected. Checked against what the definition reads, so the verdict does not depend on the
830    /// target cluster's index layout.
831    #[mz_ore::test(tokio::test)]
832    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `TLS_client_method`
833    async fn ensure_reads_only_logs_accepts_logs_rejects_storage() {
834        Catalog::with_debug(|catalog| async move {
835            let log_id = catalog
836                .entries()
837                .find(|e| matches!(e.item(), CatalogItem::Log(_)))
838                .expect("debug catalog has a builtin log")
839                .id();
840            assert!(ensure_reads_only_logs(&catalog, &BTreeSet::from([log_id])).is_ok());
841
842            let storage_id = catalog
843                .entries()
844                .find(|e| matches!(e.item(), CatalogItem::Table(_) | CatalogItem::Source(_)))
845                .expect("debug catalog has a builtin table or source")
846                .id();
847            assert!(ensure_reads_only_logs(&catalog, &BTreeSet::from([storage_id])).is_err());
848        })
849        .await
850    }
851}