Skip to main content

risingwave_connector/sink/snowflake_redshift/
redshift.rs

1// Copyright 2025 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use core::num::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    // redshift does not support add column IF NOT EXISTS yet.
71    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 // Default to 1 hour
129}
130
131fn default_intermediate_interval_schedule() -> u64 {
132    1800 // Default to 0.5 hour
133}
134
135fn default_batch_insert_rows() -> u32 {
136    4096 // Default batch size
137}
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        // Send shutdown signal to the periodic task
605        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        // Write manifest file to S3 instead of inserting to database
673        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            // Send shutdown signal to the periodic task before altering the table
705            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        // Redshift's DECIMAL without explicit precision defaults to (38,0), which drops all
863        // fractional digits. We use DECIMAL(38, 10) consistent with the Iceberg/Snowflake sink
864        // convention, though values with more than 10 fractional digits may still lose precision.
865        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}