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}