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