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}