1use core::num::NonZero;
16use std::fmt::Write;
17use std::time::Duration;
18
19use anyhow::anyhow;
20use bytes::BytesMut;
21use phf::{Set, phf_set};
22use risingwave_common::array::StreamChunk;
23use risingwave_common::catalog::Schema;
24use risingwave_common::types::DataType;
25use risingwave_pb::connector_service::sink_metadata::SerializedMetadata;
26use risingwave_pb::connector_service::{SinkMetadata, sink_metadata};
27use risingwave_pb::stream_plan::PbSinkSchemaChange;
28use serde::Deserialize;
29use serde_json::json;
30use serde_with::{DisplayFromStr, serde_as};
31use thiserror_ext::AsReport;
32use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
33use tokio::time::{MissedTickBehavior, interval};
34use tonic::async_trait;
35use tracing::warn;
36use with_options::WithOptions;
37
38use crate::connector_common::IcebergSinkCompactionUpdate;
39use crate::enforce_secret::EnforceSecret;
40use crate::sink::catalog::SinkId;
41use crate::sink::coordinate::CoordinatedLogSinker;
42use crate::sink::file_sink::opendal_sink::FileSink;
43use crate::sink::file_sink::s3::{S3Common, S3Sink};
44use crate::sink::jdbc_jni_client::{self, JdbcJniClient};
45use crate::sink::snowflake_redshift::{
46 __OP, __ROW_ID, SnowflakeRedshiftSinkJdbcWriter, SnowflakeRedshiftSinkS3Writer,
47 build_opendal_writer_path,
48};
49use crate::sink::writer::SinkWriter;
50use crate::sink::{
51 Result, SinglePhaseCommitCoordinator, Sink, SinkCommitCoordinator, SinkError, SinkParam,
52};
53
54pub const REDSHIFT_SINK: &str = "redshift";
55
56pub fn build_full_table_name(schema_name: Option<&str>, table_name: &str) -> String {
57 if let Some(schema_name) = schema_name {
58 format!(r#""{}"."{}""#, schema_name, table_name)
59 } else {
60 format!(r#""{}""#, table_name)
61 }
62}
63
64fn build_alter_add_column_sql(
65 schema_name: Option<&str>,
66 table_name: &str,
67 columns: &Vec<(String, String)>,
68) -> String {
69 let full_table_name = build_full_table_name(schema_name, table_name);
70 jdbc_jni_client::build_alter_add_column_sql(&full_table_name, columns, false)
72}
73
74#[serde_as]
75#[derive(Debug, Clone, Deserialize, WithOptions)]
76pub struct RedShiftConfig {
77 #[serde(rename = "jdbc.url")]
78 pub jdbc_url: String,
79
80 #[serde(rename = "user")]
81 pub username: Option<String>,
82
83 #[serde(rename = "password")]
84 pub password: Option<String>,
85
86 #[serde(rename = "schema")]
87 pub schema: Option<String>,
88
89 #[serde(rename = "intermediate.schema.name")]
90 pub intermediate_schema: Option<String>,
91
92 #[serde(rename = "table.name")]
93 pub table: String,
94
95 #[serde(rename = "intermediate.table.name")]
96 pub cdc_table: Option<String>,
97
98 #[serde(default)]
99 #[serde(rename = "create_table_if_not_exists")]
100 #[serde_as(as = "DisplayFromStr")]
101 pub create_table_if_not_exists: bool,
102
103 #[serde(default = "default_target_interval_schedule")]
104 #[serde(rename = "write.target.interval.seconds")]
105 #[serde_as(as = "DisplayFromStr")]
106 pub writer_target_interval_seconds: u64,
107
108 #[serde(default = "default_intermediate_interval_schedule")]
109 #[serde(rename = "write.intermediate.interval.seconds")]
110 #[serde_as(as = "DisplayFromStr")]
111 pub write_intermediate_interval_seconds: u64,
112
113 #[serde(default = "default_batch_insert_rows")]
114 #[serde(rename = "batch.insert.rows")]
115 #[serde_as(as = "DisplayFromStr")]
116 pub batch_insert_rows: u32,
117
118 #[serde(default = "default_with_s3")]
119 #[serde(rename = "with_s3")]
120 #[serde_as(as = "DisplayFromStr")]
121 pub with_s3: bool,
122
123 #[serde(flatten)]
124 pub s3_inner: Option<S3Common>,
125}
126
127fn default_target_interval_schedule() -> u64 {
128 3600 }
130
131fn default_intermediate_interval_schedule() -> u64 {
132 1800 }
134
135fn default_batch_insert_rows() -> u32 {
136 4096 }
138
139fn default_with_s3() -> bool {
140 true
141}
142
143impl RedShiftConfig {
144 pub fn build_client(&self) -> Result<JdbcJniClient> {
145 let mut jdbc_url = self.jdbc_url.clone();
146 if let Some(username) = &self.username {
147 jdbc_url = format!("{}?user={}", jdbc_url, username);
148 }
149 if let Some(password) = &self.password {
150 jdbc_url = format!("{}&password={}", jdbc_url, password);
151 }
152 JdbcJniClient::new(jdbc_url)
153 }
154}
155
156#[derive(Debug)]
157pub struct RedshiftSink {
158 config: RedShiftConfig,
159 param: SinkParam,
160 is_append_only: bool,
161 schema: Schema,
162 pk_indices: Vec<usize>,
163}
164impl EnforceSecret for RedshiftSink {
165 const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
166 "user",
167 "password",
168 "jdbc.url"
169 };
170}
171
172impl TryFrom<SinkParam> for RedshiftSink {
173 type Error = SinkError;
174
175 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
176 let config = serde_json::from_value::<RedShiftConfig>(
177 serde_json::to_value(param.properties.clone()).unwrap(),
178 )
179 .map_err(|e| SinkError::Config(anyhow!(e)))?;
180 let is_append_only = param.sink_type.is_append_only();
181 let schema = param.schema();
182 let pk_indices = param.downstream_pk_or_empty();
183 Ok(Self {
184 config,
185 param,
186 is_append_only,
187 schema,
188 pk_indices,
189 })
190 }
191}
192
193impl Sink for RedshiftSink {
194 type LogSinker = CoordinatedLogSinker<RedShiftSinkWriter>;
195
196 const SINK_NAME: &'static str = REDSHIFT_SINK;
197
198 async fn validate(&self) -> Result<()> {
199 if self.config.create_table_if_not_exists {
200 let client = self.config.build_client()?;
201 let schema = self.param.schema();
202 let build_table_sql = build_create_table_sql(
203 self.config.schema.as_deref(),
204 &self.config.table,
205 &schema,
206 false,
207 )?;
208 client.execute_sql_sync(vec![build_table_sql]).await?;
209 if !self.is_append_only {
210 let cdc_table = self.config.cdc_table.as_ref().ok_or_else(|| {
211 SinkError::Config(anyhow!(
212 "intermediate.table.name is required for append-only sink"
213 ))
214 })?;
215 let cdc_schema_for_create = self
216 .config
217 .intermediate_schema
218 .as_deref()
219 .or(self.config.schema.as_deref());
220 let build_cdc_table_sql =
221 build_create_table_sql(cdc_schema_for_create, cdc_table, &schema, true)?;
222 client.execute_sql_sync(vec![build_cdc_table_sql]).await?;
223 }
224 }
225 Ok(())
226 }
227
228 fn support_schema_change() -> bool {
229 true
230 }
231
232 async fn new_log_sinker(
233 &self,
234 writer_param: crate::sink::SinkWriterParam,
235 ) -> Result<Self::LogSinker> {
236 let writer = RedShiftSinkWriter::new(
237 self.config.clone(),
238 self.is_append_only,
239 writer_param.clone(),
240 self.param.clone(),
241 )
242 .await?;
243 CoordinatedLogSinker::new(
244 &writer_param,
245 self.param.clone(),
246 writer,
247 NonZero::new(1).unwrap(),
248 )
249 .await
250 }
251
252 fn is_coordinated_sink(&self) -> bool {
253 true
254 }
255
256 async fn new_coordinator(
257 &self,
258 _iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
259 ) -> Result<SinkCommitCoordinator> {
260 let pk_column_names: Vec<_> = self
261 .schema
262 .fields
263 .iter()
264 .enumerate()
265 .filter(|(index, _)| self.pk_indices.contains(index))
266 .map(|(_, field)| field.name.clone())
267 .collect();
268 if pk_column_names.is_empty() && !self.is_append_only {
269 return Err(SinkError::Config(anyhow!(
270 "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."
271 )));
272 }
273 let all_column_names = self
274 .schema
275 .fields
276 .iter()
277 .map(|field| field.name.clone())
278 .collect();
279 let coordinator = RedshiftSinkCommitter::new(
280 self.config.clone(),
281 self.is_append_only,
282 &pk_column_names,
283 &all_column_names,
284 self.param.sink_id,
285 )?;
286 Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
287 }
288}
289
290pub enum RedShiftSinkWriter {
291 S3(SnowflakeRedshiftSinkS3Writer),
292 Jdbc(SnowflakeRedshiftSinkJdbcWriter),
293}
294
295impl RedShiftSinkWriter {
296 pub async fn new(
297 config: RedShiftConfig,
298 is_append_only: bool,
299 writer_param: super::SinkWriterParam,
300 mut param: SinkParam,
301 ) -> Result<Self> {
302 let schema = param.schema();
303 if config.with_s3 {
304 let s3_writer = SnowflakeRedshiftSinkS3Writer::new(
305 config.s3_inner.ok_or_else(|| {
306 SinkError::Config(anyhow!("S3 configuration is required for S3 sink"))
307 })?,
308 schema,
309 is_append_only,
310 config.table,
311 )?;
312 Ok(Self::S3(s3_writer))
313 } else {
314 let (writer_schema, writer_table) = if is_append_only {
315 (config.schema.clone(), config.table.clone())
316 } else {
317 (
318 config.intermediate_schema.clone().or(config.schema.clone()),
319 config.cdc_table.clone().ok_or_else(|| {
320 SinkError::Config(anyhow!(
321 "intermediate.table.name is required for non-append-only sink"
322 ))
323 })?,
324 )
325 };
326 param.properties.remove("schema");
327 param.properties.remove("schema.name");
328 if let Some(writer_schema) = writer_schema {
329 param.properties.insert("schema".to_owned(), writer_schema);
330 }
331 let jdbc_writer = SnowflakeRedshiftSinkJdbcWriter::new(
332 is_append_only,
333 writer_param,
334 param,
335 writer_table,
336 )
337 .await?;
338 Ok(Self::Jdbc(jdbc_writer))
339 }
340 }
341}
342
343#[async_trait]
344impl SinkWriter for RedShiftSinkWriter {
345 type CommitMetadata = Option<SinkMetadata>;
346
347 async fn begin_epoch(&mut self, epoch: u64) -> Result<()> {
348 match self {
349 Self::S3(writer) => writer.begin_epoch(epoch),
350 Self::Jdbc(writer) => writer.begin_epoch(epoch).await,
351 }
352 }
353
354 async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
355 match self {
356 Self::S3(writer) => writer.write_batch(chunk).await,
357 Self::Jdbc(writer) => writer.write_batch(chunk).await,
358 }
359 }
360
361 async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
362 let metadata = match self {
363 Self::S3(writer) => {
364 if let Some(path) = writer.barrier(is_checkpoint).await? {
365 path.into_bytes()
366 } else {
367 vec![]
368 }
369 }
370 Self::Jdbc(writer) => {
371 writer.barrier(is_checkpoint).await?;
372 vec![]
373 }
374 };
375 Ok(Some(SinkMetadata {
376 metadata: Some(sink_metadata::Metadata::Serialized(SerializedMetadata {
377 metadata,
378 })),
379 }))
380 }
381
382 async fn abort(&mut self) -> Result<()> {
383 if let Self::Jdbc(writer) = self {
384 writer.abort().await
385 } else {
386 Ok(())
387 }
388 }
389}
390
391pub struct RedshiftSinkCommitter {
392 config: RedShiftConfig,
393 client: JdbcJniClient,
394 sink_id: SinkId,
395 pk_column_names: Vec<String>,
396 all_column_names: Vec<String>,
397 writer_target_interval_seconds: u64,
398 write_intermediate_interval_seconds: u64,
399 is_append_only: bool,
400 periodic_task_handle: Option<tokio::task::JoinHandle<()>>,
401 shutdown_sender: Option<tokio::sync::mpsc::UnboundedSender<()>>,
402}
403
404impl RedshiftSinkCommitter {
405 pub fn new(
406 config: RedShiftConfig,
407 is_append_only: bool,
408 pk_column_names: &Vec<String>,
409 all_column_names: &Vec<String>,
410 sink_id: SinkId,
411 ) -> Result<Self> {
412 let client = config.build_client()?;
413 let writer_target_interval_seconds = config.writer_target_interval_seconds;
414 let write_intermediate_interval_seconds = config.write_intermediate_interval_seconds;
415
416 let (periodic_task_handle, shutdown_sender) = match (is_append_only, config.with_s3) {
417 (true, true) | (false, _) => {
418 let task_client = config.build_client()?;
419 let config = config.clone();
420 let (shutdown_sender, shutdown_receiver) = unbounded_channel();
421 let target_schema_name = config.schema.as_deref();
422 let effective_cdc_schema =
423 config.intermediate_schema.as_deref().or(target_schema_name);
424 let merge_into_sql = if !is_append_only {
425 Some(build_create_merge_into_task_sql(
426 effective_cdc_schema,
427 target_schema_name,
428 config.cdc_table.as_ref().ok_or_else(|| {
429 SinkError::Config(anyhow!(
430 "intermediate.table.name is required for non-append-only sink"
431 ))
432 })?,
433 &config.table,
434 pk_column_names,
435 all_column_names,
436 ))
437 } else {
438 None
439 };
440 let periodic_task_handle = tokio::spawn(async move {
441 Self::run_periodic_query_task(
442 task_client,
443 merge_into_sql,
444 config.with_s3,
445 writer_target_interval_seconds,
446 write_intermediate_interval_seconds,
447 sink_id,
448 config,
449 is_append_only,
450 shutdown_receiver,
451 )
452 .await;
453 });
454 (Some(periodic_task_handle), Some(shutdown_sender))
455 }
456 _ => (None, None),
457 };
458
459 Ok(Self {
460 client,
461 config,
462 sink_id,
463 pk_column_names: pk_column_names.clone(),
464 all_column_names: all_column_names.clone(),
465 is_append_only,
466 writer_target_interval_seconds,
467 write_intermediate_interval_seconds,
468 periodic_task_handle,
469 shutdown_sender,
470 })
471 }
472
473 async fn flush_manifest_to_redshift(
474 client: &JdbcJniClient,
475 config: &RedShiftConfig,
476 s3_inner: &S3Common,
477 is_append_only: bool,
478 ) -> Result<()> {
479 let s3_operator = FileSink::<S3Sink>::new_s3_sink(s3_inner)?;
480 let mut manifest_path = s3_inner.path.clone().unwrap_or("".to_owned());
481 if !manifest_path.ends_with('/') {
482 manifest_path.push('/');
483 }
484 manifest_path.push_str(&format!("{}/", config.table));
485 manifest_path.push_str("manifest/");
486 let manifests = s3_operator
487 .list(&manifest_path)
488 .await?
489 .into_iter()
490 .map(|e| e.path().to_owned())
491 .collect::<Vec<_>>();
492 for manifest in &manifests {
493 Self::copy_into_from_s3_to_redshift(client, config, s3_inner, is_append_only, manifest)
494 .await?;
495 }
496 s3_operator.delete_iter(manifests).await?;
497 Ok(())
498 }
499
500 async fn write_manifest_to_s3(
501 s3_inner: &S3Common,
502 paths: Vec<String>,
503 table: &str,
504 ) -> Result<String> {
505 let manifest_entries: Vec<_> = paths
506 .into_iter()
507 .map(|path| json!({ "url": path, "mandatory": true }))
508 .collect();
509 let s3_operator = FileSink::<S3Sink>::new_s3_sink(s3_inner)?;
510 let (mut writer, manifest_path) =
511 build_opendal_writer_path(s3_inner, &s3_operator, Some("manifest"), table).await?;
512 let manifest_json = json!({ "entries": manifest_entries });
513 let mut chunk_buf = BytesMut::new();
514 writeln!(chunk_buf, "{}", manifest_json).unwrap();
515 writer.write(chunk_buf.freeze()).await?;
516 writer.close().await.map_err(|e| {
517 SinkError::Redshift(anyhow!(
518 "Failed to close manifest writer: {}",
519 e.to_report_string()
520 ))
521 })?;
522 Ok(manifest_path)
523 }
524
525 pub async fn copy_into_from_s3_to_redshift(
526 client: &JdbcJniClient,
527 config: &RedShiftConfig,
528 s3_inner: &S3Common,
529 is_append_only: bool,
530 manifest: &str,
531 ) -> Result<()> {
532 let all_path = format!("s3://{}/{}", s3_inner.bucket_name, manifest);
533
534 let (table, schema_name) = if is_append_only {
535 (&config.table, config.schema.as_deref())
536 } else {
537 (
538 config.cdc_table.as_ref().ok_or_else(|| {
539 SinkError::Config(anyhow!(
540 "intermediate.table.name is required for non-append-only sink"
541 ))
542 })?,
543 config
544 .intermediate_schema
545 .as_deref()
546 .or(config.schema.as_deref()),
547 )
548 };
549 let copy_into_sql = build_copy_into_sql(
550 schema_name,
551 table,
552 &all_path,
553 &s3_inner.access,
554 &s3_inner.secret,
555 &s3_inner.assume_role,
556 )?;
557 client.execute_sql_sync(vec![copy_into_sql]).await?;
558 Ok(())
559 }
560
561 async fn run_periodic_query_task(
562 client: JdbcJniClient,
563 merge_into_sql: Option<Vec<String>>,
564 need_copy_into: bool,
565 writer_target_interval_seconds: u64,
566 write_intermediate_interval_seconds: u64,
567 sink_id: SinkId,
568 config: RedShiftConfig,
569 is_append_only: bool,
570 mut shutdown_receiver: tokio::sync::mpsc::UnboundedReceiver<()>,
571 ) {
572 let mut copy_timer = interval(Duration::from_secs(write_intermediate_interval_seconds));
573 copy_timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
574 let mut merge_timer = interval(Duration::from_secs(writer_target_interval_seconds));
575 merge_timer.set_missed_tick_behavior(MissedTickBehavior::Skip);
576
577 loop {
578 tokio::select! {
579 _ = shutdown_receiver.recv() => break,
580 _ = merge_timer.tick(), if merge_into_sql.is_some() => {
581 if let Some(sql) = &merge_into_sql && let Err(e) = client.execute_sql_sync(sql.clone()).await {
582 tracing::warn!("Failed to execute periodic query for table {}: {}", config.table, e.as_report());
583 }
584 },
585 _ = copy_timer.tick(), if need_copy_into => {
586 if let Err(e) = async {
587 let s3_inner = config.s3_inner.as_ref().ok_or_else(|| {
588 SinkError::Config(anyhow!("S3 configuration is required for redshift s3 sink"))
589 })?;
590 Self::flush_manifest_to_redshift(&client, &config,s3_inner, is_append_only).await?;
591 Ok::<(),SinkError>(())
592 }.await {
593 tracing::error!("Failed to execute copy into task for sink id {}: {}", sink_id, e.as_report());
594 }
595 }
596 }
597 }
598 tracing::info!("Periodic query task stopped for sink id {}", sink_id);
599 }
600}
601
602impl Drop for RedshiftSinkCommitter {
603 fn drop(&mut self) {
604 if let Some(shutdown_sender) = &self.shutdown_sender
606 && let Err(e) = shutdown_sender.send(())
607 {
608 tracing::warn!(
609 "Failed to send shutdown signal to periodic task: {}",
610 e.as_report()
611 );
612 }
613 tracing::info!("RedshiftSinkCommitter dropped, periodic task stopped");
614 }
615}
616
617#[async_trait]
618impl SinglePhaseCommitCoordinator for RedshiftSinkCommitter {
619 async fn init(&mut self) -> Result<()> {
620 if self.config.with_s3 {
621 Self::flush_manifest_to_redshift(
622 &self.client,
623 &self.config,
624 self.config.s3_inner.as_ref().ok_or_else(|| {
625 SinkError::Config(anyhow!("S3 configuration is required for redshift s3 sink"))
626 })?,
627 self.is_append_only,
628 )
629 .await?;
630 }
631 Ok(())
632 }
633
634 async fn commit_data(&mut self, epoch: u64, metadata: Vec<SinkMetadata>) -> Result<()> {
635 if let Some(handle) = &self.periodic_task_handle {
636 let is_finished = handle.is_finished();
637 if is_finished {
638 let handle = self.periodic_task_handle.take().unwrap();
639 handle.await.map_err(|e| {
640 SinkError::Redshift(anyhow!(
641 "Periodic task for sink id {} panicked: {}",
642 self.sink_id,
643 e.to_report_string()
644 ))
645 })?;
646 }
647 };
648 let paths = metadata
649 .into_iter()
650 .filter(|m| {
651 if let Some(sink_metadata::Metadata::Serialized(SerializedMetadata { metadata })) =
652 &m.metadata
653 {
654 !metadata.is_empty()
655 } else {
656 false
657 }
658 })
659 .map(|metadata| {
660 let path = if let Some(sink_metadata::Metadata::Serialized(SerializedMetadata {
661 metadata,
662 })) = metadata.metadata
663 {
664 String::from_utf8(metadata).map_err(|e| SinkError::Config(anyhow!(e)))
665 } else {
666 Err(SinkError::Config(anyhow!("Invalid metadata format")))
667 }?;
668 Ok(path)
669 })
670 .collect::<Result<Vec<_>>>()?;
671
672 if !paths.is_empty() {
674 let s3_inner = self.config.s3_inner.as_ref().ok_or_else(|| {
675 SinkError::Config(anyhow!("S3 configuration is required for S3 sink"))
676 })?;
677 {
678 Self::write_manifest_to_s3(s3_inner, paths, &self.config.table).await?;
679 }
680 tracing::info!(
681 "Manifest file written to S3 for sink id {} at epoch {}",
682 self.sink_id,
683 epoch
684 );
685 }
686 Ok(())
687 }
688
689 async fn commit_schema_change(
690 &mut self,
691 _epoch: u64,
692 schema_change: PbSinkSchemaChange,
693 ) -> Result<()> {
694 use risingwave_pb::stream_plan::sink_schema_change::PbOp as SinkSchemaChangeOp;
695 let schema_change_op = schema_change
696 .op
697 .ok_or_else(|| SinkError::Coordinator(anyhow!("Invalid schema change operation")))?;
698 let SinkSchemaChangeOp::AddColumns(add_columns) = schema_change_op else {
699 return Err(SinkError::Coordinator(anyhow!(
700 "Only AddColumns schema change is supported for Redshift sink"
701 )));
702 };
703 if let Some(shutdown_sender) = &self.shutdown_sender {
704 shutdown_sender
706 .send(())
707 .map_err(|e| SinkError::Config(anyhow!(e)))?;
708 }
709 let sql = build_alter_add_column_sql(
710 self.config.schema.as_deref(),
711 &self.config.table,
712 &add_columns
713 .fields
714 .iter()
715 .map(|f| {
716 let dt = DataType::from(f.data_type.as_ref().unwrap());
717 Ok((f.name.clone(), convert_redshift_data_type(&dt)?))
718 })
719 .collect::<Result<Vec<_>>>()?,
720 );
721 let check_column_exists = |e: anyhow::Error| {
722 let err_str = e.to_report_string();
723 if regex::Regex::new(".+ of relation .+ already exists")
724 .unwrap()
725 .find(&err_str)
726 .is_none()
727 {
728 return Err(e);
729 }
730 warn!("redshift sink columns already exists. skipped");
731 Ok(())
732 };
733 self.client
734 .execute_sql_sync(vec![sql.clone()])
735 .await
736 .or_else(check_column_exists)?;
737 let merge_into_sql = if !self.is_append_only {
738 let cdc_table_name = self.config.cdc_table.as_ref().ok_or_else(|| {
739 SinkError::Config(anyhow!(
740 "intermediate.table.name is required for non-append-only sink"
741 ))
742 })?;
743 let sql = build_alter_add_column_sql(
744 self.config
745 .intermediate_schema
746 .as_deref()
747 .or(self.config.schema.as_deref()),
748 cdc_table_name,
749 &add_columns
750 .fields
751 .iter()
752 .map(|f| {
753 let dt = DataType::from(f.data_type.as_ref().unwrap());
754 Ok((f.name.clone(), convert_redshift_data_type(&dt)?))
755 })
756 .collect::<Result<Vec<_>>>()?,
757 );
758 self.client
759 .execute_sql_sync(vec![sql.clone()])
760 .await
761 .or_else(check_column_exists)?;
762 self.all_column_names
763 .extend(add_columns.fields.iter().map(|f| f.name.clone()));
764 let target_schema_name = self.config.schema.as_deref();
765 let effective_cdc_schema = self
766 .config
767 .intermediate_schema
768 .as_deref()
769 .or(target_schema_name);
770 let merge_into_sql = build_create_merge_into_task_sql(
771 effective_cdc_schema,
772 target_schema_name,
773 self.config.cdc_table.as_ref().ok_or_else(|| {
774 SinkError::Config(anyhow!(
775 "intermediate.table.name is required for non-append-only sink"
776 ))
777 })?,
778 &self.config.table,
779 &self.pk_column_names,
780 &self.all_column_names,
781 );
782 Some(merge_into_sql)
783 } else {
784 None
785 };
786
787 if let Some(shutdown_sender) = self.shutdown_sender.take() {
788 let _ = shutdown_sender.send(());
789 }
790 if let Some(periodic_task_handle) = self.periodic_task_handle.take() {
791 let _ = periodic_task_handle.await;
792 }
793
794 let (shutdown_sender, shutdown_receiver) = unbounded_channel();
795 let client = self.client.clone();
796
797 let writer_target_interval_seconds = self.writer_target_interval_seconds;
798 let write_intermediate_interval_seconds = self.write_intermediate_interval_seconds;
799 let config = self.config.clone();
800 let sink_id = self.sink_id;
801 let is_append_only = self.is_append_only;
802 let periodic_task_handle = tokio::spawn(async move {
803 Self::run_periodic_query_task(
804 client,
805 merge_into_sql,
806 config.with_s3,
807 writer_target_interval_seconds,
808 write_intermediate_interval_seconds,
809 sink_id,
810 config,
811 is_append_only,
812 shutdown_receiver,
813 )
814 .await;
815 });
816 self.shutdown_sender = Some(shutdown_sender);
817 self.periodic_task_handle = Some(periodic_task_handle);
818
819 Ok(())
820 }
821}
822
823pub fn build_create_table_sql(
824 schema_name: Option<&str>,
825 table_name: &str,
826 schema: &Schema,
827 need_op_and_row_id: bool,
828) -> Result<String> {
829 let mut columns: Vec<String> = schema
830 .fields
831 .iter()
832 .map(|field| {
833 let data_type = convert_redshift_data_type(&field.data_type)?;
834 Ok(format!("{} {}", field.name, data_type))
835 })
836 .collect::<Result<Vec<String>>>()?;
837 if need_op_and_row_id {
838 columns.push(format!("{} VARCHAR(MAX)", __ROW_ID));
839 columns.push(format!("{} INT", __OP));
840 }
841 let columns_str = columns.join(", ");
842 let full_table_name = build_full_table_name(schema_name, table_name);
843 Ok(format!(
844 "CREATE TABLE IF NOT EXISTS {} ({})",
845 full_table_name, columns_str
846 ))
847}
848
849fn convert_redshift_data_type(data_type: &DataType) -> Result<String> {
850 let data_type = match data_type {
851 DataType::Int16 => "SMALLINT".to_owned(),
852 DataType::Int32 => "INTEGER".to_owned(),
853 DataType::Int64 => "BIGINT".to_owned(),
854 DataType::Float32 => "REAL".to_owned(),
855 DataType::Float64 => "FLOAT".to_owned(),
856 DataType::Boolean => "BOOLEAN".to_owned(),
857 DataType::Varchar => "VARCHAR(MAX)".to_owned(),
858 DataType::Date => "DATE".to_owned(),
859 DataType::Timestamp => "TIMESTAMP".to_owned(),
860 DataType::Timestamptz => "TIMESTAMPTZ".to_owned(),
861 DataType::Jsonb => "VARCHAR(MAX)".to_owned(),
862 DataType::Decimal => "DECIMAL(38, 10)".to_owned(),
866 DataType::Time => "TIME".to_owned(),
867 _ => {
868 return Err(SinkError::Config(anyhow!(
869 "Dont support auto create table for datatype: {}",
870 data_type
871 )));
872 }
873 };
874 Ok(data_type)
875}
876
877fn build_create_merge_into_task_sql(
878 cdc_schema_name: Option<&str>,
879 target_schema_name: Option<&str>,
880 cdc_table_name: &str,
881 target_table_name: &str,
882 pk_column_names: &Vec<String>,
883 all_column_names: &Vec<String>,
884) -> Vec<String> {
885 let cdc_table_name = build_full_table_name(cdc_schema_name, cdc_table_name);
886 let target_table_name = build_full_table_name(target_schema_name, target_table_name);
887 let pk_names_str = pk_column_names.join(", ");
888 let pk_names_eq_str = pk_column_names
889 .iter()
890 .map(|name| format!("{target_table_name}.{name} = source.{name}", name = name))
891 .collect::<Vec<String>>()
892 .join(" AND ");
893 let all_column_names_set_str = all_column_names
894 .iter()
895 .map(|name| format!("{name} = source.{name}", name = name))
896 .collect::<Vec<String>>()
897 .join(", ");
898 let all_column_names_str = all_column_names.join(", ");
899 let all_column_names_insert_str = all_column_names
900 .iter()
901 .map(|name| format!("source.{name}", name = name))
902 .collect::<Vec<String>>()
903 .join(", ");
904
905 vec![
906 format!(
907 r#"
908 CREATE TEMP TABLE max_id_table AS
909 SELECT COALESCE(MAX({redshift_sink_row_id}), '0') AS max_row_id
910 FROM {cdc_table_name};
911 "#,
912 redshift_sink_row_id = __ROW_ID,
913 cdc_table_name = cdc_table_name,
914 ),
915 format!(
916 r#"
917 DELETE FROM {target_table_name}
918 USING (
919 SELECT *
920 FROM (
921 SELECT *, ROW_NUMBER() OVER (
922 PARTITION BY {pk_names_str}
923 ORDER BY {redshift_sink_row_id} DESC
924 ) AS dedupe_id
925 FROM {cdc_table_name}, max_id_table
926 WHERE {cdc_table_name}.{redshift_sink_row_id} <= max_id_table.max_row_id
927 ) AS subquery
928 WHERE dedupe_id = 1 AND {redshift_sink_op} IN (2, 4)
929 ) AS source
930 WHERE {pk_names_eq_str};
931 "#,
932 target_table_name = target_table_name,
933 pk_names_str = pk_names_str,
934 redshift_sink_row_id = __ROW_ID,
935 cdc_table_name = cdc_table_name,
936 redshift_sink_op = __OP,
937 pk_names_eq_str = pk_names_eq_str,
938 ),
939 format!(
940 r#"
941 MERGE INTO {target_table_name}
942 USING (
943 SELECT *
944 FROM (
945 SELECT *, ROW_NUMBER() OVER (
946 PARTITION BY {pk_names_str}
947 ORDER BY {redshift_sink_row_id} DESC
948 ) AS dedupe_id
949 FROM {cdc_table_name}, max_id_table
950 WHERE {cdc_table_name}.{redshift_sink_row_id} <= max_id_table.max_row_id
951 ) AS subquery
952 WHERE dedupe_id = 1 AND {redshift_sink_op} IN (1, 3)
953 ) AS source
954 ON {pk_names_eq_str}
955 WHEN MATCHED THEN
956 UPDATE SET {all_column_names_set_str}
957 WHEN NOT MATCHED THEN
958 INSERT ({all_column_names_str}) VALUES ({all_column_names_insert_str});
959 "#,
960 target_table_name = target_table_name,
961 pk_names_str = pk_names_str,
962 redshift_sink_row_id = __ROW_ID,
963 cdc_table_name = cdc_table_name,
964 redshift_sink_op = __OP,
965 pk_names_eq_str = pk_names_eq_str,
966 all_column_names_set_str = all_column_names_set_str,
967 all_column_names_str = all_column_names_str,
968 all_column_names_insert_str = all_column_names_insert_str,
969 ),
970 format!(
971 r#"
972 DELETE FROM {cdc_table_name}
973 USING max_id_table
974 WHERE {cdc_table_name}.{redshift_sink_row_id} <= max_id_table.max_row_id;
975 "#,
976 cdc_table_name = cdc_table_name,
977 redshift_sink_row_id = __ROW_ID,
978 ),
979 "DROP TABLE IF EXISTS max_id_table;".to_owned(),
980 ]
981}
982
983fn build_copy_into_sql(
984 schema_name: Option<&str>,
985 table_name: &str,
986 manifest_dir: &str,
987 access_key: &Option<String>,
988 secret_key: &Option<String>,
989 assume_role: &Option<String>,
990) -> Result<String> {
991 let table_name = build_full_table_name(schema_name, table_name);
992 let credentials = if let Some(assume_role) = assume_role {
993 &format!("aws_iam_role={}", assume_role)
994 } else if let (Some(access_key), Some(secret_key)) = (access_key, secret_key) {
995 &format!(
996 "aws_access_key_id={};aws_secret_access_key={}",
997 access_key, secret_key
998 )
999 } else {
1000 return Err(SinkError::Config(anyhow!(
1001 "Either assume_role or access_key and secret_key must be provided for Redshift COPY command"
1002 )));
1003 };
1004 Ok(format!(
1005 r#"
1006 COPY {table_name}
1007 FROM '{manifest_dir}'
1008 CREDENTIALS '{credentials}'
1009 FORMAT AS JSON 'auto'
1010 DATEFORMAT 'auto'
1011 TIMEFORMAT 'auto'
1012 MANIFEST;
1013 "#,
1014 table_name = table_name,
1015 manifest_dir = manifest_dir,
1016 credentials = credentials
1017 ))
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 #[test]
1025 fn test_convert_redshift_decimal_data_type() {
1026 assert_eq!(
1027 convert_redshift_data_type(&DataType::Decimal).unwrap(),
1028 "DECIMAL(38, 10)"
1029 );
1030 }
1031}