Skip to main content

risingwave_connector/source/cdc/enumerator/
mod.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::BTreeMap;
16use std::marker::PhantomData;
17use std::ops::Deref;
18use std::str::FromStr;
19use std::sync::Arc;
20
21use anyhow::{Context, anyhow};
22use async_trait::async_trait;
23use itertools::Itertools;
24use mysql_async::Row;
25use mysql_async::prelude::*;
26use prost::Message;
27use risingwave_common::global_jvm::Jvm;
28use risingwave_common::id::SourceId;
29use risingwave_common::metrics::{LabelGuardedIntGauge, LabelGuardedIntGaugeVec};
30use risingwave_common::util::addr::HostAddr;
31use risingwave_jni_core::call_static_method;
32use risingwave_jni_core::jvm_runtime::execute_with_jni_env;
33use risingwave_pb::connector_service::{SourceType, ValidateSourceRequest, ValidateSourceResponse};
34use tiberius::Config;
35use tokio_postgres::types::PgLsn;
36
37use crate::connector_common::{SslMode, create_pg_client, pg_connection_config_from_properties};
38use crate::error::ConnectorResult;
39use crate::sink::sqlserver::SqlServerClient;
40use crate::source::cdc::external::mysql::build_mysql_connection_pool;
41use crate::source::cdc::split::{extract_binlog_file_seq, parse_sql_server_lsn_str};
42use crate::source::cdc::{
43    CdcProperties, CdcSourceTypeTrait, Citus, DebeziumCdcSplit, Mongodb, Mysql, Postgres,
44    SqlServer, table_schema_exclude_additional_columns,
45};
46use crate::source::monitor::metrics::EnumeratorMetrics;
47use crate::source::{SourceEnumeratorContextRef, SplitEnumerator};
48
49pub const DATABASE_SERVERS_KEY: &str = "database.servers";
50
51#[derive(Debug)]
52pub struct DebeziumSplitEnumerator<T: CdcSourceTypeTrait> {
53    /// The `source_id` in the catalog
54    source_id: SourceId,
55    worker_node_addrs: Vec<HostAddr>,
56    metrics: Arc<EnumeratorMetrics>,
57    pg_cdc_upstream_max_lsn: Option<LabelGuardedIntGauge>,
58    pg_cdc_confirmed_flush_lsn: Option<LabelGuardedIntGauge>,
59    mysql_cdc_binlog_file_seq_min: Option<LabelGuardedIntGauge>,
60    mysql_cdc_binlog_file_seq_max: Option<LabelGuardedIntGauge>,
61    sqlserver_cdc_upstream_min_lsn: Option<LabelGuardedIntGauge>,
62    sqlserver_cdc_upstream_max_lsn: Option<LabelGuardedIntGauge>,
63    /// Properties specified in the WITH clause by user for database connection
64    properties: Arc<BTreeMap<String, String>>,
65    _phantom: PhantomData<T>,
66}
67
68fn get_or_create_guarded_int_gauge<'a>(
69    metric: &'a mut Option<LabelGuardedIntGauge>,
70    metric_vec: &LabelGuardedIntGaugeVec,
71    labels: &[String],
72) -> &'a LabelGuardedIntGauge {
73    metric.get_or_insert_with(|| metric_vec.with_guarded_label_values(labels))
74}
75
76#[async_trait]
77impl<T: CdcSourceTypeTrait> SplitEnumerator for DebeziumSplitEnumerator<T>
78where
79    Self: ListCdcSplits<CdcSourceType = T> + CdcMonitor,
80{
81    type Properties = CdcProperties<T>;
82    type Split = DebeziumCdcSplit<T>;
83
84    async fn new(
85        props: CdcProperties<T>,
86        context: SourceEnumeratorContextRef,
87    ) -> ConnectorResult<Self> {
88        let server_addrs = props
89            .properties
90            .get(DATABASE_SERVERS_KEY)
91            .map(|s| {
92                s.split(',')
93                    .map(HostAddr::from_str)
94                    .collect::<Result<Vec<_>, _>>()
95            })
96            .transpose()?
97            .unwrap_or_default();
98
99        assert_eq!(
100            props.get_source_type_pb(),
101            SourceType::from(T::source_type())
102        );
103
104        let jvm = Jvm::get_or_init()?;
105        let source_id = context.info.source_id;
106
107        // Extract fields before moving props
108        let source_type_pb = props.get_source_type_pb();
109
110        // Create Arc once and share it
111        let properties_arc = Arc::new(props.properties);
112        let properties_arc_for_validation = properties_arc.clone();
113        let table_schema_for_validation = props.table_schema;
114
115        tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
116            execute_with_jni_env(jvm, |env| {
117                let validate_source_request = ValidateSourceRequest {
118                    source_id: source_id.as_raw_id() as u64,
119                    source_type: source_type_pb as _,
120                    properties: (*properties_arc_for_validation).clone(),
121                    table_schema: Some(table_schema_exclude_additional_columns(
122                        &table_schema_for_validation,
123                    )),
124                    is_source_job: props.is_cdc_source_job,
125                    is_backfill_table: props.is_backfill_table,
126                };
127
128                let validate_source_request_bytes =
129                    env.byte_array_from_slice(&Message::encode_to_vec(&validate_source_request))?;
130
131                let validate_source_response_bytes = call_static_method!(
132                    env,
133                    {com.risingwave.connector.source.JniSourceValidateHandler},
134                    {byte[] validate(byte[] validateSourceRequestBytes)},
135                    &validate_source_request_bytes
136                )?;
137
138                let validate_source_response: ValidateSourceResponse = Message::decode(
139                    risingwave_jni_core::to_guarded_slice(&validate_source_response_bytes, env)?
140                        .deref(),
141                )?;
142
143                if let Some(error) = validate_source_response.error {
144                    return Err(anyhow!(error.error_message).context("source validation failed"));
145                }
146
147                Ok(())
148            })
149        })
150        .await
151        .context("failed to validate source")??;
152
153        tracing::debug!("validate cdc source properties success");
154        Ok(Self {
155            source_id,
156            worker_node_addrs: server_addrs,
157            metrics: context.metrics.clone(),
158            pg_cdc_upstream_max_lsn: None,
159            pg_cdc_confirmed_flush_lsn: None,
160            mysql_cdc_binlog_file_seq_min: None,
161            mysql_cdc_binlog_file_seq_max: None,
162            sqlserver_cdc_upstream_min_lsn: None,
163            sqlserver_cdc_upstream_max_lsn: None,
164            properties: properties_arc,
165            _phantom: PhantomData,
166        })
167    }
168
169    async fn list_splits(&mut self) -> ConnectorResult<Vec<DebeziumCdcSplit<T>>> {
170        Ok(self.list_cdc_splits())
171    }
172
173    async fn on_tick(&mut self) -> ConnectorResult<()> {
174        self.monitor_cdc().await
175    }
176}
177
178impl<T: CdcSourceTypeTrait> DebeziumSplitEnumerator<T> {
179    fn sql_server_lsn_to_i64(lsn: &str) -> Option<i64> {
180        parse_sql_server_lsn_str(lsn).map(|v| v.min(i64::MAX as u128) as i64)
181    }
182
183    fn pg_cdc_lsn_metric_labels(&self, slot_name: &str) -> Vec<String> {
184        vec![self.source_id.to_string(), slot_name.to_owned()]
185    }
186
187    async fn monitor_postgres_confirmed_flush_lsn(&mut self) -> ConnectorResult<()> {
188        // Query upstream LSNs and update metrics.
189        let lsns = self.query_postgres_lsns().await.with_context(|| {
190            format!(
191                "failed to query PostgreSQL LSNs for source {}",
192                self.source_id
193            )
194        })?;
195        match lsns {
196            Some((confirmed_flush_lsn, upstream_max_lsn, slot_name)) => {
197                let labels = self.pg_cdc_lsn_metric_labels(&slot_name);
198
199                get_or_create_guarded_int_gauge(
200                    &mut self.pg_cdc_upstream_max_lsn,
201                    &self.metrics.pg_cdc_upstream_max_lsn,
202                    &labels,
203                )
204                .set(upstream_max_lsn as i64);
205
206                if let Some(lsn) = confirmed_flush_lsn {
207                    get_or_create_guarded_int_gauge(
208                        &mut self.pg_cdc_confirmed_flush_lsn,
209                        &self.metrics.pg_cdc_confirmed_flush_lsn,
210                        &labels,
211                    )
212                    .set(lsn as i64);
213                    tracing::debug!(
214                        "Updated confirmed_flush_lsn for source {} slot {}: {}",
215                        self.source_id,
216                        slot_name,
217                        lsn
218                    );
219                } else {
220                    tracing::warn!(
221                        "confirmed_flush_lsn is NULL for source {} slot {}",
222                        self.source_id,
223                        slot_name
224                    );
225                }
226            }
227            None => {
228                tracing::warn!(
229                    "no replication slot was found while querying LSNs for source {}",
230                    self.source_id
231                );
232            }
233        };
234        Ok(())
235    }
236
237    /// Query LSNs from PostgreSQL, return (`confirmed_flush_lsn`, `upstream_max_lsn`, `slot_name`).
238    async fn query_postgres_lsns(&self) -> ConnectorResult<Option<(Option<u64>, u64, String)>> {
239        let pg_conn = pg_connection_config_from_properties(&self.properties)?;
240
241        let slot_name = self
242            .properties
243            .get("slot.name")
244            .ok_or_else(|| anyhow::anyhow!("missing `slot.name` in CDC properties"))?;
245
246        // No TCP keepalive for CDC enumerator
247        let application_name = format!("risingwave-postgres-source-enumerator-{}", self.source_id);
248        let client = create_pg_client(&pg_conn, None, Some(&application_name))
249            .await
250            .context("failed to create the PostgreSQL client")?;
251
252        let query = "SELECT confirmed_flush_lsn, pg_current_wal_lsn() \
253            FROM pg_replication_slots WHERE slot_name = $1";
254        let row = client
255            .query_opt(query, &[&slot_name])
256            .await
257            .context("failed to query PostgreSQL LSNs")?;
258        match row {
259            Some(row) => {
260                let confirmed_flush_lsn: Option<PgLsn> = row.get(0);
261                let upstream_max_lsn: PgLsn = row.get(1);
262                Ok(Some((
263                    confirmed_flush_lsn.map(Into::into),
264                    upstream_max_lsn.into(),
265                    slot_name.clone(),
266                )))
267            }
268            None => {
269                tracing::warn!("no replication slot found with name: {}", slot_name);
270                Ok(None)
271            }
272        }
273    }
274
275    /// Query min/max LSNs from SQL Server CDC.
276    async fn query_sql_server_lsns(&self) -> ConnectorResult<Option<(String, String)>> {
277        let hostname = self
278            .properties
279            .get("hostname")
280            .ok_or_else(|| anyhow!("missing `hostname` in CDC properties"))?;
281        let port = self
282            .properties
283            .get("port")
284            .ok_or_else(|| anyhow!("missing `port` in CDC properties"))?
285            .parse::<u16>()
286            .context("failed to parse `port` as a u16")?;
287        let username = self
288            .properties
289            .get("username")
290            .ok_or_else(|| anyhow!("missing `username` in CDC properties"))?;
291        let password = self
292            .properties
293            .get("password")
294            .ok_or_else(|| anyhow!("missing `password` in CDC properties"))?;
295        let database = self
296            .properties
297            .get("database.name")
298            .ok_or_else(|| anyhow!("missing `database.name` in CDC properties"))?;
299
300        let mut config = Config::new();
301        config.host(hostname);
302        config.port(port);
303        config.database(database);
304        config.authentication(tiberius::AuthMethod::sql_server(username, password));
305        config.trust_cert();
306
307        let mut client = SqlServerClient::new_with_config(config).await?;
308        let row = client
309            .inner_client
310            .simple_query(
311                "SELECT \
312                    sys.fn_cdc_get_max_lsn() AS max_lsn, \
313                    (SELECT MIN(sys.fn_cdc_get_min_lsn(capture_instance)) FROM cdc.change_tables) AS min_lsn"
314                    .to_owned(),
315            )
316            .await?
317            .into_row()
318            .await?
319            .ok_or_else(|| anyhow!("No result returned when querying SQL Server max/min LSN"))?;
320
321        let lsn_bytes_to_hex = |bytes: &[u8]| -> ConnectorResult<String> {
322            if bytes.len() != 10 {
323                return Err(anyhow!(
324                    "SQL Server LSN should be 10 bytes, got {} bytes",
325                    bytes.len()
326                )
327                .into());
328            }
329            let mut hex_string = String::with_capacity(22);
330            for byte in &bytes[0..4] {
331                hex_string.push_str(&format!("{:02x}", byte));
332            }
333            hex_string.push(':');
334            for byte in &bytes[4..8] {
335                hex_string.push_str(&format!("{:02x}", byte));
336            }
337            hex_string.push(':');
338            for byte in &bytes[8..10] {
339                hex_string.push_str(&format!("{:02x}", byte));
340            }
341            Ok(hex_string)
342        };
343
344        let max_lsn = row
345            .try_get::<&[u8], usize>(0)?
346            .map(lsn_bytes_to_hex)
347            .transpose()?
348            .ok_or_else(|| anyhow!("SQL Server max_lsn is NULL"))?;
349        let min_lsn = row
350            .try_get::<&[u8], usize>(1)?
351            .map(lsn_bytes_to_hex)
352            .transpose()?
353            .ok_or_else(|| anyhow!("SQL Server min_lsn is NULL"))?;
354
355        Ok(Some((min_lsn, max_lsn)))
356    }
357
358    async fn monitor_sql_server_lsns(&mut self) -> ConnectorResult<()> {
359        let lsns = self.query_sql_server_lsns().await.with_context(|| {
360            format!(
361                "failed to query SQL Server LSNs for source {}",
362                self.source_id
363            )
364        })?;
365        if let Some((min_lsn, max_lsn)) = lsns {
366            let labels = vec![self.source_id.to_string()];
367
368            if let Some(value) = Self::sql_server_lsn_to_i64(&min_lsn) {
369                get_or_create_guarded_int_gauge(
370                    &mut self.sqlserver_cdc_upstream_min_lsn,
371                    &self.metrics.sqlserver_cdc_upstream_min_lsn,
372                    &labels,
373                )
374                .set(value);
375            }
376            if let Some(value) = Self::sql_server_lsn_to_i64(&max_lsn) {
377                get_or_create_guarded_int_gauge(
378                    &mut self.sqlserver_cdc_upstream_max_lsn,
379                    &self.metrics.sqlserver_cdc_upstream_max_lsn,
380                    &labels,
381                )
382                .set(value);
383            }
384        }
385
386        Ok(())
387    }
388}
389
390pub trait ListCdcSplits {
391    type CdcSourceType: CdcSourceTypeTrait;
392    /// Generates a single split for shared source.
393    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>>;
394}
395
396/// Trait for CDC-specific monitoring behavior
397#[async_trait]
398pub trait CdcMonitor {
399    async fn monitor_cdc(&mut self) -> ConnectorResult<()>;
400}
401
402#[async_trait]
403impl<T: CdcSourceTypeTrait> CdcMonitor for DebeziumSplitEnumerator<T> {
404    default async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
405        Ok(())
406    }
407}
408
409impl DebeziumSplitEnumerator<Mysql> {
410    async fn monitor_mysql_binlog_files(&mut self) -> ConnectorResult<()> {
411        // Get hostname and port for metrics labels
412        let hostname = self
413            .properties
414            .get("hostname")
415            .map(|s| s.as_str())
416            .ok_or_else(|| {
417                anyhow::anyhow!("missing required property 'hostname' for MySQL CDC source")
418            })?;
419        let port = self
420            .properties
421            .get("port")
422            .map(|s| s.as_str())
423            .ok_or_else(|| {
424                anyhow::anyhow!("missing required property 'port' for MySQL CDC source")
425            })?;
426
427        // Query binlog files and update metrics
428        let binlog_files = self.query_binlog_files().await.with_context(|| {
429            format!(
430                "failed to query binlog files for MySQL CDC source {} ({}:{})",
431                self.source_id, hostname, port
432            )
433        })?;
434        if let Some((oldest_file, oldest_size)) = binlog_files.first()
435            && let Some(seq) = extract_binlog_file_seq(oldest_file)
436        {
437            let labels = vec![
438                self.source_id.to_string(),
439                hostname.to_owned(),
440                port.to_owned(),
441            ];
442            get_or_create_guarded_int_gauge(
443                &mut self.mysql_cdc_binlog_file_seq_min,
444                &self.metrics.mysql_cdc_binlog_file_seq_min,
445                &labels,
446            )
447            .set(seq as i64);
448            tracing::debug!(
449                "MySQL CDC source {} ({}:{}): oldest binlog = {}, seq = {}, size = {}",
450                self.source_id,
451                hostname,
452                port,
453                oldest_file,
454                seq,
455                oldest_size
456            );
457        }
458        if let Some((newest_file, newest_size)) = binlog_files.last()
459            && let Some(seq) = extract_binlog_file_seq(newest_file)
460        {
461            let labels = vec![
462                self.source_id.to_string(),
463                hostname.to_owned(),
464                port.to_owned(),
465            ];
466            get_or_create_guarded_int_gauge(
467                &mut self.mysql_cdc_binlog_file_seq_max,
468                &self.metrics.mysql_cdc_binlog_file_seq_max,
469                &labels,
470            )
471            .set(seq as i64);
472            tracing::debug!(
473                "MySQL CDC source {} ({}:{}): newest binlog = {}, seq = {}, size = {}",
474                self.source_id,
475                hostname,
476                port,
477                newest_file,
478                seq,
479                newest_size
480            );
481        }
482        tracing::debug!(
483            "MySQL CDC source {} ({}:{}): total {} binlog files",
484            self.source_id,
485            hostname,
486            port,
487            binlog_files.len()
488        );
489        Ok(())
490    }
491
492    /// Query binlog files from MySQL, returns Vec<(filename, size)>
493    async fn query_binlog_files(&self) -> ConnectorResult<Vec<(String, u64)>> {
494        // Extract connection parameters from CDC properties
495        let hostname = self
496            .properties
497            .get("hostname")
498            .ok_or_else(|| anyhow::anyhow!("missing `hostname` in CDC properties"))?;
499        let port = self
500            .properties
501            .get("port")
502            .ok_or_else(|| anyhow::anyhow!("missing `port` in CDC properties"))?
503            .parse::<u16>()
504            .context("failed to parse `port` as a u16")?;
505        let username = self
506            .properties
507            .get("username")
508            .ok_or_else(|| anyhow::anyhow!("missing `username` in CDC properties"))?;
509        let password = self
510            .properties
511            .get("password")
512            .ok_or_else(|| anyhow::anyhow!("missing `password` in CDC properties"))?;
513        let database = self
514            .properties
515            .get("database.name")
516            .ok_or_else(|| anyhow::anyhow!("missing `database.name` in CDC properties"))?;
517
518        // Get SSL mode configuration (default to Disabled if not specified)
519        let ssl_mode = self
520            .properties
521            .get("ssl.mode")
522            .and_then(|s| s.parse().ok())
523            .unwrap_or(SslMode::Preferred);
524
525        // Build MySQL connection pool with proper SSL configuration
526        let pool =
527            build_mysql_connection_pool(hostname, port, username, password, database, ssl_mode);
528        let mut conn = pool
529            .get_conn()
530            .await
531            .context("failed to connect to MySQL")?;
532
533        // Query binlog files using SHOW BINARY LOGS.
534        // MySQL 8.0+ may return 3 columns (Log_name, File_size, Encrypted), while some variants
535        // only return the first 2. Decode the row manually so we don't panic on column-count
536        // differences.
537        let rows: Vec<Row> = conn
538            .query("SHOW BINARY LOGS")
539            .await
540            .context("failed to execute `SHOW BINARY LOGS`")?;
541        let query_result = rows
542            .into_iter()
543            .map(|mut row| -> ConnectorResult<(String, u64)> {
544                let log_name = row
545                    .take_opt::<String, _>(0)
546                    .transpose()
547                    .context("`SHOW BINARY LOGS`: failed to decode `Log_name`")?
548                    .ok_or_else(|| anyhow!("`SHOW BINARY LOGS`: missing `Log_name` column"))?;
549                let file_size = row
550                    .take_opt::<u64, _>(1)
551                    .transpose()
552                    .context("`SHOW BINARY LOGS`: failed to decode `File_size`")?
553                    .ok_or_else(|| anyhow!("`SHOW BINARY LOGS`: missing `File_size` column"))?;
554                Ok((log_name, file_size))
555            })
556            .collect::<ConnectorResult<Vec<_>>>()?;
557
558        drop(conn);
559        pool.disconnect().await.ok();
560
561        Ok(query_result)
562    }
563}
564
565impl ListCdcSplits for DebeziumSplitEnumerator<Mysql> {
566    type CdcSourceType = Mysql;
567
568    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
569        // CDC source only supports single split
570        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
571            self.source_id.as_raw_id(),
572            None,
573            None,
574        )]
575    }
576}
577
578#[async_trait]
579impl CdcMonitor for DebeziumSplitEnumerator<Mysql> {
580    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
581        // For MySQL CDC, query the upstream MySQL binlog files and monitor them.
582        self.monitor_mysql_binlog_files().await?;
583        Ok(())
584    }
585}
586
587impl ListCdcSplits for DebeziumSplitEnumerator<Postgres> {
588    type CdcSourceType = Postgres;
589
590    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
591        // CDC source only supports single split
592        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
593            self.source_id.as_raw_id(),
594            None,
595            None,
596        )]
597    }
598}
599
600#[async_trait]
601impl CdcMonitor for DebeziumSplitEnumerator<Postgres> {
602    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
603        // For PostgreSQL CDC, query the upstream Postgres confirmed flush lsn and monitor it.
604        self.monitor_postgres_confirmed_flush_lsn().await?;
605        Ok(())
606    }
607}
608
609impl ListCdcSplits for DebeziumSplitEnumerator<Citus> {
610    type CdcSourceType = Citus;
611
612    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
613        self.worker_node_addrs
614            .iter()
615            .enumerate()
616            .map(|(id, addr)| {
617                DebeziumCdcSplit::<Self::CdcSourceType>::new(
618                    id as u32,
619                    None,
620                    Some(addr.to_string()),
621                )
622            })
623            .collect_vec()
624    }
625}
626impl ListCdcSplits for DebeziumSplitEnumerator<Mongodb> {
627    type CdcSourceType = Mongodb;
628
629    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
630        // CDC source only supports single split
631        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
632            self.source_id.as_raw_id(),
633            None,
634            None,
635        )]
636    }
637}
638
639impl ListCdcSplits for DebeziumSplitEnumerator<SqlServer> {
640    type CdcSourceType = SqlServer;
641
642    fn list_cdc_splits(&mut self) -> Vec<DebeziumCdcSplit<Self::CdcSourceType>> {
643        vec![DebeziumCdcSplit::<Self::CdcSourceType>::new(
644            self.source_id.as_raw_id(),
645            None,
646            None,
647        )]
648    }
649}
650
651#[async_trait]
652impl CdcMonitor for DebeziumSplitEnumerator<SqlServer> {
653    async fn monitor_cdc(&mut self) -> ConnectorResult<()> {
654        self.monitor_sql_server_lsns().await
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use prometheus::core::Collector;
661    use risingwave_common::metrics::LabelGuardedIntGaugeVec;
662
663    use super::get_or_create_guarded_int_gauge;
664
665    #[test]
666    fn cached_guarded_metric_survives_repeated_collections() {
667        let metric_vec = LabelGuardedIntGaugeVec::test_int_gauge_vec::<2>();
668        let labels = vec!["source_id".to_owned(), "slot_name".to_owned()];
669        let mut metric = None;
670
671        get_or_create_guarded_int_gauge(&mut metric, &metric_vec, &labels).set(1);
672
673        assert_eq!(1, metric_vec.collect().pop().unwrap().get_metric().len());
674        assert_eq!(1, metric_vec.collect().pop().unwrap().get_metric().len());
675
676        drop(metric);
677        assert_eq!(1, metric_vec.collect().pop().unwrap().get_metric().len());
678        assert_eq!(0, metric_vec.collect().pop().unwrap().get_metric().len());
679    }
680}