Skip to main content

mz_adapter/coord/
info_metrics.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//! Prometheus `*_info` metrics describing catalog objects.
11//!
12//! These follow the standard Prometheus "info" pattern (`kube_pod_info`
13//! style): one series per object, constant value `1`, with descriptive
14//! labels. They give other metrics a stable `group_left` join target for
15//! resolving object IDs to names.
16//!
17//! The metrics are periodically reconciled with the catalog by a
18//! background task ([Coordinator::spawn_catalog_info_metrics_task], driven by an
19//! interval off the coordinator's main loop. It rebuilds the series whenever the
20//! catalog's [transient revision](Catalog::transient_revision) changes, from the
21//! catalog. It's okay if the info metrics are not exactly up to date and
22//! eventually consistent.
23
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use mz_adapter_types::dyncfgs::CATALOG_INFO_METRICS_RECONCILE_INTERVAL;
28use mz_catalog::memory::objects::{
29    CatalogItem, Cluster, ClusterReplica, ClusterVariant, DataSourceDesc,
30};
31use mz_controller::clusters::ReplicaLocation;
32use mz_ore::cast::CastFrom;
33use mz_ore::metric;
34use mz_ore::metrics::{
35    DeleteOnDropGauge, Histogram, MetricTag, MetricVisibility, MetricsRegistry, UIntGaugeVec,
36};
37use mz_ore::stats::histogram_seconds_buckets;
38use mz_ore::task;
39use mz_ore::tracing::OpenTelemetryContext;
40use mz_repr::CatalogItemId;
41use mz_sql::names::{FullItemName, RawDatabaseSpecifier};
42use prometheus::core::AtomicU64;
43use tokio::sync::oneshot;
44use tracing::{debug, warn};
45
46use crate::catalog::{Catalog, catalog_type_to_audit_object_type};
47use crate::command::{CatalogSnapshot, Command};
48use crate::coord::{Coordinator, Message};
49
50/// Fallback reconcile cadence: the re-poll cadence used while reconciliation is
51/// disabled (a zero [CATALOG_INFO_METRICS_RECONCILE_INTERVAL]), so it can be
52/// re-enabled at runtime.
53const FALLBACK_RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
54
55/// Reconciles that take longer than this are logged at warn level.
56const RECONCILE_WARN_THRESHOLD: Duration = Duration::from_secs(5);
57
58type InfoGauge = DeleteOnDropGauge<AtomicU64, Vec<String>>;
59
60/// `*_info` metrics for catalog objects, mirroring the in-memory catalog.
61///
62/// System and user items, clusters, and replicas are all reported. Temporary
63/// (session-scoped) items and per-cluster introspection source indexes are
64/// not.
65#[derive(Debug)]
66pub(crate) struct CatalogInfoMetrics {
67    object_info: UIntGaugeVec,
68    cluster_info: UIntGaugeVec,
69    replica_info: UIntGaugeVec,
70    source_info: UIntGaugeVec,
71    sink_info: UIntGaugeVec,
72    /// Handles to all live series, held purely so that dropping them removes
73    /// the series from the registry. Series are only ever created and dropped
74    /// wholesale, by [CatalogInfoMetrics::populate].
75    series: Vec<InfoGauge>,
76    /// The [Catalog::transient_revision] the metrics were last populated
77    /// from. Used to avoid rebuilding the metrics if the catalog has not changed.
78    last_revision: Option<u64>,
79    /// Times a full (re)build of the series in [CatalogInfoMetrics::populate].
80    reconcile_seconds: Histogram,
81}
82
83impl CatalogInfoMetrics {
84    pub fn new(registry: &MetricsRegistry) -> Self {
85        Self {
86            object_info: registry.register(metric!(
87                name: "mz_object_info",
88                help: "Maps catalog object IDs to the object's name, schema, database, and \
89                       type. Constant 1.",
90                var_labels: ["object_id", "global_id", "name", "schema_name", "database_name", "type"],
91                visibility: MetricVisibility::Public,
92                tags: [MetricTag::Environment],
93            )),
94            cluster_info: registry.register(metric!(
95                name: "mz_cluster_info",
96                help: "Maps cluster IDs to the cluster's name and size. Constant 1.",
97                var_labels: ["cluster_id", "name", "size"],
98                visibility: MetricVisibility::Public,
99                tags: [MetricTag::Compute],
100            )),
101            replica_info: registry.register(metric!(
102                name: "mz_replica_info",
103                help: "Maps cluster replica IDs to the replica's name and size. Constant 1.",
104                var_labels: ["replica_id", "cluster_id", "name", "size"],
105                visibility: MetricVisibility::Public,
106                tags: [MetricTag::Compute],
107            )),
108            source_info: registry.register(metric!(
109                name: "mz_source_info",
110                help: "Maps user source IDs to the source's type, envelope type, and \
111                       cluster. Constant 1.",
112                var_labels: ["source_id", "type", "envelope_type", "cluster_id"],
113                visibility: MetricVisibility::Public,
114                tags: [MetricTag::Source],
115            )),
116            sink_info: registry.register(metric!(
117                name: "mz_sink_info",
118                help: "Maps user sink IDs to the sink's type, envelope type, and \
119                       cluster. Constant 1.",
120                var_labels: ["sink_id", "type", "envelope_type", "cluster_id"],
121                visibility: MetricVisibility::Public,
122                tags: [MetricTag::Sink],
123            )),
124            series: Vec::new(),
125            last_revision: None,
126            reconcile_seconds: registry.register(metric!(
127                name: "mz_catalog_info_metrics_reconcile_seconds",
128                help: "Time taken to rebuild the catalog info metrics from a catalog snapshot.",
129                buckets: histogram_seconds_buckets(0.000_128, 8.0),
130            )),
131        }
132    }
133
134    /// Reconciles the info metrics with the given catalog, unless they
135    /// already reflect its revision.
136    pub fn reconcile(&mut self, catalog: &Catalog) {
137        if self.last_revision != Some(catalog.transient_revision()) {
138            debug!(
139                revision = catalog.transient_revision(),
140                last_revision = self.last_revision,
141                "reconciling catalog info metrics"
142            );
143            let start = Instant::now();
144            self.populate(catalog);
145            let elapsed = start.elapsed();
146            self.reconcile_seconds.observe(elapsed.as_secs_f64());
147            if elapsed > RECONCILE_WARN_THRESHOLD {
148                warn!(
149                    ?elapsed,
150                    series = self.series.len(),
151                    "catalog info metrics reconcile was slow"
152                );
153            }
154            self.last_revision = Some(catalog.transient_revision());
155        }
156    }
157
158    /// (Re)populates all info metrics from the given catalog.
159    fn populate(&mut self, catalog: &Catalog) {
160        // Drop all existing series before creating replacements: dropping a
161        // stale handle whose labels match a newly created series would remove
162        // the new series from the vector
163        self.series.clear();
164
165        for entry in catalog.entries() {
166            let full_name = catalog.resolve_full_name(entry.name(), entry.conn_id());
167            self.insert_item(entry.id(), entry.item(), &full_name);
168            self.insert_source_or_sink(entry.id(), entry.item());
169        }
170        for cluster in catalog.clusters() {
171            self.insert_cluster(cluster);
172            for replica in cluster.replicas() {
173                self.insert_replica(replica);
174            }
175        }
176    }
177
178    fn insert_item(&mut self, id: CatalogItemId, item: &CatalogItem, full_name: &FullItemName) {
179        match id {
180            // Ignore per-cluster introspection source indexes and temporary
181            // (session-scoped) items.
182            CatalogItemId::IntrospectionSourceIndex(_) | CatalogItemId::Transient(_) => return,
183            CatalogItemId::System(_) | CatalogItemId::User(_) => (),
184        }
185
186        // System (ambient) objects don't belong to a database.
187        let database_name = match &full_name.database {
188            RawDatabaseSpecifier::Name(name) => name.clone(),
189            RawDatabaseSpecifier::Ambient => String::new(),
190        };
191        let item_type = catalog_type_to_audit_object_type(item.typ()).to_string();
192        for global_id in item.global_ids() {
193            let series = new_series(
194                &self.object_info,
195                vec![
196                    id.to_string(),
197                    global_id.to_string(),
198                    full_name.item.clone(),
199                    full_name.schema.clone(),
200                    database_name.clone(),
201                    item_type.clone(),
202                ],
203            );
204            self.series.push(series);
205        }
206    }
207
208    /// Reports an `mz_source_info` or `mz_sink_info` series for `item` if it
209    /// is a source or sink. Progress sources and subsources are not reported.
210    fn insert_source_or_sink(&mut self, id: CatalogItemId, item: &CatalogItem) {
211        let (info_vec, object_type, envelope_type) = match item {
212            CatalogItem::Source(source) => {
213                match &source.data_source {
214                    DataSourceDesc::Ingestion { .. }
215                    | DataSourceDesc::OldSyntaxIngestion { .. }
216                    | DataSourceDesc::Webhook { .. } => (),
217                    DataSourceDesc::IngestionExport { .. }
218                    | DataSourceDesc::Progress
219                    | DataSourceDesc::Introspection(_)
220                    | DataSourceDesc::Catalog => return,
221                }
222                (
223                    &self.source_info,
224                    source.source_type(),
225                    source.data_source.envelope(),
226                )
227            }
228            CatalogItem::Sink(sink) => (&self.sink_info, sink.sink_type(), sink.envelope()),
229            _ => return,
230        };
231
232        let series = new_series(
233            info_vec,
234            vec![
235                id.to_string(),
236                object_type.to_string(),
237                envelope_type.map(|s| s.to_string()).unwrap_or_default(),
238                item.cluster_id()
239                    .map(|id| id.to_string())
240                    .unwrap_or_default(),
241            ],
242        );
243        self.series.push(series);
244    }
245
246    fn insert_cluster(&mut self, cluster: &Cluster) {
247        let size = match &cluster.config.variant {
248            ClusterVariant::Managed(managed) => managed.size.clone(),
249            ClusterVariant::Unmanaged => String::new(),
250        };
251        let series = new_series(
252            &self.cluster_info,
253            vec![cluster.id.to_string(), cluster.name.clone(), size],
254        );
255        self.series.push(series);
256    }
257
258    fn insert_replica(&mut self, replica: &ClusterReplica) {
259        let size = match &replica.config.location {
260            ReplicaLocation::Managed(managed) => managed.size.clone(),
261            ReplicaLocation::Unmanaged(_) => String::new(),
262        };
263        let series = new_series(
264            &self.replica_info,
265            vec![
266                replica.replica_id.to_string(),
267                replica.cluster_id.to_string(),
268                replica.name.clone(),
269                size,
270            ],
271        );
272        self.series.push(series);
273    }
274}
275
276impl Coordinator {
277    /// Spawns a background task that keeps the catalog info metrics in sync with
278    /// the catalog.
279    pub(crate) fn spawn_catalog_info_metrics_task(&self) {
280        let internal_cmd_tx = self.internal_cmd_tx.clone();
281        let mut metrics = CatalogInfoMetrics::new(&self.catalog_info_metrics_registry);
282        let catalog_arc_strong_count = self.metrics.catalog_arc_strong_count.clone();
283        let catalog_arc_weak_count = self.metrics.catalog_arc_weak_count.clone();
284        task::spawn(|| "catalog_info_metrics", async move {
285            loop {
286                let (tx, rx) = oneshot::channel();
287                let send = internal_cmd_tx.send(Message::Command(
288                    OpenTelemetryContext::obtain(),
289                    Command::CatalogSnapshot { tx },
290                ));
291                // Bail if the coordinator has gone away.
292                if send.is_err() {
293                    break;
294                }
295                let Ok(CatalogSnapshot { catalog }) = rx.await else {
296                    break;
297                };
298
299                // Sample the reference counts of the current catalog
300                // allocation. The strong count includes this task's own
301                // snapshot. The weak count is the number of session catalog
302                // caches pointing at the current allocation (sessions caching
303                // an older version are not counted).
304                catalog_arc_strong_count.set(u64::cast_from(Arc::strong_count(&catalog)));
305                catalog_arc_weak_count.set(u64::cast_from(Arc::weak_count(&catalog)));
306
307                // The reconcile cadence is a dyncfg; a zero interval disables
308                // reconciliation.
309                let interval =
310                    CATALOG_INFO_METRICS_RECONCILE_INTERVAL.get(catalog.system_config().dyncfgs());
311                if !interval.is_zero() {
312                    metrics.reconcile(&catalog);
313                }
314
315                // When disabled, keep polling at the fallback cadence so it can
316                // be re-enabled at runtime.
317                let sleep = if interval.is_zero() {
318                    FALLBACK_RECONCILE_INTERVAL
319                } else {
320                    interval
321                };
322                tokio::time::sleep(sleep).await;
323            }
324        });
325    }
326}
327
328fn new_series(vec: &UIntGaugeVec, labels: Vec<String>) -> InfoGauge {
329    let gauge = vec.get_delete_on_drop_metric(labels);
330    gauge.set(1);
331    gauge
332}
333#[cfg(test)]
334mod tests {
335    use std::collections::{BTreeMap, BTreeSet};
336
337    use mz_catalog::memory::objects::{
338        ClusterConfig, ClusterVariantManaged, Sink, Source, Table, TableDataSource,
339    };
340    use mz_compute_types::config::ComputeReplicaConfig;
341    use mz_controller::clusters::{
342        ManagedReplicaLocation, ReplicaAllocation, ReplicaConfig, UnmanagedReplicaLocation,
343    };
344    use mz_controller_types::{ClusterId, ReplicaId};
345    use mz_repr::adt::mz_acl_item::PrivilegeMap;
346    use mz_repr::role_id::RoleId;
347    use mz_repr::{GlobalId, RelationDesc, RelationVersion, SqlScalarType, VersionedRelationDesc};
348    use mz_sql::names::ResolvedIds;
349    use mz_sql::plan::{WebhookBodyFormat, WebhookHeaders};
350    use mz_sql::session::user::MZ_SYSTEM_ROLE_ID;
351    use mz_storage_types::sinks::{
352        KafkaIdStyle, KafkaSinkCompressionType, KafkaSinkConnection, KafkaSinkFormat,
353        KafkaSinkFormatType, SinkEnvelope, StorageSinkConnection,
354    };
355    use mz_storage_types::sources::Timeline;
356
357    use super::*;
358    use crate::catalog::Op;
359
360    /// Returns the label maps of all series of the metric `name`
361    fn series(registry: &MetricsRegistry, name: &str) -> Vec<BTreeMap<String, String>> {
362        registry
363            .gather()
364            .iter()
365            .filter(|family| family.name() == name)
366            .flat_map(|family| family.get_metric())
367            .map(|metric| {
368                // ensure every series has the value 1.
369                assert_eq!(metric.get_gauge().value(), 1.0, "info series must be 1");
370                metric
371                    .get_label()
372                    .iter()
373                    .map(|label| (label.name().to_string(), label.value().to_string()))
374                    .collect()
375            })
376            .collect()
377    }
378
379    fn labels(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
380        pairs
381            .iter()
382            .map(|(k, v)| (k.to_string(), v.to_string()))
383            .collect()
384    }
385
386    fn test_table(gid: GlobalId) -> CatalogItem {
387        CatalogItem::Table(Table {
388            create_sql: None,
389            desc: VersionedRelationDesc::new(
390                RelationDesc::builder()
391                    .with_column("a", SqlScalarType::String.nullable(false))
392                    .finish(),
393            ),
394            collections: BTreeMap::from([(RelationVersion::root(), gid)]),
395            conn_id: None,
396            resolved_ids: ResolvedIds::empty(),
397            custom_logical_compaction_window: None,
398            is_retained_metrics_object: false,
399            data_source: TableDataSource::TableWrites { defaults: vec![] },
400        })
401    }
402
403    fn test_webhook_source(gid: u64, cluster_id: ClusterId) -> CatalogItem {
404        CatalogItem::Source(Source {
405            create_sql: None,
406            global_id: GlobalId::User(gid),
407            data_source: DataSourceDesc::Webhook {
408                validate_using: None,
409                body_format: WebhookBodyFormat::Json { array: false },
410                headers: WebhookHeaders::default(),
411                cluster_id,
412            },
413            desc: RelationDesc::builder()
414                .with_column("a", SqlScalarType::String.nullable(false))
415                .finish(),
416            timeline: Timeline::EpochMilliseconds,
417            resolved_ids: ResolvedIds::empty(),
418            custom_logical_compaction_window: None,
419            is_retained_metrics_object: false,
420        })
421    }
422
423    fn test_kafka_sink(gid: u64, cluster_id: ClusterId) -> CatalogItem {
424        CatalogItem::Sink(Sink {
425            create_sql: "CREATE SINK s FROM t INTO KAFKA CONNECTION c (TOPIC 'topic')".to_string(),
426            global_id: GlobalId::User(gid),
427            from: GlobalId::User(1),
428            connection: StorageSinkConnection::Kafka(KafkaSinkConnection {
429                connection_id: CatalogItemId::User(2),
430                connection: CatalogItemId::User(2),
431                format: KafkaSinkFormat {
432                    key_format: None,
433                    value_format: KafkaSinkFormatType::Json,
434                },
435                relation_key_indices: None,
436                key_desc_and_indices: None,
437                headers_index: None,
438                value_desc: RelationDesc::builder()
439                    .with_column("a", SqlScalarType::String.nullable(false))
440                    .finish(),
441                partition_by: None,
442                topic: "topic".to_string(),
443                topic_options: Default::default(),
444                compression_type: KafkaSinkCompressionType::None,
445                progress_group_id: KafkaIdStyle::Legacy,
446                transactional_id: KafkaIdStyle::Legacy,
447                topic_metadata_refresh_interval: Duration::from_secs(60),
448            }),
449            envelope: SinkEnvelope::Upsert,
450            with_snapshot: true,
451            version: 0,
452            resolved_ids: ResolvedIds::empty(),
453            cluster_id,
454            commit_interval: None,
455        })
456    }
457
458    fn test_cluster_config(size: &str) -> ClusterConfig {
459        ClusterConfig {
460            variant: ClusterVariant::Managed(ClusterVariantManaged {
461                size: size.to_string(),
462                availability_zones: Vec::new(),
463                logging: Default::default(),
464                replication_factor: 1,
465                optimizer_feature_overrides: Default::default(),
466                schedule: Default::default(),
467                auto_scaling_strategy: None,
468                reconfiguration: None,
469                burst: None,
470            }),
471            workload_class: None,
472        }
473    }
474
475    fn test_cluster(id: ClusterId, name: &str, size: &str) -> Cluster {
476        Cluster {
477            name: name.to_string(),
478            id,
479            config: test_cluster_config(size),
480            log_indexes: BTreeMap::new(),
481            bound_objects: BTreeSet::new(),
482            replica_id_by_name_: BTreeMap::new(),
483            replicas_by_id_: BTreeMap::new(),
484            owner_id: RoleId::User(1),
485            privileges: PrivilegeMap::default(),
486        }
487    }
488
489    fn test_replica(cluster_id: ClusterId, replica_id: ReplicaId, name: &str) -> ClusterReplica {
490        ClusterReplica {
491            name: name.to_string(),
492            cluster_id,
493            replica_id,
494            config: ReplicaConfig {
495                location: ReplicaLocation::Unmanaged(UnmanagedReplicaLocation {
496                    storagectl_addrs: Vec::new(),
497                    computectl_addrs: Vec::new(),
498                }),
499                compute: ComputeReplicaConfig {
500                    logging: Default::default(),
501                },
502            },
503            owner_id: RoleId::User(1),
504        }
505    }
506
507    fn full_name(database: Option<&str>, schema: &str, item: &str) -> FullItemName {
508        FullItemName {
509            database: match database {
510                Some(database) => RawDatabaseSpecifier::Name(database.to_string()),
511                None => RawDatabaseSpecifier::Ambient,
512            },
513            schema: schema.to_string(),
514            item: item.to_string(),
515        }
516    }
517
518    #[mz_ore::test]
519    fn item_insertion_creates_object_info_series() {
520        let registry = MetricsRegistry::new();
521        let mut metrics = CatalogInfoMetrics::new(&registry);
522
523        metrics.insert_item(
524            CatalogItemId::User(1),
525            &test_table(GlobalId::User(1)),
526            &full_name(Some("materialize"), "public", "t"),
527        );
528
529        assert_eq!(
530            series(&registry, "mz_object_info"),
531            vec![labels(&[
532                ("object_id", "u1"),
533                ("global_id", "u1"),
534                ("name", "t"),
535                ("schema_name", "public"),
536                ("database_name", "materialize"),
537                ("type", "table"),
538            ])]
539        );
540    }
541
542    #[mz_ore::test]
543    fn system_items_are_reported() {
544        let registry = MetricsRegistry::new();
545        let mut metrics = CatalogInfoMetrics::new(&registry);
546
547        metrics.insert_item(
548            CatalogItemId::System(456),
549            &test_table(GlobalId::System(456)),
550            // System schemas are ambient, i.e. not in a database.
551            &full_name(None, "mz_catalog", "mz_test"),
552        );
553
554        assert_eq!(
555            series(&registry, "mz_object_info"),
556            vec![labels(&[
557                ("object_id", "s456"),
558                ("global_id", "s456"),
559                ("name", "mz_test"),
560                ("schema_name", "mz_catalog"),
561                ("database_name", ""),
562                ("type", "table"),
563            ])]
564        );
565    }
566
567    #[mz_ore::test]
568    fn introspection_indexes_and_temporary_items_are_not_reported() {
569        let registry = MetricsRegistry::new();
570        let mut metrics = CatalogInfoMetrics::new(&registry);
571
572        metrics.insert_item(
573            CatalogItemId::IntrospectionSourceIndex(5),
574            &test_table(GlobalId::IntrospectionSourceIndex(5)),
575            &full_name(None, "mz_introspection", "mz_test"),
576        );
577        metrics.insert_item(
578            CatalogItemId::Transient(6),
579            &test_table(GlobalId::Transient(6)),
580            &full_name(None, "mz_temp", "t"),
581        );
582
583        assert_eq!(series(&registry, "mz_object_info"), Vec::new());
584    }
585
586    #[mz_ore::test]
587    fn user_sources_get_source_info_series() {
588        let registry = MetricsRegistry::new();
589        let mut metrics = CatalogInfoMetrics::new(&registry);
590
591        let cluster_id = ClusterId::user(9).expect("valid id");
592        metrics.insert_source_or_sink(CatalogItemId::User(4), &test_webhook_source(4, cluster_id));
593
594        assert_eq!(
595            series(&registry, "mz_source_info"),
596            vec![labels(&[
597                ("source_id", "u4"),
598                ("type", "webhook"),
599                // Webhook sources have no envelope.
600                ("envelope_type", ""),
601                ("cluster_id", "u9"),
602            ])]
603        );
604    }
605
606    #[mz_ore::test]
607    fn user_sinks_get_sink_info_series() {
608        let registry = MetricsRegistry::new();
609        let mut metrics = CatalogInfoMetrics::new(&registry);
610
611        let cluster_id = ClusterId::user(9).expect("valid id");
612        metrics.insert_source_or_sink(CatalogItemId::User(5), &test_kafka_sink(5, cluster_id));
613
614        assert_eq!(
615            series(&registry, "mz_sink_info"),
616            vec![labels(&[
617                ("sink_id", "u5"),
618                ("type", "kafka"),
619                ("envelope_type", "upsert"),
620                ("cluster_id", "u9"),
621            ])]
622        );
623    }
624
625    #[mz_ore::test]
626    fn cluster_and_replica_insertions_create_info_series() {
627        let registry = MetricsRegistry::new();
628        let mut metrics = CatalogInfoMetrics::new(&registry);
629
630        let cluster_id = ClusterId::user(7).expect("valid id");
631        let replica_id = ReplicaId::User(2);
632        metrics.insert_cluster(&test_cluster(cluster_id, "prod", "123cc"));
633        metrics.insert_replica(&test_replica(cluster_id, replica_id, "r1"));
634
635        assert_eq!(
636            series(&registry, "mz_cluster_info"),
637            vec![labels(&[
638                ("cluster_id", "u7"),
639                ("name", "prod"),
640                ("size", "123cc"),
641            ])]
642        );
643        assert_eq!(
644            series(&registry, "mz_replica_info"),
645            vec![labels(&[
646                ("replica_id", "u2"),
647                ("cluster_id", "u7"),
648                ("name", "r1"),
649                // Replicas with unmanaged *locations* (user-specified
650                // addresses) have no allocation, and so no size.
651                ("size", ""),
652            ])]
653        );
654    }
655
656    #[mz_ore::test]
657    #[cfg_attr(miri, ignore)] // can't call foreign function `decContextDefault` on OS `linux`
658    fn replicas_of_unmanaged_clusters_report_their_size() {
659        let registry = MetricsRegistry::new();
660        let mut metrics = CatalogInfoMetrics::new(&registry);
661
662        // Replicas of unmanaged clusters (`CREATE CLUSTER c REPLICAS (..)`)
663        // have a managed *location* with an allocation and a size of their
664        // own, even though the cluster variant carries none.
665        let allocation: ReplicaAllocation =
666            serde_json::from_str(r#"{"scale": 2, "workers": 4, "credits_per_hour": "0"}"#)
667                .expect("valid allocation");
668        let replica = ClusterReplica {
669            name: "r2".to_string(),
670            cluster_id: ClusterId::user(7).expect("valid id"),
671            replica_id: ReplicaId::User(3),
672            config: ReplicaConfig {
673                location: ReplicaLocation::Managed(ManagedReplicaLocation {
674                    allocation,
675                    size: "scale=2,workers=4".to_string(),
676                    internal: false,
677                    billed_as: None,
678                    availability_zones: Vec::new(),
679                    pending: false,
680                }),
681                compute: ComputeReplicaConfig {
682                    logging: Default::default(),
683                },
684            },
685            owner_id: RoleId::User(1),
686        };
687        metrics.insert_replica(&replica);
688
689        assert_eq!(
690            series(&registry, "mz_replica_info"),
691            vec![labels(&[
692                ("replica_id", "u3"),
693                ("cluster_id", "u7"),
694                ("name", "r2"),
695                ("size", "scale=2,workers=4"),
696            ])]
697        );
698    }
699
700    #[mz_ore::test(tokio::test)]
701    #[cfg_attr(miri, ignore)]
702    async fn reconcile_rebuilds_from_the_catalog_when_its_revision_changes() {
703        Catalog::with_debug(|mut catalog| async move {
704            let registry = MetricsRegistry::new();
705            let mut metrics = CatalogInfoMetrics::new(&registry);
706
707            let has_test_cluster_series = |registry: &MetricsRegistry| {
708                series(registry, "mz_cluster_info")
709                    .iter()
710                    .any(|s| s["name"] == "test_cluster")
711            };
712
713            metrics.reconcile(&catalog);
714
715            // The initial reconciliation reports the catalog's contents,
716            // including system items, but not introspection source indexes.
717            assert!(
718                series(&registry, "mz_cluster_info")
719                    .iter()
720                    .any(|s| s["name"] == "mz_system")
721            );
722            assert_ne!(series(&registry, "mz_replica_info"), Vec::new());
723            let objects = series(&registry, "mz_object_info");
724            // System items
725            assert!(objects.iter().any(|s| s["object_id"].starts_with("s")));
726            // Introspection source indexes
727            assert!(!objects.iter().any(|s| s["object_id"].starts_with("si")));
728
729            // Assert that the test cluster series is not present.
730            assert!(!has_test_cluster_series(&registry));
731
732            // Create a cluster in the catalog.
733            let commit_ts = catalog.current_upper().await;
734            let cluster_id = catalog
735                .allocate_user_cluster_id(commit_ts)
736                .await
737                .expect("failed to allocate cluster id");
738            let commit_ts = catalog.current_upper().await;
739            catalog
740                .transact(
741                    None,
742                    commit_ts,
743                    None,
744                    vec![Op::CreateCluster {
745                        id: cluster_id,
746                        name: "test_cluster".to_string(),
747                        introspection_sources: Vec::new(),
748                        owner_id: MZ_SYSTEM_ROLE_ID,
749                        config: test_cluster_config("scale=1,workers=2"),
750                    }],
751                )
752                .await
753                .expect("failed to transact");
754
755            // The metrics are stale until the next reconciliation.
756            assert!(!has_test_cluster_series(&registry));
757
758            metrics.reconcile(&catalog);
759            assert!(has_test_cluster_series(&registry));
760
761            catalog.expire().await;
762        })
763        .await
764    }
765}