1use 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 #[serde(rename = "auth.method")]
111 pub auth_method: Option<String>,
112
113 #[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 #[serde(rename = "private_key_pem")]
122 pub private_key_pem: Option<String>,
123
124 #[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 #[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 }
157
158fn default_intermediate_interval_schedule() -> u64 {
159 1800 }
161
162fn default_with_s3() -> bool {
163 true
164}
165
166impl SnowflakeV2Config {
167 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 match self.auth_method.as_deref().unwrap() {
186 AUTH_METHOD_PASSWORD => {
187 connection_properties.push(("password".to_owned(), self.password.clone().unwrap()));
189 }
190 AUTH_METHOD_KEY_PAIR_FILE => {
191 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 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 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 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 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 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 "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(¶m.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 pub target_table_name: String,
659 pub database: String,
660 pub schema_name: String,
661 pub schema: Schema,
662
663 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 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 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(()); }
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 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 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 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}