Skip to main content

risingwave_connector/sink/
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
15feature_gated_sink_mod!(big_query, "bigquery");
16pub mod boxed;
17pub mod catalog;
18feature_gated_sink_mod!(clickhouse, ClickHouse, "clickhouse");
19pub mod coordinate;
20pub mod decouple_checkpoint_log_sink;
21feature_gated_sink_mod!(deltalake, DeltaLake, "deltalake");
22feature_gated_sink_mod!(doris, "doris");
23#[cfg(any(feature = "sink-doris", feature = "sink-starrocks"))]
24pub mod doris_starrocks_connector;
25pub mod dynamodb;
26pub mod elasticsearch_opensearch;
27pub mod encoder;
28pub mod file_sink;
29pub mod formatter;
30feature_gated_sink_mod!(google_pubsub, GooglePubSub, "google_pubsub");
31pub mod http;
32pub mod iceberg;
33pub mod kafka;
34pub mod kinesis;
35use risingwave_common::bail;
36use risingwave_pb::stream_plan::PbSinkSchemaChange;
37pub mod jdbc_jni_client;
38pub mod log_store;
39pub mod mock_coordination_client;
40pub mod mongodb;
41pub mod mqtt;
42pub mod nats;
43pub mod postgres;
44pub mod pulsar;
45pub mod redis;
46pub mod remote;
47pub mod snowflake_redshift;
48pub mod sqlserver;
49feature_gated_sink_mod!(starrocks, "starrocks");
50pub mod test_sink;
51pub mod trivial;
52pub mod turbopuffer;
53pub mod utils;
54pub mod writer;
55pub mod prelude {
56    pub use crate::sink::{
57        Result, SINK_TYPE_APPEND_ONLY, SINK_USER_FORCE_APPEND_ONLY_OPTION,
58        SINK_USER_FORCE_COMPACTION, Sink, SinkError, SinkParam, SinkWriterParam,
59    };
60}
61
62use std::collections::{BTreeMap, HashMap};
63use std::future::Future;
64use std::sync::{Arc, LazyLock};
65
66use ::redis::RedisError;
67use anyhow::anyhow;
68use async_trait::async_trait;
69use chrono_tz::{Tz, UTC};
70use decouple_checkpoint_log_sink::{
71    COMMIT_CHECKPOINT_INTERVAL, DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITH_SINK_DECOUPLE,
72    DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITHOUT_SINK_DECOUPLE,
73};
74use futures::future::BoxFuture;
75use opendal::Error as OpendalError;
76use prometheus::Registry;
77use risingwave_common::array::ArrayError;
78use risingwave_common::bitmap::Bitmap;
79use risingwave_common::catalog::{ColumnDesc, Field, Schema};
80use risingwave_common::config::StreamingConfig;
81use risingwave_common::hash::ActorId;
82use risingwave_common::metrics::{
83    LabelGuardedHistogram, LabelGuardedHistogramVec, LabelGuardedIntCounter,
84    LabelGuardedIntCounterVec, LabelGuardedIntGaugeVec,
85};
86use risingwave_common::monitor::GLOBAL_METRICS_REGISTRY;
87use risingwave_common::secret::{LocalSecretManager, SecretError};
88use risingwave_common::session_config::sink_decouple::SinkDecouple;
89use risingwave_common::{
90    register_guarded_histogram_vec_with_registry, register_guarded_int_counter_vec_with_registry,
91    register_guarded_int_gauge_vec_with_registry,
92};
93use risingwave_pb::catalog::PbSinkType;
94use risingwave_pb::connector_service::{PbSinkParam, SinkMetadata, TableSchema};
95use risingwave_pb::id::ExecutorId;
96use risingwave_rpc_client::MetaClient;
97use risingwave_rpc_client::error::RpcError;
98use starrocks::STARROCKS_SINK;
99use thiserror::Error;
100use thiserror_ext::AsReport;
101use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
102pub use tracing;
103
104use self::catalog::{SinkFormatDesc, SinkType};
105use self::clickhouse::CLICKHOUSE_SINK;
106use self::deltalake::DELTALAKE_SINK;
107use self::iceberg::ICEBERG_SINK;
108use self::mock_coordination_client::{MockMetaClient, SinkCoordinationRpcClientEnum};
109use crate::WithPropertiesExt;
110use crate::connector_common::IcebergSinkCompactionUpdate;
111use crate::error::{ConnectorError, ConnectorResult};
112use crate::sink::boxed::{BoxSinglePhaseCoordinator, BoxTwoPhaseCoordinator};
113use crate::sink::catalog::desc::SinkDesc;
114use crate::sink::catalog::{SinkCatalog, SinkId};
115use crate::sink::decouple_checkpoint_log_sink::ICEBERG_DEFAULT_COMMIT_CHECKPOINT_INTERVAL;
116use crate::sink::file_sink::fs::FsSink;
117use crate::sink::log_store::{LogReader, LogStoreReadItem, LogStoreResult, TruncateOffset};
118use crate::sink::snowflake_redshift::snowflake::SNOWFLAKE_SINK_V2;
119use crate::sink::utils::feature_gated_sink_mod;
120
121const BOUNDED_CHANNEL_SIZE: usize = 16;
122#[macro_export]
123macro_rules! for_all_sinks {
124    ($macro:path $(, $arg:tt)*) => {
125        $macro! {
126            {
127                { Redis, $crate::sink::redis::RedisSink, $crate::sink::redis::RedisConfig },
128                { Kafka, $crate::sink::kafka::KafkaSink, $crate::sink::kafka::KafkaConfig },
129                { Pulsar, $crate::sink::pulsar::PulsarSink, $crate::sink::pulsar::PulsarConfig },
130                { BlackHole, $crate::sink::trivial::BlackHoleSink, () },
131                { Http, $crate::sink::http::HttpSink, $crate::sink::http::HttpConfig },
132                { Turbopuffer, $crate::sink::turbopuffer::TurbopufferSink, $crate::sink::turbopuffer::TurbopufferConfig },
133                { Kinesis, $crate::sink::kinesis::KinesisSink, $crate::sink::kinesis::KinesisSinkConfig },
134                { ClickHouse, $crate::sink::clickhouse::ClickHouseSink, $crate::sink::clickhouse::ClickHouseConfig },
135                { Iceberg, $crate::sink::iceberg::IcebergSink, $crate::sink::iceberg::IcebergConfig },
136                { Mqtt, $crate::sink::mqtt::MqttSink, $crate::sink::mqtt::MqttConfig },
137                { GooglePubSub, $crate::sink::google_pubsub::GooglePubSubSink, $crate::sink::google_pubsub::GooglePubSubConfig },
138                { Nats, $crate::sink::nats::NatsSink, $crate::sink::nats::NatsConfig },
139                { Jdbc, $crate::sink::remote::JdbcSink, () },
140                { ElasticSearch, $crate::sink::elasticsearch_opensearch::elasticsearch::ElasticSearchSink, $crate::sink::elasticsearch_opensearch::elasticsearch_opensearch_config::ElasticSearchConfig },
141                { Opensearch, $crate::sink::elasticsearch_opensearch::opensearch::OpenSearchSink, $crate::sink::elasticsearch_opensearch::elasticsearch_opensearch_config::OpenSearchConfig },
142                { Cassandra, $crate::sink::remote::CassandraSink, () },
143                { Doris, $crate::sink::doris::DorisSink, $crate::sink::doris::DorisConfig },
144                { Starrocks, $crate::sink::starrocks::StarrocksSink, $crate::sink::starrocks::StarrocksConfig },
145                { S3, $crate::sink::file_sink::opendal_sink::FileSink<$crate::sink::file_sink::s3::S3Sink>, $crate::sink::file_sink::s3::S3Config },
146
147                { Gcs, $crate::sink::file_sink::opendal_sink::FileSink<$crate::sink::file_sink::gcs::GcsSink>, $crate::sink::file_sink::gcs::GcsConfig },
148                { Azblob, $crate::sink::file_sink::opendal_sink::FileSink<$crate::sink::file_sink::azblob::AzblobSink>, $crate::sink::file_sink::azblob::AzblobConfig },
149                { Webhdfs, $crate::sink::file_sink::opendal_sink::FileSink<$crate::sink::file_sink::webhdfs::WebhdfsSink>, $crate::sink::file_sink::webhdfs::WebhdfsConfig },
150
151                { Fs, $crate::sink::file_sink::opendal_sink::FileSink<FsSink>, $crate::sink::file_sink::fs::FsConfig },
152                { SnowflakeV2, $crate::sink::snowflake_redshift::snowflake::SnowflakeV2Sink, $crate::sink::snowflake_redshift::snowflake::SnowflakeV2Config },
153                { Snowflake, $crate::sink::file_sink::opendal_sink::FileSink<$crate::sink::file_sink::s3::SnowflakeSink>, $crate::sink::file_sink::s3::SnowflakeConfig },
154                { RedShift, $crate::sink::snowflake_redshift::redshift::RedshiftSink, $crate::sink::snowflake_redshift::redshift::RedShiftConfig },
155                { DeltaLake, $crate::sink::deltalake::DeltaLakeSink, $crate::sink::deltalake::DeltaLakeConfig },
156                { BigQuery, $crate::sink::big_query::BigQuerySink, $crate::sink::big_query::BigQueryConfig },
157                { DynamoDb, $crate::sink::dynamodb::DynamoDbSink, $crate::sink::dynamodb::DynamoDbConfig },
158                { Mongodb, $crate::sink::mongodb::MongodbSink, $crate::sink::mongodb::MongodbConfig },
159                { SqlServer, $crate::sink::sqlserver::SqlServerSink, $crate::sink::sqlserver::SqlServerConfig },
160                { Postgres, $crate::sink::postgres::PostgresSink, $crate::sink::postgres::PostgresConfig },
161
162                { Test, $crate::sink::test_sink::TestSink, () },
163                { Table, $crate::sink::trivial::TableSink, () }
164            }
165            $(,$arg)*
166        }
167    };
168}
169
170#[macro_export]
171macro_rules! generate_config_use_clauses {
172    ({$({ $variant_name:ident, $sink_type:ty, $($config_type:tt)+ }), *}) => {
173        $(
174            $crate::generate_config_use_single! { $($config_type)+ }
175        )*
176    };
177}
178
179#[macro_export]
180macro_rules! generate_config_use_single {
181    // Skip () config types
182    (()) => {};
183
184    // Generate use clause for actual config types
185    ($config_type:path) => {
186        #[allow(unused_imports)]
187        pub(super) use $config_type;
188    };
189}
190
191// Convenience macro that uses for_all_sinks
192#[macro_export]
193macro_rules! use_all_sink_configs {
194    () => {
195        $crate::for_all_sinks! { $crate::generate_config_use_clauses }
196    };
197}
198
199#[macro_export]
200macro_rules! dispatch_sink {
201    ({$({$variant_name:ident, $sink_type:ty, $config_type:ty}),*}, $impl:tt, $sink:tt, $body:tt) => {{
202        use $crate::sink::SinkImpl;
203
204        match $impl {
205            $(
206                SinkImpl::$variant_name($sink) => $body,
207            )*
208        }
209    }};
210    ($impl:expr, $sink:ident, $body:expr) => {{
211        $crate::for_all_sinks! {$crate::dispatch_sink, {$impl}, $sink, {$body}}
212    }};
213}
214
215#[macro_export]
216macro_rules! match_sink_name_str {
217    ({$({$variant_name:ident, $sink_type:ty, $config_type:ty}),*}, $name_str:tt, $type_name:ident, $body:tt, $on_other_closure:tt) => {{
218        use $crate::sink::Sink;
219        match $name_str {
220            $(
221                <$sink_type>::SINK_NAME => {
222                    type $type_name = $sink_type;
223                    {
224                        $body
225                    }
226                },
227            )*
228            other => ($on_other_closure)(other),
229        }
230    }};
231    ($name_str:expr, $type_name:ident, $body:expr, $on_other_closure:expr) => {{
232        $crate::for_all_sinks! {$crate::match_sink_name_str, {$name_str}, $type_name, {$body}, {$on_other_closure}}
233    }};
234}
235
236pub const CONNECTOR_TYPE_KEY: &str = "connector";
237pub const SINK_TYPE_OPTION: &str = "type";
238/// `snapshot = false` corresponds to [`risingwave_pb::stream_plan::StreamScanType::UpstreamOnly`]
239pub const SINK_SNAPSHOT_OPTION: &str = "snapshot";
240pub const SINK_TYPE_APPEND_ONLY: &str = "append-only";
241pub const SINK_TYPE_DEBEZIUM: &str = "debezium";
242pub const SINK_TYPE_UPSERT: &str = "upsert";
243pub const SINK_TYPE_RETRACT: &str = "retract";
244/// Whether to drop DELETE and convert UPDATE to INSERT in the sink executor.
245pub const SINK_USER_IGNORE_DELETE_OPTION: &str = "ignore_delete";
246/// Alias for [`SINK_USER_IGNORE_DELETE_OPTION`], kept for backward compatibility.
247pub const SINK_USER_FORCE_APPEND_ONLY_OPTION: &str = "force_append_only";
248pub const SINK_USER_FORCE_COMPACTION: &str = "force_compaction";
249/// When enabled, the sink executor preserves distinct upstream stream-key changes that map to the
250/// same downstream primary key instead of compacting them into one final-state update within a
251/// barrier. Upstream changes under the same stream key may still be compacted earlier.
252pub const SINK_USER_PRESERVE_ROW_LEVEL_CHANGES: &str = "preserve_row_level_changes";
253
254pub trait UnknownFields {
255    /// Unrecognized fields in the `WITH` clause.
256    fn unknown_fields(&self) -> HashMap<String, String>;
257}
258
259impl UnknownFields for () {
260    fn unknown_fields(&self) -> HashMap<String, String> {
261        HashMap::new()
262    }
263}
264
265impl UnknownFields for HashMap<String, String> {
266    fn unknown_fields(&self) -> HashMap<String, String> {
267        self.clone()
268    }
269}
270
271#[macro_export]
272macro_rules! impl_sink_unknown_fields {
273    ($config_type:ty) => {
274        impl $crate::sink::UnknownFields for $config_type {
275            fn unknown_fields(&self) -> std::collections::HashMap<String, String> {
276                self.unknown_fields.clone()
277            }
278        }
279    };
280}
281
282#[macro_export]
283macro_rules! impl_validate_sink_unknown_fields {
284    () => {
285        fn validate_unknown_fields(&self) -> $crate::sink::Result<()> {
286            $crate::sink::validate_sink_unknown_fields(&self.config)
287        }
288    };
289}
290
291pub fn validate_sink_unknown_fields(config: &impl UnknownFields) -> Result<()> {
292    let mut unknown_fields = config.unknown_fields();
293    // These options are consumed by the sink DDL/planner layer. They are valid for sink creation
294    // even if a connector-specific config does not declare them.
295    for key in [
296        CONNECTOR_TYPE_KEY,
297        SINK_TYPE_OPTION,
298        SINK_SNAPSHOT_OPTION,
299        SINK_USER_IGNORE_DELETE_OPTION,
300        SINK_USER_FORCE_APPEND_ONLY_OPTION,
301        SINK_USER_FORCE_COMPACTION,
302        SINK_USER_PRESERVE_ROW_LEVEL_CHANGES,
303        "backfill_rate_limit",
304        "primary_key",
305        "sink_rate_limit",
306    ] {
307        unknown_fields.remove(key);
308    }
309    if unknown_fields.is_empty() {
310        Ok(())
311    } else {
312        Err(SinkError::Config(anyhow!(
313            "Unknown fields in the WITH clause: {:?}",
314            unknown_fields
315        )))
316    }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct SinkParam {
321    pub sink_id: SinkId,
322    pub sink_name: String,
323    pub properties: BTreeMap<String, String>,
324    pub columns: Vec<ColumnDesc>,
325    /// User-defined primary key indices for upsert sink, if any.
326    pub downstream_pk: Option<Vec<usize>>,
327    pub sink_type: SinkType,
328    /// Whether to drop DELETE and convert UPDATE to INSERT in the sink executor.
329    pub ignore_delete: bool,
330    pub format_desc: Option<SinkFormatDesc>,
331    pub db_name: String,
332
333    /// - For `CREATE SINK ... FROM ...`, the name of the source table.
334    /// - For `CREATE SINK ... AS <query>`, the name of the sink itself.
335    ///
336    /// See also `gen_sink_plan`.
337    // TODO(eric): Why need these 2 fields (db_name and sink_from_name)?
338    pub sink_from_name: String,
339}
340
341impl SinkParam {
342    pub fn from_proto(pb_param: PbSinkParam) -> Self {
343        let ignore_delete = pb_param.ignore_delete();
344        let table_schema = pb_param.table_schema.expect("should contain table schema");
345        let format_desc = match pb_param.format_desc {
346            Some(f) => f.try_into().ok(),
347            None => {
348                let connector = pb_param.properties.get(CONNECTOR_TYPE_KEY);
349                let r#type = pb_param.properties.get(SINK_TYPE_OPTION);
350                match (connector, r#type) {
351                    (Some(c), Some(t)) => SinkFormatDesc::from_legacy_type(c, t).ok().flatten(),
352                    _ => None,
353                }
354            }
355        };
356        Self {
357            sink_id: SinkId::from(pb_param.sink_id),
358            sink_name: pb_param.sink_name,
359            properties: pb_param.properties,
360            columns: table_schema.columns.iter().map(ColumnDesc::from).collect(),
361            downstream_pk: if table_schema.pk_indices.is_empty() {
362                None
363            } else {
364                Some(
365                    (table_schema.pk_indices.iter())
366                        .map(|i| *i as usize)
367                        .collect(),
368                )
369            },
370            sink_type: SinkType::from_proto(
371                PbSinkType::try_from(pb_param.sink_type).expect("should be able to convert"),
372            ),
373            ignore_delete,
374            format_desc,
375            db_name: pb_param.db_name,
376            sink_from_name: pb_param.sink_from_name,
377        }
378    }
379
380    pub fn to_proto(&self) -> PbSinkParam {
381        PbSinkParam {
382            sink_id: self.sink_id,
383            sink_name: self.sink_name.clone(),
384            properties: self.properties.clone(),
385            table_schema: Some(TableSchema {
386                columns: self.columns.iter().map(|col| col.to_protobuf()).collect(),
387                pk_indices: (self.downstream_pk.as_ref())
388                    .map_or_else(Vec::new, |pk| pk.iter().map(|i| *i as u32).collect()),
389            }),
390            sink_type: self.sink_type.to_proto().into(),
391            format_desc: self.format_desc.as_ref().map(|f| f.to_proto()),
392            db_name: self.db_name.clone(),
393            sink_from_name: self.sink_from_name.clone(),
394            raw_ignore_delete: self.ignore_delete,
395        }
396    }
397
398    pub fn schema(&self) -> Schema {
399        Schema {
400            fields: self.columns.iter().map(Field::from).collect(),
401        }
402    }
403
404    /// Get the downstream primary key indices specified by the user. If not specified, return
405    /// an empty vector.
406    ///
407    /// Prefer directly accessing the `downstream_pk` field, as it uses `None` to represent
408    /// unspecified values, making it clearer.
409    pub fn downstream_pk_or_empty(&self) -> Vec<usize> {
410        self.downstream_pk.clone().unwrap_or_default()
411    }
412
413    // `SinkParams` should only be used when there is a secret context.
414    // FIXME: Use a new type for `SinkFormatDesc` with properties contain filled secrets.
415    pub fn fill_secret_for_format_desc(
416        format_desc: Option<SinkFormatDesc>,
417    ) -> Result<Option<SinkFormatDesc>> {
418        match format_desc {
419            Some(mut format_desc) => {
420                format_desc.options = LocalSecretManager::global()
421                    .fill_secrets(format_desc.options, format_desc.secret_refs.clone())?;
422                Ok(Some(format_desc))
423            }
424            None => Ok(None),
425        }
426    }
427
428    /// Try to convert a `SinkCatalog` to a `SinkParam` and fill the secrets to properties.
429    pub fn try_from_sink_catalog(sink_catalog: SinkCatalog) -> Result<Self> {
430        let columns = sink_catalog
431            .visible_columns()
432            .map(|col| col.column_desc.clone())
433            .collect();
434        let properties_with_secret = LocalSecretManager::global()
435            .fill_secrets(sink_catalog.properties, sink_catalog.secret_refs)?;
436        let format_desc_with_secret = Self::fill_secret_for_format_desc(sink_catalog.format_desc)?;
437        Ok(Self {
438            sink_id: sink_catalog.id,
439            sink_name: sink_catalog.name,
440            properties: properties_with_secret,
441            columns,
442            downstream_pk: sink_catalog.downstream_pk,
443            sink_type: sink_catalog.sink_type,
444            ignore_delete: sink_catalog.ignore_delete,
445            format_desc: format_desc_with_secret,
446            db_name: sink_catalog.db_name,
447            sink_from_name: sink_catalog.sink_from_name,
448        })
449    }
450}
451
452pub fn enforce_secret_sink(props: &impl WithPropertiesExt) -> ConnectorResult<()> {
453    use crate::enforce_secret::EnforceSecret;
454
455    let connector = props
456        .get_connector()
457        .ok_or_else(|| anyhow!("Must specify 'connector' in WITH clause"))?;
458    let key_iter = props.key_iter();
459    match_sink_name_str!(
460        connector.as_str(),
461        PropType,
462        PropType::enforce_secret(key_iter),
463        |other| bail!("connector '{}' is not supported", other)
464    )
465}
466
467pub static GLOBAL_SINK_METRICS: LazyLock<SinkMetrics> =
468    LazyLock::new(|| SinkMetrics::new(&GLOBAL_METRICS_REGISTRY));
469
470#[derive(Clone)]
471pub struct SinkMetrics {
472    pub sink_commit_duration: LabelGuardedHistogramVec,
473    pub connector_sink_rows_received: LabelGuardedIntCounterVec,
474
475    // Log store writer metrics
476    pub log_store_first_write_epoch: LabelGuardedIntGaugeVec,
477    pub log_store_latest_write_epoch: LabelGuardedIntGaugeVec,
478    pub log_store_write_rows: LabelGuardedIntCounterVec,
479
480    // Log store reader metrics
481    pub log_store_latest_read_epoch: LabelGuardedIntGaugeVec,
482    pub log_store_read_rows: LabelGuardedIntCounterVec,
483    pub log_store_read_bytes: LabelGuardedIntCounterVec,
484    pub log_store_reader_wait_new_future_duration_ns: LabelGuardedIntCounterVec,
485
486    // Iceberg metrics
487    pub iceberg_write_qps: LabelGuardedIntCounterVec,
488    pub iceberg_write_latency: LabelGuardedHistogramVec,
489    pub iceberg_rolling_unflushed_data_file: LabelGuardedIntGaugeVec,
490    pub iceberg_position_delete_cache_num: LabelGuardedIntGaugeVec,
491    pub iceberg_partition_num: LabelGuardedIntGaugeVec,
492    pub iceberg_write_bytes: LabelGuardedIntCounterVec,
493    pub iceberg_snapshot_num: LabelGuardedIntGaugeVec,
494}
495
496impl SinkMetrics {
497    pub fn new(registry: &Registry) -> Self {
498        let sink_commit_duration = register_guarded_histogram_vec_with_registry!(
499            "sink_commit_duration",
500            "Duration of commit op in sink",
501            &["actor_id", "connector", "sink_id", "sink_name"],
502            registry
503        )
504        .unwrap();
505
506        let connector_sink_rows_received = register_guarded_int_counter_vec_with_registry!(
507            "connector_sink_rows_received",
508            "Number of rows received by sink",
509            &["actor_id", "connector_type", "sink_id", "sink_name"],
510            registry
511        )
512        .unwrap();
513
514        let log_store_first_write_epoch = register_guarded_int_gauge_vec_with_registry!(
515            "log_store_first_write_epoch",
516            "The first write epoch of log store",
517            &["actor_id", "sink_id", "sink_name"],
518            registry
519        )
520        .unwrap();
521
522        let log_store_latest_write_epoch = register_guarded_int_gauge_vec_with_registry!(
523            "log_store_latest_write_epoch",
524            "The latest write epoch of log store",
525            &["actor_id", "sink_id", "sink_name"],
526            registry
527        )
528        .unwrap();
529
530        let log_store_write_rows = register_guarded_int_counter_vec_with_registry!(
531            "log_store_write_rows",
532            "The write rate of rows",
533            &["actor_id", "sink_id", "sink_name"],
534            registry
535        )
536        .unwrap();
537
538        let log_store_latest_read_epoch = register_guarded_int_gauge_vec_with_registry!(
539            "log_store_latest_read_epoch",
540            "The latest read epoch of log store",
541            &["actor_id", "connector", "sink_id", "sink_name"],
542            registry
543        )
544        .unwrap();
545
546        let log_store_read_rows = register_guarded_int_counter_vec_with_registry!(
547            "log_store_read_rows",
548            "The read rate of rows",
549            &["actor_id", "connector", "sink_id", "sink_name"],
550            registry
551        )
552        .unwrap();
553
554        let log_store_read_bytes = register_guarded_int_counter_vec_with_registry!(
555            "log_store_read_bytes",
556            "Total size of chunks read by log reader",
557            &["actor_id", "connector", "sink_id", "sink_name"],
558            registry
559        )
560        .unwrap();
561
562        let log_store_reader_wait_new_future_duration_ns =
563            register_guarded_int_counter_vec_with_registry!(
564                "log_store_reader_wait_new_future_duration_ns",
565                "Accumulated duration of LogReader to wait for next call to create future",
566                &["actor_id", "connector", "sink_id", "sink_name"],
567                registry
568            )
569            .unwrap();
570
571        let iceberg_write_qps = register_guarded_int_counter_vec_with_registry!(
572            "iceberg_write_qps",
573            "The qps of iceberg writer",
574            &["actor_id", "sink_id", "sink_name"],
575            registry
576        )
577        .unwrap();
578
579        let iceberg_write_latency = register_guarded_histogram_vec_with_registry!(
580            "iceberg_write_latency",
581            "The latency of iceberg writer",
582            &["actor_id", "sink_id", "sink_name"],
583            registry
584        )
585        .unwrap();
586
587        let iceberg_rolling_unflushed_data_file = register_guarded_int_gauge_vec_with_registry!(
588            "iceberg_rolling_unflushed_data_file",
589            "The unflushed data file count of iceberg rolling writer",
590            &["actor_id", "sink_id", "sink_name"],
591            registry
592        )
593        .unwrap();
594
595        let iceberg_position_delete_cache_num = register_guarded_int_gauge_vec_with_registry!(
596            "iceberg_position_delete_cache_num",
597            "The delete cache num of iceberg position delete writer",
598            &["actor_id", "sink_id", "sink_name"],
599            registry
600        )
601        .unwrap();
602
603        let iceberg_partition_num = register_guarded_int_gauge_vec_with_registry!(
604            "iceberg_partition_num",
605            "The partition num of iceberg partition writer",
606            &["actor_id", "sink_id", "sink_name"],
607            registry
608        )
609        .unwrap();
610
611        let iceberg_write_bytes = register_guarded_int_counter_vec_with_registry!(
612            "iceberg_write_bytes",
613            "The write bytes of iceberg writer",
614            &["actor_id", "sink_id", "sink_name"],
615            registry
616        )
617        .unwrap();
618
619        let iceberg_snapshot_num = register_guarded_int_gauge_vec_with_registry!(
620            "iceberg_snapshot_num",
621            "The snapshot number of iceberg table",
622            &["sink_name", "catalog_name", "table_name"],
623            registry
624        )
625        .unwrap();
626
627        Self {
628            sink_commit_duration,
629            connector_sink_rows_received,
630            log_store_first_write_epoch,
631            log_store_latest_write_epoch,
632            log_store_write_rows,
633            log_store_latest_read_epoch,
634            log_store_read_rows,
635            log_store_read_bytes,
636            log_store_reader_wait_new_future_duration_ns,
637            iceberg_write_qps,
638            iceberg_write_latency,
639            iceberg_rolling_unflushed_data_file,
640            iceberg_position_delete_cache_num,
641            iceberg_partition_num,
642            iceberg_write_bytes,
643            iceberg_snapshot_num,
644        }
645    }
646}
647
648#[derive(Clone)]
649pub struct SinkWriterParam {
650    // TODO(eric): deprecate executor_id
651    pub executor_id: ExecutorId,
652    pub vnode_bitmap: Option<Bitmap>,
653    pub meta_client: Option<SinkMetaClient>,
654    // The val has two effect:
655    // 1. Indicates that the sink will accpect the data chunk with extra partition value column.
656    // 2. The index of the extra partition value column.
657    // More detail of partition value column, see `PartitionComputeInfo`
658    pub extra_partition_col_idx: Option<usize>,
659
660    pub actor_id: ActorId,
661    pub sink_id: SinkId,
662    pub sink_name: String,
663    pub connector: String,
664    pub streaming_config: StreamingConfig,
665    pub time_zone: Tz,
666}
667
668#[derive(Clone)]
669pub struct SinkWriterMetrics {
670    pub sink_commit_duration: LabelGuardedHistogram,
671    pub connector_sink_rows_received: LabelGuardedIntCounter,
672}
673
674impl SinkWriterMetrics {
675    pub fn new(writer_param: &SinkWriterParam) -> Self {
676        let labels = [
677            &writer_param.actor_id.to_string(),
678            writer_param.connector.as_str(),
679            &writer_param.sink_id.to_string(),
680            writer_param.sink_name.as_str(),
681        ];
682        let sink_commit_duration = GLOBAL_SINK_METRICS
683            .sink_commit_duration
684            .with_guarded_label_values(&labels);
685        let connector_sink_rows_received = GLOBAL_SINK_METRICS
686            .connector_sink_rows_received
687            .with_guarded_label_values(&labels);
688        Self {
689            sink_commit_duration,
690            connector_sink_rows_received,
691        }
692    }
693
694    #[cfg(test)]
695    pub fn for_test() -> Self {
696        Self {
697            sink_commit_duration: LabelGuardedHistogram::test_histogram::<4>(),
698            connector_sink_rows_received: LabelGuardedIntCounter::test_int_counter::<4>(),
699        }
700    }
701}
702
703#[derive(Clone)]
704pub enum SinkMetaClient {
705    MetaClient(MetaClient),
706    MockMetaClient(MockMetaClient),
707}
708
709impl SinkMetaClient {
710    pub async fn sink_coordinate_client(&self) -> SinkCoordinationRpcClientEnum {
711        match self {
712            SinkMetaClient::MetaClient(meta_client) => {
713                SinkCoordinationRpcClientEnum::SinkCoordinationRpcClient(
714                    meta_client.sink_coordinate_client().await,
715                )
716            }
717            SinkMetaClient::MockMetaClient(mock_meta_client) => {
718                SinkCoordinationRpcClientEnum::MockSinkCoordinationRpcClient(
719                    mock_meta_client.sink_coordinate_client(),
720                )
721            }
722        }
723    }
724
725    pub async fn add_sink_fail_evet_log(
726        &self,
727        sink_id: SinkId,
728        sink_name: String,
729        connector: String,
730        error: String,
731    ) {
732        match self {
733            SinkMetaClient::MetaClient(meta_client) => {
734                match meta_client
735                    .add_sink_fail_evet(sink_id, sink_name, connector, error)
736                    .await
737                {
738                    Ok(_) => {}
739                    Err(e) => {
740                        tracing::warn!(
741                            error = %e.as_report(),
742                            %sink_id,
743                            "failed to add the sink failure event to the event log",
744                        );
745                    }
746                }
747            }
748            SinkMetaClient::MockMetaClient(_) => {}
749        }
750    }
751}
752
753impl SinkWriterParam {
754    pub fn for_test() -> Self {
755        SinkWriterParam {
756            executor_id: Default::default(),
757            vnode_bitmap: Default::default(),
758            meta_client: Default::default(),
759            extra_partition_col_idx: Default::default(),
760
761            actor_id: 1.into(),
762            sink_id: SinkId::new(1),
763            sink_name: "test_sink".to_owned(),
764            connector: "test_connector".to_owned(),
765            streaming_config: StreamingConfig::default(),
766            time_zone: UTC,
767        }
768    }
769}
770
771fn is_sink_support_commit_checkpoint_interval(sink_name: &str) -> bool {
772    matches!(
773        sink_name,
774        ICEBERG_SINK | CLICKHOUSE_SINK | STARROCKS_SINK | DELTALAKE_SINK | SNOWFLAKE_SINK_V2
775    )
776}
777pub trait Sink: TryFrom<SinkParam, Error = SinkError> {
778    const SINK_NAME: &'static str;
779
780    type LogSinker: LogSinker;
781
782    fn set_default_commit_checkpoint_interval(
783        desc: &mut SinkDesc,
784        user_specified: &SinkDecouple,
785    ) -> Result<()> {
786        if is_sink_support_commit_checkpoint_interval(Self::SINK_NAME) {
787            match desc.properties.get(COMMIT_CHECKPOINT_INTERVAL) {
788                Some(commit_checkpoint_interval) => {
789                    let commit_checkpoint_interval = commit_checkpoint_interval
790                        .parse::<u64>()
791                        .map_err(|e| SinkError::Config(anyhow!(e)))?;
792                    if matches!(user_specified, SinkDecouple::Disable)
793                        && commit_checkpoint_interval > 1
794                    {
795                        return Err(SinkError::Config(anyhow!(
796                            "config conflict: `commit_checkpoint_interval` larger than 1 means that sink decouple must be enabled, but session config sink_decouple is disabled"
797                        )));
798                    }
799                }
800                None => match user_specified {
801                    SinkDecouple::Default | SinkDecouple::Enable => {
802                        if matches!(Self::SINK_NAME, ICEBERG_SINK) {
803                            desc.properties.insert(
804                                COMMIT_CHECKPOINT_INTERVAL.to_owned(),
805                                ICEBERG_DEFAULT_COMMIT_CHECKPOINT_INTERVAL.to_string(),
806                            );
807                        } else {
808                            desc.properties.insert(
809                                COMMIT_CHECKPOINT_INTERVAL.to_owned(),
810                                DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITH_SINK_DECOUPLE.to_string(),
811                            );
812                        }
813                    }
814                    SinkDecouple::Disable => {
815                        desc.properties.insert(
816                            COMMIT_CHECKPOINT_INTERVAL.to_owned(),
817                            DEFAULT_COMMIT_CHECKPOINT_INTERVAL_WITHOUT_SINK_DECOUPLE.to_string(),
818                        );
819                    }
820                },
821            }
822        }
823        Ok(())
824    }
825
826    /// `user_specified` is the value of `sink_decouple` config.
827    fn is_sink_decouple(user_specified: &SinkDecouple) -> Result<bool> {
828        match user_specified {
829            SinkDecouple::Default | SinkDecouple::Enable => Ok(true),
830            SinkDecouple::Disable => Ok(false),
831        }
832    }
833
834    fn support_schema_change() -> bool {
835        false
836    }
837
838    fn validate_alter_config(_config: &BTreeMap<String, String>) -> Result<()> {
839        Ok(())
840    }
841
842    fn validate_unknown_fields(&self) -> Result<()> {
843        Ok(())
844    }
845
846    async fn validate(&self) -> Result<()>;
847    async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker>;
848
849    fn is_coordinated_sink(&self) -> bool {
850        false
851    }
852
853    async fn new_coordinator(
854        &self,
855        _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
856    ) -> Result<SinkCommitCoordinator> {
857        Err(SinkError::Coordinator(anyhow!("no coordinator")))
858    }
859}
860
861pub trait SinkLogReader: Send {
862    fn start_from(
863        &mut self,
864        start_offset: Option<u64>,
865    ) -> impl Future<Output = LogStoreResult<()>> + Send + '_;
866    /// Emit the next item.
867    ///
868    /// The implementation should ensure that the future is cancellation safe.
869    fn next_item(
870        &mut self,
871    ) -> impl Future<Output = LogStoreResult<(u64, LogStoreReadItem)>> + Send + '_;
872
873    /// Mark that all items emitted so far have been consumed and it is safe to truncate the log
874    /// from the current offset.
875    fn truncate(&mut self, offset: TruncateOffset) -> LogStoreResult<()>;
876}
877
878impl<R: LogReader> SinkLogReader for &mut R {
879    fn next_item(
880        &mut self,
881    ) -> impl Future<Output = LogStoreResult<(u64, LogStoreReadItem)>> + Send + '_ {
882        <R as LogReader>::next_item(*self)
883    }
884
885    fn truncate(&mut self, offset: TruncateOffset) -> LogStoreResult<()> {
886        <R as LogReader>::truncate(*self, offset)
887    }
888
889    fn start_from(
890        &mut self,
891        start_offset: Option<u64>,
892    ) -> impl Future<Output = LogStoreResult<()>> + Send + '_ {
893        <R as LogReader>::start_from(*self, start_offset)
894    }
895}
896
897#[async_trait]
898pub trait LogSinker: 'static + Send {
899    // Note: Please rebuild the log reader's read stream before consuming the log store,
900    async fn consume_log_and_sink(self, log_reader: impl SinkLogReader) -> Result<!>;
901}
902pub type SinkCommittedEpochSubscriber = Arc<
903    dyn Fn(SinkId) -> BoxFuture<'static, Result<(u64, UnboundedReceiver<u64>)>>
904        + Send
905        + Sync
906        + 'static,
907>;
908
909pub enum SinkCommitCoordinator {
910    SinglePhase(BoxSinglePhaseCoordinator),
911    TwoPhase(BoxTwoPhaseCoordinator),
912}
913
914#[async_trait]
915pub trait SinglePhaseCommitCoordinator {
916    /// Initialize the sink committer coordinator.
917    async fn init(&mut self) -> Result<()>;
918
919    /// Commit data directly using single-phase strategy.
920    async fn commit_data(&mut self, epoch: u64, metadata: Vec<SinkMetadata>) -> Result<()>;
921
922    /// Idempotent implementation is required, because `commit_schema_change` in the same epoch could be called multiple
923    /// times.
924    async fn commit_schema_change(
925        &mut self,
926        _epoch: u64,
927        _schema_change: PbSinkSchemaChange,
928    ) -> Result<()> {
929        Err(SinkError::Coordinator(anyhow!(
930            "Schema change is not implemented for single-phase commit coordinator {}",
931            std::any::type_name::<Self>()
932        )))
933    }
934}
935
936#[async_trait]
937pub trait TwoPhaseCommitCoordinator {
938    /// Initialize the sink committer coordinator.
939    async fn init(&mut self) -> Result<()>;
940
941    /// Return serialized commit metadata to be passed to `commit`.
942    async fn pre_commit(
943        &mut self,
944        epoch: u64,
945        metadata: Vec<SinkMetadata>,
946        schema_change: Option<PbSinkSchemaChange>,
947    ) -> Result<Option<Vec<u8>>>;
948
949    /// Idempotent implementation is required, because `commit_data` in the same epoch could be called multiple times.
950    async fn commit_data(&mut self, epoch: u64, commit_metadata: Vec<u8>) -> Result<()>;
951
952    /// Idempotent implementation is required, because `commit_schema_change` in the same epoch could be called multiple
953    /// times.
954    async fn commit_schema_change(
955        &mut self,
956        _epoch: u64,
957        _schema_change: PbSinkSchemaChange,
958    ) -> Result<()> {
959        Err(SinkError::Coordinator(anyhow!(
960            "Schema change is not implemented for two-phase commit coordinator {}",
961            std::any::type_name::<Self>()
962        )))
963    }
964
965    /// Idempotent implementation is required, because `abort` in the same epoch could be called multiple times.
966    async fn abort(&mut self, epoch: u64, commit_metadata: Vec<u8>);
967}
968
969impl SinkImpl {
970    pub fn new(mut param: SinkParam) -> Result<Self> {
971        const PRIVATE_LINK_TARGET_KEY: &str = "privatelink.targets";
972
973        // remove privatelink related properties if any
974        param.properties.remove(PRIVATE_LINK_TARGET_KEY);
975
976        let sink_type = param
977            .properties
978            .get(CONNECTOR_TYPE_KEY)
979            .ok_or_else(|| SinkError::Config(anyhow!("missing config: {}", CONNECTOR_TYPE_KEY)))?;
980
981        let sink_type = sink_type.to_lowercase();
982        match_sink_name_str!(
983            sink_type.as_str(),
984            SinkType,
985            Ok(SinkType::try_from(param)?.into()),
986            |other| {
987                Err(SinkError::Config(anyhow!(
988                    "unsupported sink connector {}",
989                    other
990                )))
991            }
992        )
993    }
994
995    pub fn is_sink_into_table(&self) -> bool {
996        matches!(self, SinkImpl::Table(_))
997    }
998
999    pub fn is_blackhole(&self) -> bool {
1000        matches!(self, SinkImpl::BlackHole(_))
1001    }
1002
1003    pub fn is_coordinated_sink(&self) -> bool {
1004        dispatch_sink!(self, sink, sink.is_coordinated_sink())
1005    }
1006
1007    pub fn validate_unknown_fields(&self) -> Result<()> {
1008        dispatch_sink!(self, sink, sink.validate_unknown_fields())
1009    }
1010}
1011
1012pub fn build_sink(param: SinkParam) -> Result<SinkImpl> {
1013    SinkImpl::new(param)
1014}
1015
1016macro_rules! def_sink_impl {
1017    () => {
1018        $crate::for_all_sinks! { def_sink_impl }
1019    };
1020    ({ $({ $variant_name:ident, $sink_type:ty, $config_type:ty }),* }) => {
1021        #[derive(Debug)]
1022        pub enum SinkImpl {
1023            $(
1024                $variant_name(Box<$sink_type>),
1025            )*
1026        }
1027
1028        $(
1029            impl From<$sink_type> for SinkImpl {
1030                fn from(sink: $sink_type) -> SinkImpl {
1031                    SinkImpl::$variant_name(Box::new(sink))
1032                }
1033            }
1034        )*
1035    };
1036}
1037
1038def_sink_impl!();
1039
1040pub type Result<T> = std::result::Result<T, SinkError>;
1041
1042#[derive(Error, Debug)]
1043pub enum SinkError {
1044    #[error("Kafka error: {0}")]
1045    Kafka(#[from] rdkafka::error::KafkaError),
1046    #[error("Kinesis error: {0}")]
1047    Kinesis(
1048        #[source]
1049        #[backtrace]
1050        anyhow::Error,
1051    ),
1052    #[error("Remote sink error: {0}")]
1053    Remote(
1054        #[source]
1055        #[backtrace]
1056        anyhow::Error,
1057    ),
1058    #[error("Encode error: {0}")]
1059    Encode(String),
1060    #[error("Avro error: {0}")]
1061    Avro(#[from] apache_avro::Error),
1062    #[error("Iceberg error: {0}")]
1063    Iceberg(
1064        #[source]
1065        #[backtrace]
1066        anyhow::Error,
1067    ),
1068    #[error("config error: {0}")]
1069    Config(
1070        #[source]
1071        #[backtrace]
1072        anyhow::Error,
1073    ),
1074    #[error("coordinator error: {0}")]
1075    Coordinator(
1076        #[source]
1077        #[backtrace]
1078        anyhow::Error,
1079    ),
1080    #[error("ClickHouse error: {0}")]
1081    ClickHouse(String),
1082    #[error("Redis error: {0}")]
1083    Redis(String),
1084    #[error("Http error: {0}")]
1085    Http(
1086        #[source]
1087        #[backtrace]
1088        anyhow::Error,
1089    ),
1090    #[error("Mqtt error: {0}")]
1091    Mqtt(
1092        #[source]
1093        #[backtrace]
1094        anyhow::Error,
1095    ),
1096    #[error("Nats error: {0}")]
1097    Nats(
1098        #[source]
1099        #[backtrace]
1100        anyhow::Error,
1101    ),
1102    #[error("Google Pub/Sub error: {0}")]
1103    GooglePubSub(
1104        #[source]
1105        #[backtrace]
1106        anyhow::Error,
1107    ),
1108    #[error("Doris/Starrocks connect error: {0}")]
1109    DorisStarrocksConnect(
1110        #[source]
1111        #[backtrace]
1112        anyhow::Error,
1113    ),
1114    #[error("Doris error: {0}")]
1115    Doris(String),
1116    #[error("DeltaLake error: {0}")]
1117    DeltaLake(
1118        #[source]
1119        #[backtrace]
1120        anyhow::Error,
1121    ),
1122    #[error("ElasticSearch/OpenSearch error: {0}")]
1123    ElasticSearchOpenSearch(
1124        #[source]
1125        #[backtrace]
1126        anyhow::Error,
1127    ),
1128    #[error("Starrocks error: {0}")]
1129    Starrocks(String),
1130    #[error("File error: {0}")]
1131    File(String),
1132    #[error("Pulsar error: {0}")]
1133    Pulsar(
1134        #[source]
1135        #[backtrace]
1136        anyhow::Error,
1137    ),
1138    #[error(transparent)]
1139    Internal(
1140        #[from]
1141        #[backtrace]
1142        anyhow::Error,
1143    ),
1144    #[error("BigQuery error: {0}")]
1145    BigQuery(
1146        #[source]
1147        #[backtrace]
1148        anyhow::Error,
1149    ),
1150    #[error("DynamoDB error: {0}")]
1151    DynamoDb(
1152        #[source]
1153        #[backtrace]
1154        anyhow::Error,
1155    ),
1156    #[error("SQL Server error: {0}")]
1157    SqlServer(
1158        #[source]
1159        #[backtrace]
1160        anyhow::Error,
1161    ),
1162    #[error("Postgres error: {0}")]
1163    Postgres(
1164        #[source]
1165        #[backtrace]
1166        anyhow::Error,
1167    ),
1168    #[error(transparent)]
1169    Connector(
1170        #[from]
1171        #[backtrace]
1172        ConnectorError,
1173    ),
1174    #[error("Secret error: {0}")]
1175    Secret(
1176        #[from]
1177        #[backtrace]
1178        SecretError,
1179    ),
1180    #[error("Mongodb error: {0}")]
1181    Mongodb(
1182        #[source]
1183        #[backtrace]
1184        anyhow::Error,
1185    ),
1186    #[error("Redshift error: {0}")]
1187    Redshift(
1188        #[source]
1189        #[backtrace]
1190        anyhow::Error,
1191    ),
1192}
1193
1194#[allow(clippy::disallowed_types)]
1195impl From<::iceberg::Error> for SinkError {
1196    fn from(err: ::iceberg::Error) -> Self {
1197        SinkError::Iceberg(anyhow!(err))
1198    }
1199}
1200
1201impl From<sea_orm::DbErr> for SinkError {
1202    fn from(err: sea_orm::DbErr) -> Self {
1203        SinkError::Iceberg(anyhow!(err))
1204    }
1205}
1206
1207impl From<OpendalError> for SinkError {
1208    fn from(error: OpendalError) -> Self {
1209        SinkError::File(error.to_report_string())
1210    }
1211}
1212
1213impl From<parquet::errors::ParquetError> for SinkError {
1214    fn from(error: parquet::errors::ParquetError) -> Self {
1215        SinkError::File(error.to_report_string())
1216    }
1217}
1218
1219impl From<ArrayError> for SinkError {
1220    fn from(error: ArrayError) -> Self {
1221        SinkError::File(error.to_report_string())
1222    }
1223}
1224
1225impl From<RpcError> for SinkError {
1226    fn from(value: RpcError) -> Self {
1227        SinkError::Remote(anyhow!(value))
1228    }
1229}
1230
1231impl From<RedisError> for SinkError {
1232    fn from(value: RedisError) -> Self {
1233        SinkError::Redis(value.to_report_string())
1234    }
1235}
1236
1237impl From<tiberius::error::Error> for SinkError {
1238    fn from(err: tiberius::error::Error) -> Self {
1239        SinkError::SqlServer(anyhow!(err))
1240    }
1241}
1242
1243impl From<::elasticsearch::Error> for SinkError {
1244    fn from(err: ::elasticsearch::Error) -> Self {
1245        SinkError::ElasticSearchOpenSearch(anyhow!(err))
1246    }
1247}
1248
1249impl From<::opensearch::Error> for SinkError {
1250    fn from(err: ::opensearch::Error) -> Self {
1251        SinkError::ElasticSearchOpenSearch(anyhow!(err))
1252    }
1253}
1254
1255impl From<tokio_postgres::Error> for SinkError {
1256    fn from(err: tokio_postgres::Error) -> Self {
1257        SinkError::Postgres(anyhow!(err))
1258    }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use std::collections::BTreeMap;
1264
1265    use super::*;
1266
1267    fn btreemap<const N: usize>(entries: [(&str, &str); N]) -> BTreeMap<String, String> {
1268        entries
1269            .into_iter()
1270            .map(|(key, value)| (key.to_owned(), value.to_owned()))
1271            .collect()
1272    }
1273
1274    #[test]
1275    fn test_validate_sink_unknown_fields() {
1276        let config = crate::sink::redis::RedisConfig::from_btreemap(btreemap([
1277            (CONNECTOR_TYPE_KEY, "redis"),
1278            (SINK_TYPE_OPTION, SINK_TYPE_APPEND_ONLY),
1279            ("primary_key", "id"),
1280            (SINK_USER_FORCE_COMPACTION, "true"),
1281            (SINK_USER_PRESERVE_ROW_LEVEL_CHANGES, "true"),
1282            ("redis.url", "redis://127.0.0.1:6379"),
1283        ]))
1284        .unwrap();
1285        validate_sink_unknown_fields(&config).unwrap();
1286
1287        let config = crate::sink::redis::RedisConfig::from_btreemap(btreemap([
1288            (CONNECTOR_TYPE_KEY, "redis"),
1289            (SINK_TYPE_OPTION, SINK_TYPE_APPEND_ONLY),
1290            ("redis.url", "redis://127.0.0.1:6379"),
1291            ("bogus_with", "1"),
1292        ]))
1293        .unwrap();
1294        let err = validate_sink_unknown_fields(&config).unwrap_err();
1295        let report = err.to_report_string();
1296        assert!(report.contains("bogus_with"), "{report}");
1297
1298        let err = crate::sink::kafka::KafkaConfig::from_btreemap(btreemap([
1299            (CONNECTOR_TYPE_KEY, "kafka"),
1300            (SINK_TYPE_OPTION, SINK_TYPE_APPEND_ONLY),
1301        ]))
1302        .unwrap_err();
1303        assert!(err.to_report_string().contains("missing field `topic`"));
1304    }
1305}