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