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