Skip to main content

risingwave_connector/sink/snowflake_redshift/
snowflake.rs

1// Copyright 2025 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 core::num::NonZeroU64;
16use std::collections::BTreeMap;
17use std::time::Duration;
18
19use anyhow::anyhow;
20use phf::{Set, phf_set};
21use risingwave_common::array::StreamChunk;
22use risingwave_common::catalog::Schema;
23use risingwave_common::types::DataType;
24use risingwave_pb::connector_service::{SinkMetadata, sink_metadata};
25use risingwave_pb::stream_plan::PbSinkSchemaChange;
26use serde::Deserialize;
27use serde_with::{DisplayFromStr, serde_as};
28use thiserror_ext::AsReport;
29use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
30use tokio::time::{MissedTickBehavior, interval};
31use tonic::async_trait;
32use with_options::WithOptions;
33
34use crate::connector_common::IcebergSinkCompactionUpdate;
35use crate::enforce_secret::EnforceSecret;
36use crate::sink::catalog::SinkId;
37use crate::sink::coordinate::CoordinatedLogSinker;
38use crate::sink::decouple_checkpoint_log_sink::default_commit_checkpoint_interval;
39use crate::sink::file_sink::s3::S3Common;
40use crate::sink::jdbc_jni_client::{self, JdbcJniClient};
41use crate::sink::snowflake_redshift::{
42    __OP, __ROW_ID, SnowflakeRedshiftSinkJdbcWriter, SnowflakeRedshiftSinkS3Writer,
43};
44use crate::sink::writer::SinkWriter;
45use crate::sink::{
46    Result, SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT,
47    SinglePhaseCommitCoordinator, Sink, SinkCommitCoordinator, SinkError, SinkParam,
48    SinkWriterParam,
49};
50
51pub const SNOWFLAKE_SINK_V2: &str = "snowflake_v2";
52
53const AUTH_METHOD_PASSWORD: &str = "password";
54const AUTH_METHOD_KEY_PAIR_FILE: &str = "key_pair_file";
55const AUTH_METHOD_KEY_PAIR_OBJECT: &str = "key_pair_object";
56const PROP_AUTH_METHOD: &str = "auth.method";
57
58pub fn build_full_table_name(database: &str, schema_name: &str, table_name: &str) -> String {
59    format!(r#""{}"."{}"."{}""#, database, schema_name, table_name)
60}
61
62#[serde_as]
63#[derive(Debug, Clone, Deserialize, WithOptions)]
64pub struct SnowflakeV2Config {
65    #[serde(rename = "type")]
66    pub r#type: String,
67
68    #[serde(rename = "intermediate.table.name")]
69    pub snowflake_cdc_table_name: Option<String>,
70
71    #[serde(rename = "table.name")]
72    pub snowflake_target_table_name: Option<String>,
73
74    #[serde(rename = "database")]
75    pub snowflake_database: Option<String>,
76
77    #[serde(rename = "schema")]
78    pub snowflake_schema: Option<String>,
79
80    #[serde(default = "default_target_interval_schedule")]
81    #[serde(rename = "write.target.interval.seconds")]
82    #[serde_as(as = "DisplayFromStr")]
83    pub writer_target_interval_seconds: u64,
84
85    #[serde(default = "default_intermediate_interval_schedule")]
86    #[serde(rename = "write.intermediate.interval.seconds")]
87    #[serde_as(as = "DisplayFromStr")]
88    pub write_intermediate_interval_seconds: u64,
89
90    #[serde(rename = "warehouse")]
91    pub snowflake_warehouse: Option<String>,
92
93    #[serde(default, rename = "task.serverless")]
94    #[serde_as(as = "DisplayFromStr")]
95    pub task_serverless: bool,
96
97    #[serde(rename = "task.target_completion_interval")]
98    pub task_target_completion_interval: Option<String>,
99
100    #[serde(rename = "jdbc.url")]
101    pub jdbc_url: Option<String>,
102
103    #[serde(rename = "username")]
104    pub username: Option<String>,
105
106    #[serde(rename = "password")]
107    pub password: Option<String>,
108
109    // Authentication method control (password | key_pair_file | key_pair_object)
110    #[serde(rename = "auth.method")]
111    pub auth_method: Option<String>,
112
113    // Key-pair authentication via connection Properties (Option 2: file-based)
114    #[serde(rename = "private_key_file")]
115    pub private_key_file: Option<String>,
116
117    #[serde(rename = "private_key_file_pwd")]
118    pub private_key_file_pwd: Option<String>,
119
120    // Key-pair authentication via connection Properties (Option 1: object-based, PEM content)
121    #[serde(rename = "private_key_pem")]
122    pub private_key_pem: Option<String>,
123
124    /// Commit every n(>0) checkpoints, default is 10.
125    #[serde(default = "default_commit_checkpoint_interval")]
126    #[serde_as(as = "DisplayFromStr")]
127    #[with_option(allow_alter_on_fly)]
128    pub commit_checkpoint_interval: u64,
129
130    /// Enable auto schema change for upsert sink.
131    /// If enabled, the sink will automatically alter the target table to add new columns.
132    #[serde(default)]
133    #[serde(rename = "auto.schema.change")]
134    #[serde_as(as = "DisplayFromStr")]
135    pub auto_schema_change: bool,
136
137    #[serde(default)]
138    #[serde(rename = "create_table_if_not_exists")]
139    #[serde_as(as = "DisplayFromStr")]
140    pub create_table_if_not_exists: bool,
141
142    #[serde(default = "default_with_s3")]
143    #[serde(rename = "with_s3")]
144    #[serde_as(as = "DisplayFromStr")]
145    pub with_s3: bool,
146
147    #[serde(flatten)]
148    pub s3_inner: Option<S3Common>,
149
150    #[serde(rename = "stage")]
151    pub stage: Option<String>,
152}
153
154fn default_target_interval_schedule() -> u64 {
155    3600 // Default to 1 hour
156}
157
158fn default_intermediate_interval_schedule() -> u64 {
159    1800 // Default to 0.5 hour
160}
161
162fn default_with_s3() -> bool {
163    true
164}
165
166impl SnowflakeV2Config {
167    /// Build JDBC Properties for the Snowflake JDBC connection (no URL parameters).
168    /// Returns (`jdbc_url`, `driver_properties`).
169    /// - `driver_properties` are transformed/used by the Java runner and passed to `DriverManager::getConnection(url, props)`
170    ///
171    /// Note: This method assumes the config has been validated by `from_btreemap`.
172    pub fn build_jdbc_connection_properties(&self) -> Result<(String, Vec<(String, String)>)> {
173        let jdbc_url = self
174            .jdbc_url
175            .clone()
176            .ok_or(SinkError::Config(anyhow!("jdbc.url is required")))?;
177        let username = self
178            .username
179            .clone()
180            .ok_or(SinkError::Config(anyhow!("username is required")))?;
181
182        let mut connection_properties: Vec<(String, String)> = vec![("user".to_owned(), username)];
183
184        // auth_method is guaranteed to be Some after validation in from_btreemap
185        match self.auth_method.as_deref().unwrap() {
186            AUTH_METHOD_PASSWORD => {
187                // password is guaranteed to exist by from_btreemap validation
188                connection_properties.push(("password".to_owned(), self.password.clone().unwrap()));
189            }
190            AUTH_METHOD_KEY_PAIR_FILE => {
191                // private_key_file is guaranteed to exist by from_btreemap validation
192                connection_properties.push((
193                    "private_key_file".to_owned(),
194                    self.private_key_file.clone().unwrap(),
195                ));
196                if let Some(pwd) = self.private_key_file_pwd.clone() {
197                    connection_properties.push(("private_key_file_pwd".to_owned(), pwd));
198                }
199            }
200            AUTH_METHOD_KEY_PAIR_OBJECT => {
201                connection_properties.push((
202                    PROP_AUTH_METHOD.to_owned(),
203                    AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
204                ));
205                // private_key_pem is guaranteed to exist by from_btreemap validation
206                connection_properties.push((
207                    "private_key_pem".to_owned(),
208                    self.private_key_pem.clone().unwrap(),
209                ));
210                if let Some(pwd) = self.private_key_file_pwd.clone() {
211                    connection_properties.push(("private_key_file_pwd".to_owned(), pwd));
212                }
213            }
214            _ => {
215                // This should never happen since from_btreemap validates auth_method
216                unreachable!(
217                    "Invalid auth_method - should have been caught during config validation"
218                )
219            }
220        }
221
222        Ok((jdbc_url, connection_properties))
223    }
224
225    pub fn from_btreemap(properties: &BTreeMap<String, String>) -> Result<Self> {
226        let mut config =
227            serde_json::from_value::<SnowflakeV2Config>(serde_json::to_value(properties).unwrap())
228                .map_err(|e| SinkError::Config(anyhow!(e)))?;
229        if config.r#type != SINK_TYPE_APPEND_ONLY && config.r#type != SINK_TYPE_UPSERT {
230            return Err(SinkError::Config(anyhow!(
231                "`{}` must be {}, or {}",
232                SINK_TYPE_OPTION,
233                SINK_TYPE_APPEND_ONLY,
234                SINK_TYPE_UPSERT
235            )));
236        }
237        let has_upsert_task_config = config.snowflake_cdc_table_name.is_some()
238            || properties.contains_key("write.target.interval.seconds")
239            || config.snowflake_warehouse.is_some()
240            || config.task_serverless
241            || config.task_target_completion_interval.is_some();
242        if config.r#type != SINK_TYPE_UPSERT && has_upsert_task_config {
243            return Err(SinkError::Config(anyhow!(
244                "`intermediate.table.name`, `write.target.interval.seconds`, `warehouse`, \
245                 `task.serverless`, and `task.target_completion_interval` require `{}` = {}",
246                SINK_TYPE_OPTION,
247                SINK_TYPE_UPSERT
248            )));
249        }
250        if config.task_target_completion_interval.is_some() && !config.task_serverless {
251            return Err(SinkError::Config(anyhow!(
252                "`task.target_completion_interval` requires `task.serverless` to be true"
253            )));
254        }
255        if config.task_serverless && config.snowflake_warehouse.is_some() {
256            return Err(SinkError::Config(anyhow!(
257                "`task.serverless` must not be combined with `warehouse`"
258            )));
259        }
260
261        // Normalize and validate authentication method
262        let has_password = config.password.is_some();
263        let has_file = config.private_key_file.is_some();
264        let has_pem = config.private_key_pem.as_deref().is_some();
265
266        let normalized_auth_method = match config
267            .auth_method
268            .as_deref()
269            .map(|s| s.trim().to_ascii_lowercase())
270        {
271            Some(method) if method == AUTH_METHOD_PASSWORD => {
272                if !has_password {
273                    return Err(SinkError::Config(anyhow!(
274                        "auth.method=password requires `password`"
275                    )));
276                }
277                if has_file || has_pem {
278                    return Err(SinkError::Config(anyhow!(
279                        "auth.method=password must not set `private_key_file`/`private_key_pem`"
280                    )));
281                }
282                AUTH_METHOD_PASSWORD.to_owned()
283            }
284            Some(method) if method == AUTH_METHOD_KEY_PAIR_FILE => {
285                if !has_file {
286                    return Err(SinkError::Config(anyhow!(
287                        "auth.method=key_pair_file requires `private_key_file`"
288                    )));
289                }
290                if has_password {
291                    return Err(SinkError::Config(anyhow!(
292                        "auth.method=key_pair_file must not set `password`"
293                    )));
294                }
295                if has_pem {
296                    return Err(SinkError::Config(anyhow!(
297                        "auth.method=key_pair_file must not set `private_key_pem`"
298                    )));
299                }
300                AUTH_METHOD_KEY_PAIR_FILE.to_owned()
301            }
302            Some(method) if method == AUTH_METHOD_KEY_PAIR_OBJECT => {
303                if !has_pem {
304                    return Err(SinkError::Config(anyhow!(
305                        "auth.method=key_pair_object requires `private_key_pem`"
306                    )));
307                }
308                if has_password {
309                    return Err(SinkError::Config(anyhow!(
310                        "auth.method=key_pair_object must not set `password`"
311                    )));
312                }
313                AUTH_METHOD_KEY_PAIR_OBJECT.to_owned()
314            }
315            Some(other) => {
316                return Err(SinkError::Config(anyhow!(
317                    "invalid auth.method: {} (allowed: password | key_pair_file | key_pair_object)",
318                    other
319                )));
320            }
321            None => {
322                // Infer auth method from supplied fields
323                match (has_password, has_file, has_pem) {
324                    (true, false, false) => AUTH_METHOD_PASSWORD.to_owned(),
325                    (false, true, false) => AUTH_METHOD_KEY_PAIR_FILE.to_owned(),
326                    (false, false, true) => AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
327                    (true, true, _) | (true, _, true) | (false, true, true) => {
328                        return Err(SinkError::Config(anyhow!(
329                            "ambiguous auth: multiple auth options provided; remove one or set `auth.method`"
330                        )));
331                    }
332                    _ => {
333                        return Err(SinkError::Config(anyhow!(
334                            "no authentication configured: set either `password`, or `private_key_file`, or `private_key_pem` (or provide `auth.method`)"
335                        )));
336                    }
337                }
338            }
339        };
340        config.auth_method = Some(normalized_auth_method);
341        Ok(config)
342    }
343
344    pub fn build_snowflake_task_ctx_jdbc_client(
345        &self,
346        is_append_only: bool,
347        schema: &Schema,
348        pk_indices: &Vec<usize>,
349    ) -> Result<Option<(SnowflakeTaskContext, JdbcJniClient)>> {
350        if !self.auto_schema_change
351            && is_append_only
352            && !self.create_table_if_not_exists
353            && !self.with_s3
354        {
355            // append-only + no auto schema change is not need to create a client
356            return Ok(None);
357        }
358        let target_table_name = self
359            .snowflake_target_table_name
360            .clone()
361            .ok_or(SinkError::Config(anyhow!("table.name is required")))?;
362        let database = self
363            .snowflake_database
364            .clone()
365            .ok_or(SinkError::Config(anyhow!("database is required")))?;
366        let schema_name = self
367            .snowflake_schema
368            .clone()
369            .ok_or(SinkError::Config(anyhow!("schema is required")))?;
370        let mut snowflake_task_ctx = SnowflakeTaskContext {
371            target_table_name: target_table_name.clone(),
372            database,
373            schema_name,
374            schema: schema.clone(),
375            ..Default::default()
376        };
377
378        let (jdbc_url, connection_properties) = self.build_jdbc_connection_properties()?;
379        let client = JdbcJniClient::new_with_props(jdbc_url, connection_properties)?;
380
381        if self.with_s3 {
382            let stage = self
383                .stage
384                .clone()
385                .ok_or(SinkError::Config(anyhow!("stage is required")))?;
386            snowflake_task_ctx.stage = Some(stage);
387            snowflake_task_ctx.pipe_name = Some(format!("{}_pipe", target_table_name));
388        }
389        if !is_append_only {
390            let cdc_table_name = self
391                .snowflake_cdc_table_name
392                .clone()
393                .ok_or(SinkError::Config(anyhow!(
394                    "intermediate.table.name is required"
395                )))?;
396            snowflake_task_ctx.cdc_table_name = Some(cdc_table_name.clone());
397            snowflake_task_ctx.writer_target_interval_seconds = self.writer_target_interval_seconds;
398            snowflake_task_ctx.task_serverless = self.task_serverless;
399            snowflake_task_ctx.task_target_completion_interval =
400                self.task_target_completion_interval.clone();
401            if !self.task_serverless {
402                snowflake_task_ctx.warehouse = Some(
403                    self.snowflake_warehouse
404                        .clone()
405                        .ok_or(SinkError::Config(anyhow!("warehouse is required")))?,
406                );
407            }
408            let pk_column_names: Vec<_> = schema
409                .fields
410                .iter()
411                .enumerate()
412                .filter(|(index, _)| pk_indices.contains(index))
413                .map(|(_, field)| field.name.clone())
414                .collect();
415            if pk_column_names.is_empty() {
416                return Err(SinkError::Config(anyhow!(
417                    "Primary key columns not found. Please set the `primary_key` column in the sink properties, or ensure that the sink contains the primary key columns from the upstream."
418                )));
419            }
420            snowflake_task_ctx.pk_column_names = Some(pk_column_names);
421            snowflake_task_ctx.all_column_names = Some(
422                schema
423                    .fields
424                    .iter()
425                    .map(|field| field.name.clone())
426                    .collect(),
427            );
428            snowflake_task_ctx.task_name = Some(format!(
429                "rw_snowflake_sink_from_{cdc_table_name}_to_{target_table_name}"
430            ));
431        }
432        Ok(Some((snowflake_task_ctx, client)))
433    }
434}
435
436impl EnforceSecret for SnowflakeV2Config {
437    const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
438        "username",
439        "password",
440        "jdbc.url",
441        // Key-pair authentication secrets
442        "private_key_file_pwd",
443        "private_key_pem",
444    };
445}
446
447#[derive(Clone, Debug)]
448pub struct SnowflakeV2Sink {
449    config: SnowflakeV2Config,
450    schema: Schema,
451    pk_indices: Vec<usize>,
452    is_append_only: bool,
453    param: SinkParam,
454}
455
456impl EnforceSecret for SnowflakeV2Sink {
457    fn enforce_secret<'a>(
458        prop_iter: impl Iterator<Item = &'a str>,
459    ) -> crate::sink::ConnectorResult<()> {
460        for prop in prop_iter {
461            SnowflakeV2Config::enforce_one(prop)?;
462        }
463        Ok(())
464    }
465}
466
467impl TryFrom<SinkParam> for SnowflakeV2Sink {
468    type Error = SinkError;
469
470    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
471        let schema = param.schema();
472        let config = SnowflakeV2Config::from_btreemap(&param.properties)?;
473        let is_append_only = param.sink_type.is_append_only();
474        let pk_indices = param.downstream_pk_or_empty();
475        Ok(Self {
476            config,
477            schema,
478            pk_indices,
479            is_append_only,
480            param,
481        })
482    }
483}
484
485impl Sink for SnowflakeV2Sink {
486    type LogSinker = CoordinatedLogSinker<SnowflakeSinkWriter>;
487
488    const SINK_NAME: &'static str = SNOWFLAKE_SINK_V2;
489
490    async fn validate(&self) -> Result<()> {
491        risingwave_common::license::Feature::SnowflakeSink
492            .check_available()
493            .map_err(|e| anyhow::anyhow!(e))?;
494        if let Some((snowflake_task_ctx, client)) =
495            self.config.build_snowflake_task_ctx_jdbc_client(
496                self.is_append_only,
497                &self.schema,
498                &self.pk_indices,
499            )?
500        {
501            let client = SnowflakeJniClient::new(client, snowflake_task_ctx);
502            client.execute_create_table().await?;
503            client.execute_create_pipe().await?;
504        }
505
506        Ok(())
507    }
508
509    fn support_schema_change() -> bool {
510        true
511    }
512
513    fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
514        SnowflakeV2Config::from_btreemap(config)?;
515        Ok(())
516    }
517
518    async fn new_log_sinker(
519        &self,
520        writer_param: crate::sink::SinkWriterParam,
521    ) -> Result<Self::LogSinker> {
522        let writer = SnowflakeSinkWriter::new(
523            self.config.clone(),
524            self.is_append_only,
525            writer_param.clone(),
526            self.param.clone(),
527        )
528        .await?;
529
530        let commit_checkpoint_interval =
531            NonZeroU64::new(self.config.commit_checkpoint_interval).expect(
532                "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
533            );
534
535        CoordinatedLogSinker::new(
536            &writer_param,
537            self.param.clone(),
538            writer,
539            commit_checkpoint_interval,
540        )
541        .await
542    }
543
544    fn is_coordinated_sink(&self) -> bool {
545        true
546    }
547
548    async fn new_coordinator(
549        &self,
550        _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
551    ) -> Result<SinkCommitCoordinator> {
552        let coordinator = SnowflakeSinkCommitter::new(
553            self.config.clone(),
554            &self.schema,
555            &self.pk_indices,
556            self.is_append_only,
557            self.param.sink_id,
558        )?;
559        Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
560    }
561}
562
563pub enum SnowflakeSinkWriter {
564    S3(SnowflakeRedshiftSinkS3Writer),
565    Jdbc(SnowflakeRedshiftSinkJdbcWriter),
566}
567
568impl SnowflakeSinkWriter {
569    pub async fn new(
570        config: SnowflakeV2Config,
571        is_append_only: bool,
572        writer_param: SinkWriterParam,
573        param: SinkParam,
574    ) -> Result<Self> {
575        let schema = param.schema();
576        let database = config.snowflake_database.ok_or_else(|| {
577            SinkError::Config(anyhow!("database is required for Snowflake JDBC sink"))
578        })?;
579        let schema_name = config.snowflake_schema.ok_or_else(|| {
580            SinkError::Config(anyhow!("schema is required for Snowflake JDBC sink"))
581        })?;
582        let table_name = config.snowflake_target_table_name.ok_or_else(|| {
583            SinkError::Config(anyhow!("table.name is required for Snowflake JDBC sink"))
584        })?;
585        if config.with_s3 {
586            let s3_writer = SnowflakeRedshiftSinkS3Writer::new(
587                config.s3_inner.ok_or_else(|| {
588                    SinkError::Config(anyhow!(
589                        "S3 configuration is required for Snowflake S3 sink"
590                    ))
591                })?,
592                schema,
593                is_append_only,
594                table_name,
595            )?;
596            Ok(Self::S3(s3_writer))
597        } else {
598            let jdbc_writer = SnowflakeRedshiftSinkJdbcWriter::new(
599                is_append_only,
600                writer_param,
601                param,
602                build_full_table_name(&database, &schema_name, &table_name),
603            )
604            .await?;
605            Ok(Self::Jdbc(jdbc_writer))
606        }
607    }
608}
609
610#[async_trait]
611impl SinkWriter for SnowflakeSinkWriter {
612    type CommitMetadata = Option<SinkMetadata>;
613
614    async fn begin_epoch(&mut self, epoch: u64) -> Result<()> {
615        match self {
616            Self::S3(writer) => writer.begin_epoch(epoch),
617            Self::Jdbc(writer) => writer.begin_epoch(epoch).await,
618        }
619    }
620
621    async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
622        match self {
623            Self::S3(writer) => writer.write_batch(chunk).await,
624            Self::Jdbc(writer) => writer.write_batch(chunk).await,
625        }
626    }
627
628    async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
629        match self {
630            Self::S3(writer) => {
631                writer.barrier(is_checkpoint).await?;
632            }
633            Self::Jdbc(writer) => {
634                writer.barrier(is_checkpoint).await?;
635            }
636        }
637        Ok(Some(SinkMetadata {
638            metadata: Some(sink_metadata::Metadata::Serialized(
639                risingwave_pb::connector_service::sink_metadata::SerializedMetadata {
640                    metadata: vec![],
641                },
642            )),
643        }))
644    }
645
646    async fn abort(&mut self) -> Result<()> {
647        if let Self::Jdbc(writer) = self {
648            writer.abort().await
649        } else {
650            Ok(())
651        }
652    }
653}
654
655#[derive(Default, Clone)]
656pub struct SnowflakeTaskContext {
657    // required for task creation
658    pub target_table_name: String,
659    pub database: String,
660    pub schema_name: String,
661    pub schema: Schema,
662
663    // only upsert
664    pub task_name: Option<String>,
665    pub cdc_table_name: Option<String>,
666    pub writer_target_interval_seconds: u64,
667    pub warehouse: Option<String>,
668    pub task_serverless: bool,
669    pub task_target_completion_interval: Option<String>,
670    pub pk_column_names: Option<Vec<String>>,
671    pub all_column_names: Option<Vec<String>>,
672
673    // only s3 writer
674    pub stage: Option<String>,
675    pub pipe_name: Option<String>,
676}
677pub struct SnowflakeSinkCommitter {
678    client: Option<SnowflakeJniClient>,
679    _periodic_task_handle: Option<tokio::task::JoinHandle<()>>,
680    shutdown_sender: Option<tokio::sync::mpsc::UnboundedSender<()>>,
681}
682
683impl SnowflakeSinkCommitter {
684    pub fn new(
685        config: SnowflakeV2Config,
686        schema: &Schema,
687        pk_indices: &Vec<usize>,
688        is_append_only: bool,
689        sink_id: SinkId,
690    ) -> Result<Self> {
691        let (client, periodic_task_handle, shutdown_sender) =
692            if let Some((snowflake_task_ctx, client)) =
693                config.build_snowflake_task_ctx_jdbc_client(is_append_only, schema, pk_indices)?
694            {
695                let (shutdown_sender, shutdown_receiver) = unbounded_channel();
696                let snowflake_client =
697                    SnowflakeJniClient::new(client.clone(), snowflake_task_ctx.clone());
698                let periodic_task_handle = tokio::spawn(async move {
699                    Self::run_periodic_query_task(
700                        snowflake_client,
701                        config.write_intermediate_interval_seconds,
702                        sink_id,
703                        shutdown_receiver,
704                    )
705                    .await;
706                });
707                (
708                    Some(SnowflakeJniClient::new(client, snowflake_task_ctx)),
709                    Some(periodic_task_handle),
710                    Some(shutdown_sender),
711                )
712            } else {
713                (None, None, None)
714            };
715
716        Ok(Self {
717            client,
718            _periodic_task_handle: periodic_task_handle,
719            shutdown_sender,
720        })
721    }
722
723    async fn run_periodic_query_task(
724        client: SnowflakeJniClient,
725        write_intermediate_interval_seconds: u64,
726        sink_id: SinkId,
727        mut shutdown_receiver: tokio::sync::mpsc::UnboundedReceiver<()>,
728    ) {
729        let mut copy_timer = interval(Duration::from_secs(write_intermediate_interval_seconds));
730        copy_timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
731        loop {
732            tokio::select! {
733                _ = shutdown_receiver.recv() => break,
734                _ = copy_timer.tick() => {
735                    if let Err(e) = async {
736                        client.execute_flush_pipe().await?;
737                        Ok::<(),SinkError>(())
738                    }.await {
739                        tracing::error!("Failed to execute copy into task for sink id {}: {}", sink_id, e.as_report());
740                    }
741                }
742            }
743        }
744        tracing::info!("Periodic query task stopped for sink id {}", sink_id);
745    }
746}
747
748#[async_trait]
749impl SinglePhaseCommitCoordinator for SnowflakeSinkCommitter {
750    async fn init(&mut self) -> Result<()> {
751        if let Some(client) = &self.client {
752            // Todo: move this to validate
753            client.execute_create_pipe().await?;
754            client.execute_create_merge_into_task().await?;
755        }
756        Ok(())
757    }
758
759    async fn commit_data(&mut self, _epoch: u64, _metadata: Vec<SinkMetadata>) -> Result<()> {
760        Ok(())
761    }
762
763    async fn commit_schema_change(
764        &mut self,
765        _epoch: u64,
766        schema_change: PbSinkSchemaChange,
767    ) -> Result<()> {
768        use risingwave_pb::stream_plan::sink_schema_change::PbOp as SinkSchemaChangeOp;
769        let schema_change_op = schema_change
770            .op
771            .ok_or_else(|| SinkError::Coordinator(anyhow!("Invalid schema change operation")))?;
772        let SinkSchemaChangeOp::AddColumns(add_columns) = schema_change_op else {
773            return Err(SinkError::Coordinator(anyhow!(
774                "Only AddColumns schema change is supported for Snowflake sink"
775            )));
776        };
777        let client = self.client.as_mut().ok_or_else(|| {
778            SinkError::Config(anyhow!("Snowflake sink committer is not initialized."))
779        })?;
780        client
781            .execute_alter_add_columns(
782                &add_columns
783                    .fields
784                    .into_iter()
785                    .map(|f| {
786                        let dt = DataType::from(f.data_type.unwrap());
787                        Ok((f.name, convert_snowflake_data_type(&dt)?))
788                    })
789                    .collect::<Result<Vec<_>>>()?,
790            )
791            .await
792    }
793}
794
795impl Drop for SnowflakeSinkCommitter {
796    fn drop(&mut self) {
797        if let Some(client) = self.client.take() {
798            if let Some(sender) = self.shutdown_sender.take() {
799                let _ = sender.send(()); // Ignore the result, as the receiver may have been dropped.
800            }
801            tokio::spawn(async move {
802                client.execute_drop_task().await.ok();
803            });
804        }
805    }
806}
807
808pub struct SnowflakeJniClient {
809    jdbc_client: JdbcJniClient,
810    snowflake_task_context: SnowflakeTaskContext,
811}
812
813impl SnowflakeJniClient {
814    pub fn new(jdbc_client: JdbcJniClient, snowflake_task_context: SnowflakeTaskContext) -> Self {
815        Self {
816            jdbc_client,
817            snowflake_task_context,
818        }
819    }
820
821    pub async fn execute_alter_add_columns(
822        &mut self,
823        columns: &Vec<(String, String)>,
824    ) -> Result<()> {
825        self.execute_drop_task().await?;
826        if let Some(names) = self.snowflake_task_context.all_column_names.as_mut() {
827            names.extend(columns.iter().map(|(name, _)| name.clone()));
828        }
829        if let Some(cdc_table_name) = &self.snowflake_task_context.cdc_table_name {
830            let alter_add_column_cdc_table_sql = build_alter_add_column_sql(
831                cdc_table_name,
832                &self.snowflake_task_context.database,
833                &self.snowflake_task_context.schema_name,
834                columns,
835            );
836            self.jdbc_client
837                .execute_sql_sync(vec![alter_add_column_cdc_table_sql])
838                .await?;
839        }
840
841        let alter_add_column_target_table_sql = build_alter_add_column_sql(
842            &self.snowflake_task_context.target_table_name,
843            &self.snowflake_task_context.database,
844            &self.snowflake_task_context.schema_name,
845            columns,
846        );
847        self.jdbc_client
848            .execute_sql_sync(vec![alter_add_column_target_table_sql])
849            .await?;
850
851        self.execute_create_merge_into_task().await?;
852        Ok(())
853    }
854
855    pub async fn execute_create_merge_into_task(&self) -> Result<()> {
856        if self.snowflake_task_context.task_name.is_some() {
857            let create_task_sql = build_create_merge_into_task_sql(&self.snowflake_task_context);
858            let start_task_sql = build_start_task_sql(&self.snowflake_task_context);
859            self.jdbc_client
860                .execute_sql_sync(vec![create_task_sql])
861                .await?;
862            self.jdbc_client
863                .execute_sql_sync(vec![start_task_sql])
864                .await?;
865        }
866        Ok(())
867    }
868
869    pub async fn execute_drop_task(&self) -> Result<()> {
870        if self.snowflake_task_context.task_name.is_some() {
871            let sql = build_drop_task_sql(&self.snowflake_task_context);
872            if let Err(e) = self.jdbc_client.execute_sql_sync(vec![sql]).await {
873                tracing::error!(
874                    "Failed to drop Snowflake sink task {:?}: {:?}",
875                    self.snowflake_task_context.task_name,
876                    e.as_report()
877                );
878            } else {
879                tracing::info!(
880                    "Snowflake sink task {:?} dropped",
881                    self.snowflake_task_context.task_name
882                );
883            }
884        }
885        Ok(())
886    }
887
888    pub async fn execute_create_table(&self) -> Result<()> {
889        // create target table
890        let create_target_table_sql = build_create_table_sql(
891            &self.snowflake_task_context.target_table_name,
892            &self.snowflake_task_context.database,
893            &self.snowflake_task_context.schema_name,
894            &self.snowflake_task_context.schema,
895            false,
896        )?;
897        self.jdbc_client
898            .execute_sql_sync(vec![create_target_table_sql])
899            .await?;
900        if let Some(cdc_table_name) = &self.snowflake_task_context.cdc_table_name {
901            let create_cdc_table_sql = build_create_table_sql(
902                cdc_table_name,
903                &self.snowflake_task_context.database,
904                &self.snowflake_task_context.schema_name,
905                &self.snowflake_task_context.schema,
906                true,
907            )?;
908            self.jdbc_client
909                .execute_sql_sync(vec![create_cdc_table_sql])
910                .await?;
911        }
912        Ok(())
913    }
914
915    pub async fn execute_create_pipe(&self) -> Result<()> {
916        if let Some(pipe_name) = &self.snowflake_task_context.pipe_name {
917            let table_name =
918                if let Some(table_name) = self.snowflake_task_context.cdc_table_name.as_ref() {
919                    table_name
920                } else {
921                    &self.snowflake_task_context.target_table_name
922                };
923            let create_pipe_sql = build_create_pipe_sql(
924                table_name,
925                &self.snowflake_task_context.database,
926                &self.snowflake_task_context.schema_name,
927                self.snowflake_task_context.stage.as_ref().ok_or_else(|| {
928                    SinkError::Config(anyhow!("snowflake.stage is required for S3 writer"))
929                })?,
930                pipe_name,
931                &self.snowflake_task_context.target_table_name,
932            );
933            self.jdbc_client
934                .execute_sql_sync(vec![create_pipe_sql])
935                .await?;
936        }
937        Ok(())
938    }
939
940    pub async fn execute_flush_pipe(&self) -> Result<()> {
941        if let Some(pipe_name) = &self.snowflake_task_context.pipe_name {
942            let flush_pipe_sql = build_flush_pipe_sql(
943                &self.snowflake_task_context.database,
944                &self.snowflake_task_context.schema_name,
945                pipe_name,
946            );
947            self.jdbc_client
948                .execute_sql_sync(vec![flush_pipe_sql])
949                .await?;
950        }
951        Ok(())
952    }
953}
954
955fn build_create_table_sql(
956    table_name: &str,
957    database: &str,
958    schema_name: &str,
959    schema: &Schema,
960    need_op_and_row_id: bool,
961) -> Result<String> {
962    let full_table_name = build_full_table_name(database, schema_name, table_name);
963    let mut columns: Vec<String> = schema
964        .fields
965        .iter()
966        .map(|field| {
967            let data_type = convert_snowflake_data_type(&field.data_type)?;
968            Ok(format!(r#""{}" {}"#, field.name, data_type))
969        })
970        .collect::<Result<Vec<String>>>()?;
971    if need_op_and_row_id {
972        columns.push(format!(r#""{}" STRING"#, __ROW_ID));
973        columns.push(format!(r#""{}" INT"#, __OP));
974    }
975    let columns_str = columns.join(", ");
976    Ok(format!(
977        "CREATE TABLE IF NOT EXISTS {} ({}) ENABLE_SCHEMA_EVOLUTION  = true",
978        full_table_name, columns_str
979    ))
980}
981
982fn convert_snowflake_data_type(data_type: &DataType) -> Result<String> {
983    let data_type = match data_type {
984        DataType::Int16 => "SMALLINT".to_owned(),
985        DataType::Int32 => "INTEGER".to_owned(),
986        DataType::Int64 => "BIGINT".to_owned(),
987        DataType::Float32 => "FLOAT4".to_owned(),
988        DataType::Float64 => "FLOAT8".to_owned(),
989        DataType::Boolean => "BOOLEAN".to_owned(),
990        DataType::Varchar => "STRING".to_owned(),
991        DataType::Date => "DATE".to_owned(),
992        DataType::Timestamp => "TIMESTAMP".to_owned(),
993        DataType::Timestamptz => "TIMESTAMP_TZ".to_owned(),
994        DataType::Jsonb => "STRING".to_owned(),
995        // RisingWave uses rust_decimal with MAX_PRECISION=28. Snowflake's DECIMAL without
996        // explicit precision defaults to (38,0), which drops all fractional digits. We use
997        // DECIMAL(38, 10) to preserve up to 10 fractional digits, matching the Iceberg sink
998        // convention, though values with more than 10 fractional digits may still lose precision.
999        DataType::Decimal => "DECIMAL(38, 10)".to_owned(),
1000        DataType::Bytea => "BINARY".to_owned(),
1001        DataType::Time => "TIME".to_owned(),
1002        _ => {
1003            return Err(SinkError::Config(anyhow!(
1004                "Dont support auto create table for datatype: {}",
1005                data_type
1006            )));
1007        }
1008    };
1009    Ok(data_type)
1010}
1011
1012fn build_create_pipe_sql(
1013    table_name: &str,
1014    database: &str,
1015    schema: &str,
1016    stage: &str,
1017    pipe_name: &str,
1018    target_table_name: &str,
1019) -> String {
1020    let pipe_name = format!(r#""{}"."{}"."{}""#, database, schema, pipe_name);
1021    // Trailing `/` is required to enforce exact directory matching.
1022    // Without it, a PIPE for table "dim_project" would also match files under
1023    // "dim_project_contract/" since Snowflake uses prefix matching.
1024    let stage = format!(
1025        r#""{}"."{}"."{}"/{}/"#,
1026        database, schema, stage, target_table_name
1027    );
1028    let table_name = format!(r#""{}"."{}"."{}""#, database, schema, table_name);
1029    format!(
1030        "CREATE OR REPLACE PIPE {} AUTO_INGEST = FALSE AS COPY INTO {} FROM @{} MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE FILE_FORMAT = (type = 'JSON');",
1031        pipe_name, table_name, stage
1032    )
1033}
1034
1035fn build_flush_pipe_sql(database: &str, schema: &str, pipe_name: &str) -> String {
1036    let pipe_name = format!(r#""{}"."{}"."{}""#, database, schema, pipe_name);
1037    format!("ALTER PIPE {} REFRESH;", pipe_name,)
1038}
1039
1040fn build_alter_add_column_sql(
1041    table_name: &str,
1042    database: &str,
1043    schema: &str,
1044    columns: &Vec<(String, String)>,
1045) -> String {
1046    let full_table_name = build_full_table_name(database, schema, table_name);
1047    jdbc_jni_client::build_alter_add_column_sql(&full_table_name, columns, true)
1048}
1049
1050fn build_start_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1051    let SnowflakeTaskContext {
1052        task_name,
1053        database,
1054        schema_name: schema,
1055        ..
1056    } = snowflake_task_context;
1057    let full_task_name = format!(
1058        r#""{}"."{}"."{}""#,
1059        database,
1060        schema,
1061        task_name.as_ref().unwrap()
1062    );
1063    format!("ALTER TASK {} RESUME", full_task_name)
1064}
1065
1066fn build_drop_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1067    let SnowflakeTaskContext {
1068        task_name,
1069        database,
1070        schema_name: schema,
1071        ..
1072    } = snowflake_task_context;
1073    let full_task_name = format!(
1074        r#""{}"."{}"."{}""#,
1075        database,
1076        schema,
1077        task_name.as_ref().unwrap()
1078    );
1079    format!("DROP TASK IF EXISTS {}", full_task_name)
1080}
1081
1082fn build_create_merge_into_task_sql(snowflake_task_context: &SnowflakeTaskContext) -> String {
1083    let SnowflakeTaskContext {
1084        task_name,
1085        cdc_table_name,
1086        target_table_name,
1087        writer_target_interval_seconds,
1088        warehouse,
1089        task_serverless,
1090        task_target_completion_interval,
1091        pk_column_names,
1092        all_column_names,
1093        database,
1094        schema_name,
1095        ..
1096    } = snowflake_task_context;
1097    let full_task_name = format!(
1098        r#""{}"."{}"."{}""#,
1099        database,
1100        schema_name,
1101        task_name.as_ref().unwrap()
1102    );
1103    let full_cdc_table_name = format!(
1104        r#""{}"."{}"."{}""#,
1105        database,
1106        schema_name,
1107        cdc_table_name.as_ref().unwrap()
1108    );
1109    let full_target_table_name = format!(
1110        r#""{}"."{}"."{}""#,
1111        database, schema_name, target_table_name
1112    );
1113
1114    let pk_names_str = pk_column_names
1115        .as_ref()
1116        .unwrap()
1117        .iter()
1118        .map(|name| format!(r#""{}""#, name))
1119        .collect::<Vec<String>>()
1120        .join(", ");
1121    let pk_names_eq_str = pk_column_names
1122        .as_ref()
1123        .unwrap()
1124        .iter()
1125        .map(|name| format!(r#"target."{}" = source."{}""#, name, name))
1126        .collect::<Vec<String>>()
1127        .join(" AND ");
1128    let all_column_names_set_str = all_column_names
1129        .as_ref()
1130        .unwrap()
1131        .iter()
1132        .map(|name| format!(r#"target."{}" = source."{}""#, name, name))
1133        .collect::<Vec<String>>()
1134        .join(", ");
1135    let all_column_names_str = all_column_names
1136        .as_ref()
1137        .unwrap()
1138        .iter()
1139        .map(|name| format!(r#""{}""#, name))
1140        .collect::<Vec<String>>()
1141        .join(", ");
1142    let all_column_names_insert_str = all_column_names
1143        .as_ref()
1144        .unwrap()
1145        .iter()
1146        .map(|name| format!(r#"source."{}""#, name))
1147        .collect::<Vec<String>>()
1148        .join(", ");
1149
1150    let compute_clause = if *task_serverless {
1151        task_target_completion_interval
1152            .as_ref()
1153            .map(|interval| format!("TARGET_COMPLETION_INTERVAL = '{}'", interval))
1154    } else {
1155        Some(format!("WAREHOUSE = {}", warehouse.as_ref().unwrap()))
1156    };
1157
1158    format!(
1159        r#"CREATE OR REPLACE TASK {task_name}
1160{compute_clause}
1161SCHEDULE = '{writer_target_interval_seconds} SECONDS'
1162AS
1163BEGIN
1164    LET max_row_id STRING;
1165
1166    SELECT COALESCE(MAX("{snowflake_sink_row_id}"), '0') INTO :max_row_id
1167    FROM {cdc_table_name};
1168
1169    MERGE INTO {target_table_name} AS target
1170    USING (
1171        SELECT *
1172        FROM (
1173            SELECT *, ROW_NUMBER() OVER (PARTITION BY {pk_names_str} ORDER BY "{snowflake_sink_row_id}" DESC) AS dedupe_id
1174            FROM {cdc_table_name}
1175            WHERE "{snowflake_sink_row_id}" <= :max_row_id
1176        ) AS subquery
1177        WHERE dedupe_id = 1
1178    ) AS source
1179    ON {pk_names_eq_str}
1180    WHEN MATCHED AND source."{snowflake_sink_op}" IN (2, 4) THEN DELETE
1181    WHEN MATCHED AND source."{snowflake_sink_op}" IN (1, 3) THEN UPDATE SET {all_column_names_set_str}
1182    WHEN NOT MATCHED AND source."{snowflake_sink_op}" IN (1, 3) THEN INSERT ({all_column_names_str}) VALUES ({all_column_names_insert_str});
1183
1184    DELETE FROM {cdc_table_name}
1185    WHERE "{snowflake_sink_row_id}" <= :max_row_id;
1186END;"#,
1187        task_name = full_task_name,
1188        compute_clause = compute_clause
1189            .map(|clause| format!("{clause}\n"))
1190            .unwrap_or_default(),
1191        writer_target_interval_seconds = writer_target_interval_seconds,
1192        cdc_table_name = full_cdc_table_name,
1193        target_table_name = full_target_table_name,
1194        pk_names_str = pk_names_str,
1195        pk_names_eq_str = pk_names_eq_str,
1196        all_column_names_set_str = all_column_names_set_str,
1197        all_column_names_str = all_column_names_str,
1198        all_column_names_insert_str = all_column_names_insert_str,
1199        snowflake_sink_row_id = __ROW_ID,
1200        snowflake_sink_op = __OP,
1201    )
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use std::collections::BTreeMap;
1207
1208    use super::*;
1209    use crate::sink::jdbc_jni_client::normalize_sql;
1210
1211    fn base_properties() -> BTreeMap<String, String> {
1212        BTreeMap::from([
1213            ("type".to_owned(), "append-only".to_owned()),
1214            ("jdbc.url".to_owned(), "jdbc:snowflake://account".to_owned()),
1215            ("username".to_owned(), "RW_USER".to_owned()),
1216        ])
1217    }
1218
1219    #[test]
1220    fn test_build_jdbc_props_password() {
1221        let mut props = base_properties();
1222        props.insert("password".to_owned(), "secret".to_owned());
1223        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1224        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1225        assert_eq!(url, "jdbc:snowflake://account");
1226        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1227        assert_eq!(map.get("user"), Some(&"RW_USER".to_owned()));
1228        assert_eq!(map.get("password"), Some(&"secret".to_owned()));
1229        assert!(!map.contains_key("authenticator"));
1230    }
1231
1232    #[test]
1233    fn test_build_jdbc_props_key_pair_file() {
1234        let mut props = base_properties();
1235        props.insert(
1236            "auth.method".to_owned(),
1237            AUTH_METHOD_KEY_PAIR_FILE.to_owned(),
1238        );
1239        props.insert("private_key_file".to_owned(), "/tmp/rsa_key.p8".to_owned());
1240        props.insert("private_key_file_pwd".to_owned(), "dummy".to_owned());
1241        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1242        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1243        assert_eq!(url, "jdbc:snowflake://account");
1244        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1245        assert_eq!(map.get("user"), Some(&"RW_USER".to_owned()));
1246        assert_eq!(
1247            map.get("private_key_file"),
1248            Some(&"/tmp/rsa_key.p8".to_owned())
1249        );
1250        assert_eq!(map.get("private_key_file_pwd"), Some(&"dummy".to_owned()));
1251    }
1252
1253    #[test]
1254    fn test_build_jdbc_props_key_pair_object() {
1255        let mut props = base_properties();
1256        props.insert(
1257            "auth.method".to_owned(),
1258            AUTH_METHOD_KEY_PAIR_OBJECT.to_owned(),
1259        );
1260        props.insert(
1261            "private_key_pem".to_owned(),
1262            "-----BEGIN PRIVATE KEY-----
1263...
1264-----END PRIVATE KEY-----"
1265                .to_owned(),
1266        );
1267        let config = SnowflakeV2Config::from_btreemap(&props).unwrap();
1268        let (url, connection_properties) = config.build_jdbc_connection_properties().unwrap();
1269        assert_eq!(url, "jdbc:snowflake://account");
1270        let map: BTreeMap<_, _> = connection_properties.into_iter().collect();
1271        assert_eq!(
1272            map.get("private_key_pem"),
1273            Some(
1274                &"-----BEGIN PRIVATE KEY-----
1275...
1276-----END PRIVATE KEY-----"
1277                    .to_owned()
1278            )
1279        );
1280        assert!(!map.contains_key("private_key_file"));
1281    }
1282
1283    #[test]
1284    fn test_snowflake_task_target_completion_interval_requires_serverless() {
1285        let mut props = base_properties();
1286        props.insert("password".to_owned(), "secret".to_owned());
1287        props.insert("type".to_owned(), "upsert".to_owned());
1288        props.insert(
1289            "task.target_completion_interval".to_owned(),
1290            "5 MINUTES".to_owned(),
1291        );
1292
1293        let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1294        assert!(
1295            err.as_report().to_string().contains(
1296                "`task.target_completion_interval` requires `task.serverless` to be true"
1297            )
1298        );
1299    }
1300
1301    #[test]
1302    fn test_snowflake_serverless_task_rejects_warehouse() {
1303        let mut props = base_properties();
1304        props.insert("password".to_owned(), "secret".to_owned());
1305        props.insert("type".to_owned(), "upsert".to_owned());
1306        props.insert("task.serverless".to_owned(), "true".to_owned());
1307        props.insert("warehouse".to_owned(), "test_warehouse".to_owned());
1308
1309        let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1310        assert!(
1311            err.as_report()
1312                .to_string()
1313                .contains("`task.serverless` must not be combined with `warehouse`")
1314        );
1315    }
1316
1317    #[test]
1318    fn test_snowflake_append_only_rejects_upsert_task_options() {
1319        for (key, value) in [
1320            ("intermediate.table.name", "test_intermediate"),
1321            ("write.target.interval.seconds", "3600"),
1322            ("warehouse", "test_warehouse"),
1323            ("task.serverless", "true"),
1324            ("task.target_completion_interval", "5 MINUTES"),
1325        ] {
1326            let mut props = base_properties();
1327            props.insert("password".to_owned(), "secret".to_owned());
1328            props.insert(key.to_owned(), value.to_owned());
1329
1330            let err = SnowflakeV2Config::from_btreemap(&props).unwrap_err();
1331            assert!(
1332                err.as_report().to_string().contains(
1333                    "`intermediate.table.name`, `write.target.interval.seconds`, `warehouse`, \
1334                 `task.serverless`, and `task.target_completion_interval` require `type` = upsert"
1335                ),
1336                "option {key} should be rejected for append-only sink"
1337            );
1338        }
1339    }
1340
1341    #[test]
1342    fn test_snowflake_sink_commit_coordinator() {
1343        let snowflake_task_context = SnowflakeTaskContext {
1344            task_name: Some("test_task".to_owned()),
1345            cdc_table_name: Some("test_cdc_table".to_owned()),
1346            target_table_name: "test_target_table".to_owned(),
1347            writer_target_interval_seconds: 3600,
1348            warehouse: Some("test_warehouse".to_owned()),
1349            task_serverless: false,
1350            task_target_completion_interval: None,
1351            pk_column_names: Some(vec!["v1".to_owned()]),
1352            all_column_names: Some(vec!["v1".to_owned(), "v2".to_owned()]),
1353            database: "test_db".to_owned(),
1354            schema_name: "test_schema".to_owned(),
1355            schema: Schema { fields: vec![] },
1356            stage: None,
1357            pipe_name: None,
1358        };
1359        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1360        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_task"
1361WAREHOUSE = test_warehouse
1362SCHEDULE = '3600 SECONDS'
1363AS
1364BEGIN
1365    LET max_row_id STRING;
1366
1367    SELECT COALESCE(MAX("__row_id"), '0') INTO :max_row_id
1368    FROM "test_db"."test_schema"."test_cdc_table";
1369
1370    MERGE INTO "test_db"."test_schema"."test_target_table" AS target
1371    USING (
1372        SELECT *
1373        FROM (
1374            SELECT *, ROW_NUMBER() OVER (PARTITION BY "v1" ORDER BY "__row_id" DESC) AS dedupe_id
1375            FROM "test_db"."test_schema"."test_cdc_table"
1376            WHERE "__row_id" <= :max_row_id
1377        ) AS subquery
1378        WHERE dedupe_id = 1
1379    ) AS source
1380    ON target."v1" = source."v1"
1381    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1382    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."v1" = source."v1", target."v2" = source."v2"
1383    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("v1", "v2") VALUES (source."v1", source."v2");
1384
1385    DELETE FROM "test_db"."test_schema"."test_cdc_table"
1386    WHERE "__row_id" <= :max_row_id;
1387END;"#;
1388        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1389    }
1390
1391    #[test]
1392    fn test_snowflake_sink_commit_coordinator_multi_pk() {
1393        let snowflake_task_context = SnowflakeTaskContext {
1394            task_name: Some("test_task_multi_pk".to_owned()),
1395            cdc_table_name: Some("cdc_multi_pk".to_owned()),
1396            target_table_name: "target_multi_pk".to_owned(),
1397            writer_target_interval_seconds: 300,
1398            warehouse: Some("multi_pk_warehouse".to_owned()),
1399            task_serverless: false,
1400            task_target_completion_interval: None,
1401            pk_column_names: Some(vec!["id1".to_owned(), "id2".to_owned()]),
1402            all_column_names: Some(vec!["id1".to_owned(), "id2".to_owned(), "val".to_owned()]),
1403            database: "test_db".to_owned(),
1404            schema_name: "test_schema".to_owned(),
1405            schema: Schema { fields: vec![] },
1406            stage: None,
1407            pipe_name: None,
1408        };
1409        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1410        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_task_multi_pk"
1411WAREHOUSE = multi_pk_warehouse
1412SCHEDULE = '300 SECONDS'
1413AS
1414BEGIN
1415    LET max_row_id STRING;
1416
1417    SELECT COALESCE(MAX("__row_id"), '0') INTO :max_row_id
1418    FROM "test_db"."test_schema"."cdc_multi_pk";
1419
1420    MERGE INTO "test_db"."test_schema"."target_multi_pk" AS target
1421    USING (
1422        SELECT *
1423        FROM (
1424            SELECT *, ROW_NUMBER() OVER (PARTITION BY "id1", "id2" ORDER BY "__row_id" DESC) AS dedupe_id
1425            FROM "test_db"."test_schema"."cdc_multi_pk"
1426            WHERE "__row_id" <= :max_row_id
1427        ) AS subquery
1428        WHERE dedupe_id = 1
1429    ) AS source
1430    ON target."id1" = source."id1" AND target."id2" = source."id2"
1431    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1432    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."id1" = source."id1", target."id2" = source."id2", target."val" = source."val"
1433    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("id1", "id2", "val") VALUES (source."id1", source."id2", source."val");
1434
1435    DELETE FROM "test_db"."test_schema"."cdc_multi_pk"
1436    WHERE "__row_id" <= :max_row_id;
1437END;"#;
1438        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1439    }
1440
1441    #[test]
1442    fn test_snowflake_sink_commit_coordinator_serverless_task() {
1443        let snowflake_task_context = SnowflakeTaskContext {
1444            task_name: Some("test_serverless_task".to_owned()),
1445            cdc_table_name: Some("serverless_cdc_table".to_owned()),
1446            target_table_name: "serverless_target_table".to_owned(),
1447            writer_target_interval_seconds: 120,
1448            warehouse: None,
1449            task_serverless: true,
1450            task_target_completion_interval: Some("5 MINUTES".to_owned()),
1451            pk_column_names: Some(vec!["id".to_owned()]),
1452            all_column_names: Some(vec!["id".to_owned(), "val".to_owned()]),
1453            database: "test_db".to_owned(),
1454            schema_name: "test_schema".to_owned(),
1455            schema: Schema { fields: vec![] },
1456            stage: None,
1457            pipe_name: None,
1458        };
1459        let task_sql = build_create_merge_into_task_sql(&snowflake_task_context);
1460        let expected = r#"CREATE OR REPLACE TASK "test_db"."test_schema"."test_serverless_task"
1461TARGET_COMPLETION_INTERVAL = '5 MINUTES'
1462SCHEDULE = '120 SECONDS'
1463AS
1464BEGIN
1465    LET max_row_id STRING;
1466
1467    SELECT COALESCE(MAX("__row_id"), '0') INTO :max_row_id
1468    FROM "test_db"."test_schema"."serverless_cdc_table";
1469
1470    MERGE INTO "test_db"."test_schema"."serverless_target_table" AS target
1471    USING (
1472        SELECT *
1473        FROM (
1474            SELECT *, ROW_NUMBER() OVER (PARTITION BY "id" ORDER BY "__row_id" DESC) AS dedupe_id
1475            FROM "test_db"."test_schema"."serverless_cdc_table"
1476            WHERE "__row_id" <= :max_row_id
1477        ) AS subquery
1478        WHERE dedupe_id = 1
1479    ) AS source
1480    ON target."id" = source."id"
1481    WHEN MATCHED AND source."__op" IN (2, 4) THEN DELETE
1482    WHEN MATCHED AND source."__op" IN (1, 3) THEN UPDATE SET target."id" = source."id", target."val" = source."val"
1483    WHEN NOT MATCHED AND source."__op" IN (1, 3) THEN INSERT ("id", "val") VALUES (source."id", source."val");
1484
1485    DELETE FROM "test_db"."test_schema"."serverless_cdc_table"
1486    WHERE "__row_id" <= :max_row_id;
1487END;"#;
1488        assert_eq!(normalize_sql(&task_sql), normalize_sql(expected));
1489    }
1490
1491    #[test]
1492    fn test_build_create_pipe_sql_stage_has_trailing_slash() {
1493        let sql = build_create_pipe_sql(
1494            "reservations_intermediate",
1495            "test_db",
1496            "test_schema",
1497            "RW_S3_STAGE",
1498            "reservations_pipe",
1499            "reservations",
1500        );
1501        assert!(
1502            sql.contains(r#"FROM @"test_db"."test_schema"."RW_S3_STAGE"/reservations/ "#),
1503            "unexpected pipe sql: {sql}"
1504        );
1505    }
1506
1507    #[test]
1508    fn test_convert_snowflake_decimal_data_type() {
1509        assert_eq!(
1510            convert_snowflake_data_type(&DataType::Decimal).unwrap(),
1511            "DECIMAL(38, 10)"
1512        );
1513    }
1514}