1use 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
50const FALLBACK_RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
54
55const RECONCILE_WARN_THRESHOLD: Duration = Duration::from_secs(5);
57
58type InfoGauge = DeleteOnDropGauge<AtomicU64, Vec<String>>;
59
60#[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 series: Vec<InfoGauge>,
76 last_revision: Option<u64>,
79 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 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 fn populate(&mut self, catalog: &Catalog) {
160 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 CatalogItemId::IntrospectionSourceIndex(_) | CatalogItemId::Transient(_) => return,
183 CatalogItemId::System(_) | CatalogItemId::User(_) => (),
184 }
185
186 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 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 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 if send.is_err() {
293 break;
294 }
295 let Ok(CatalogSnapshot { catalog }) = rx.await else {
296 break;
297 };
298
299 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 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 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 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 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 arrangement_compression: false,
465 replication_factor: 1,
466 optimizer_feature_overrides: Default::default(),
467 schedule: Default::default(),
468 auto_scaling_strategy: None,
469 reconfiguration: None,
470 burst: None,
471 }),
472 workload_class: None,
473 }
474 }
475
476 fn test_cluster(id: ClusterId, name: &str, size: &str) -> Cluster {
477 Cluster {
478 name: name.to_string(),
479 id,
480 config: test_cluster_config(size),
481 log_indexes: BTreeMap::new(),
482 bound_objects: BTreeSet::new(),
483 replica_id_by_name_: BTreeMap::new(),
484 replicas_by_id_: BTreeMap::new(),
485 owner_id: RoleId::User(1),
486 privileges: PrivilegeMap::default(),
487 }
488 }
489
490 fn test_replica(cluster_id: ClusterId, replica_id: ReplicaId, name: &str) -> ClusterReplica {
491 ClusterReplica {
492 name: name.to_string(),
493 cluster_id,
494 replica_id,
495 config: ReplicaConfig {
496 location: ReplicaLocation::Unmanaged(UnmanagedReplicaLocation {
497 storagectl_addrs: Vec::new(),
498 computectl_addrs: Vec::new(),
499 }),
500 compute: ComputeReplicaConfig {
501 logging: Default::default(),
502 arrangement_compression: false,
503 },
504 },
505 owner_id: RoleId::User(1),
506 }
507 }
508
509 fn full_name(database: Option<&str>, schema: &str, item: &str) -> FullItemName {
510 FullItemName {
511 database: match database {
512 Some(database) => RawDatabaseSpecifier::Name(database.to_string()),
513 None => RawDatabaseSpecifier::Ambient,
514 },
515 schema: schema.to_string(),
516 item: item.to_string(),
517 }
518 }
519
520 #[mz_ore::test]
521 fn item_insertion_creates_object_info_series() {
522 let registry = MetricsRegistry::new();
523 let mut metrics = CatalogInfoMetrics::new(®istry);
524
525 metrics.insert_item(
526 CatalogItemId::User(1),
527 &test_table(GlobalId::User(1)),
528 &full_name(Some("materialize"), "public", "t"),
529 );
530
531 assert_eq!(
532 series(®istry, "mz_object_info"),
533 vec![labels(&[
534 ("object_id", "u1"),
535 ("global_id", "u1"),
536 ("name", "t"),
537 ("schema_name", "public"),
538 ("database_name", "materialize"),
539 ("type", "table"),
540 ])]
541 );
542 }
543
544 #[mz_ore::test]
545 fn system_items_are_reported() {
546 let registry = MetricsRegistry::new();
547 let mut metrics = CatalogInfoMetrics::new(®istry);
548
549 metrics.insert_item(
550 CatalogItemId::System(456),
551 &test_table(GlobalId::System(456)),
552 &full_name(None, "mz_catalog", "mz_test"),
554 );
555
556 assert_eq!(
557 series(®istry, "mz_object_info"),
558 vec![labels(&[
559 ("object_id", "s456"),
560 ("global_id", "s456"),
561 ("name", "mz_test"),
562 ("schema_name", "mz_catalog"),
563 ("database_name", ""),
564 ("type", "table"),
565 ])]
566 );
567 }
568
569 #[mz_ore::test]
570 fn introspection_indexes_and_temporary_items_are_not_reported() {
571 let registry = MetricsRegistry::new();
572 let mut metrics = CatalogInfoMetrics::new(®istry);
573
574 metrics.insert_item(
575 CatalogItemId::IntrospectionSourceIndex(5),
576 &test_table(GlobalId::IntrospectionSourceIndex(5)),
577 &full_name(None, "mz_introspection", "mz_test"),
578 );
579 metrics.insert_item(
580 CatalogItemId::Transient(6),
581 &test_table(GlobalId::Transient(6)),
582 &full_name(None, "mz_temp", "t"),
583 );
584
585 assert_eq!(series(®istry, "mz_object_info"), Vec::new());
586 }
587
588 #[mz_ore::test]
589 fn user_sources_get_source_info_series() {
590 let registry = MetricsRegistry::new();
591 let mut metrics = CatalogInfoMetrics::new(®istry);
592
593 let cluster_id = ClusterId::user(9).expect("valid id");
594 metrics.insert_source_or_sink(CatalogItemId::User(4), &test_webhook_source(4, cluster_id));
595
596 assert_eq!(
597 series(®istry, "mz_source_info"),
598 vec![labels(&[
599 ("source_id", "u4"),
600 ("type", "webhook"),
601 ("envelope_type", ""),
603 ("cluster_id", "u9"),
604 ])]
605 );
606 }
607
608 #[mz_ore::test]
609 fn user_sinks_get_sink_info_series() {
610 let registry = MetricsRegistry::new();
611 let mut metrics = CatalogInfoMetrics::new(®istry);
612
613 let cluster_id = ClusterId::user(9).expect("valid id");
614 metrics.insert_source_or_sink(CatalogItemId::User(5), &test_kafka_sink(5, cluster_id));
615
616 assert_eq!(
617 series(®istry, "mz_sink_info"),
618 vec![labels(&[
619 ("sink_id", "u5"),
620 ("type", "kafka"),
621 ("envelope_type", "upsert"),
622 ("cluster_id", "u9"),
623 ])]
624 );
625 }
626
627 #[mz_ore::test]
628 fn cluster_and_replica_insertions_create_info_series() {
629 let registry = MetricsRegistry::new();
630 let mut metrics = CatalogInfoMetrics::new(®istry);
631
632 let cluster_id = ClusterId::user(7).expect("valid id");
633 let replica_id = ReplicaId::User(2);
634 metrics.insert_cluster(&test_cluster(cluster_id, "prod", "123cc"));
635 metrics.insert_replica(&test_replica(cluster_id, replica_id, "r1"));
636
637 assert_eq!(
638 series(®istry, "mz_cluster_info"),
639 vec![labels(&[
640 ("cluster_id", "u7"),
641 ("name", "prod"),
642 ("size", "123cc"),
643 ])]
644 );
645 assert_eq!(
646 series(®istry, "mz_replica_info"),
647 vec![labels(&[
648 ("replica_id", "u2"),
649 ("cluster_id", "u7"),
650 ("name", "r1"),
651 ("size", ""),
654 ])]
655 );
656 }
657
658 #[mz_ore::test]
659 #[cfg_attr(miri, ignore)] fn replicas_of_unmanaged_clusters_report_their_size() {
661 let registry = MetricsRegistry::new();
662 let mut metrics = CatalogInfoMetrics::new(®istry);
663
664 let allocation: ReplicaAllocation =
668 serde_json::from_str(r#"{"scale": 2, "workers": 4, "credits_per_hour": "0"}"#)
669 .expect("valid allocation");
670 let replica = ClusterReplica {
671 name: "r2".to_string(),
672 cluster_id: ClusterId::user(7).expect("valid id"),
673 replica_id: ReplicaId::User(3),
674 config: ReplicaConfig {
675 location: ReplicaLocation::Managed(ManagedReplicaLocation {
676 allocation,
677 size: "scale=2,workers=4".to_string(),
678 internal: false,
679 billed_as: None,
680 availability_zones: Vec::new(),
681 pending: false,
682 }),
683 compute: ComputeReplicaConfig {
684 logging: Default::default(),
685 arrangement_compression: false,
686 },
687 },
688 owner_id: RoleId::User(1),
689 };
690 metrics.insert_replica(&replica);
691
692 assert_eq!(
693 series(®istry, "mz_replica_info"),
694 vec![labels(&[
695 ("replica_id", "u3"),
696 ("cluster_id", "u7"),
697 ("name", "r2"),
698 ("size", "scale=2,workers=4"),
699 ])]
700 );
701 }
702
703 #[mz_ore::test(tokio::test)]
704 #[cfg_attr(miri, ignore)]
705 async fn reconcile_rebuilds_from_the_catalog_when_its_revision_changes() {
706 Catalog::with_debug(|mut catalog| async move {
707 let registry = MetricsRegistry::new();
708 let mut metrics = CatalogInfoMetrics::new(®istry);
709
710 let has_test_cluster_series = |registry: &MetricsRegistry| {
711 series(registry, "mz_cluster_info")
712 .iter()
713 .any(|s| s["name"] == "test_cluster")
714 };
715
716 metrics.reconcile(&catalog);
717
718 assert!(
721 series(®istry, "mz_cluster_info")
722 .iter()
723 .any(|s| s["name"] == "mz_system")
724 );
725 assert_ne!(series(®istry, "mz_replica_info"), Vec::new());
726 let objects = series(®istry, "mz_object_info");
727 assert!(objects.iter().any(|s| s["object_id"].starts_with("s")));
729 assert!(!objects.iter().any(|s| s["object_id"].starts_with("si")));
731
732 assert!(!has_test_cluster_series(®istry));
734
735 let commit_ts = catalog.current_upper().await;
737 let cluster_id = catalog
738 .allocate_user_cluster_id(commit_ts)
739 .await
740 .expect("failed to allocate cluster id");
741 let commit_ts = catalog.current_upper().await;
742 catalog
743 .transact(
744 None,
745 commit_ts,
746 None,
747 vec![Op::CreateCluster {
748 id: cluster_id,
749 name: "test_cluster".to_string(),
750 introspection_sources: Vec::new(),
751 owner_id: MZ_SYSTEM_ROLE_ID,
752 config: test_cluster_config("scale=1,workers=2"),
753 }],
754 )
755 .await
756 .expect("failed to transact");
757
758 assert!(!has_test_cluster_series(®istry));
760
761 metrics.reconcile(&catalog);
762 assert!(has_test_cluster_series(®istry));
763
764 catalog.expire().await;
765 })
766 .await
767 }
768}