realonbebeto commented on code in PR #3654:
URL: https://github.com/apache/iggy/pull/3654#discussion_r3649213930


##########
core/connectors/sinks/redshift_sink/src/lib.rs:
##########
@@ -0,0 +1,1182 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod config;
+
+use std::{str::FromStr, sync::Arc, time::Duration};
+
+use arrow::{
+    array::{
+        ArrayRef, BinaryArray, Decimal256Array, Int32Array, Int64Array, 
RecordBatch, StringArray,
+        TimestampMicrosecondArray,
+    },
+    datatypes::{DataType, Field, Schema, TimeUnit},
+};
+use async_trait::async_trait;
+use humantime::Duration as HumanDuration;
+use iggy_connector_sdk::{
+    ConsumedMessage, Error, MessagesMetadata, Sink, TopicMetadata, 
sink_connector,
+};
+use parquet::arrow::ArrowWriter;
+use s3::{Bucket, Region, creds::Credentials};
+use secrecy::ExposeSecret;
+use sqlx::{AssertSqlSafe, Pool, Postgres, postgres::PgPoolOptions};
+use tokio::sync::Mutex;
+use uuid::Uuid;
+
+use crate::config::{PayloadFormat, RedshiftSinkConfig};
+
+sink_connector!(RedshiftSink);
+
+const DEFAULT_MAX_RETRIES: u32 = 3;
+const DEFAULT_RETRY_DELAY: &str = "1s";
+const DEFAULT_MAX_CONNECTIONS: u32 = 5;
+const DEFAULT_ARCHIVE_PREFIX: &str = "archive/messages";
+
+#[derive(Debug)]
+pub struct RedshiftSink {
+    pub id: u32,
+    config: RedshiftSinkConfig,
+    pool: Option<Pool<Postgres>>,
+    state: Mutex<State>,
+    verbose: bool,
+    bucket: Option<Box<Bucket>>,
+}
+
+#[async_trait]
+impl Sink for RedshiftSink {
+    async fn open(&mut self) -> Result<(), Error> {
+        tracing::info!(
+            sink_id = self.id,
+            table = %self.config.target_table, "opening Redshift sink 
connector"
+        );
+
+        self.connect().await?;
+        self.ensure_table_exists().await?;
+        Ok(())
+    }
+
+    async fn consume(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: MessagesMetadata,
+        messages: Vec<ConsumedMessage>,
+    ) -> Result<(), Error> {
+        tracing::debug!(
+            sink_id = self.id,
+            count = messages.len(),
+            "consuming messages"
+        );
+        self.process_messages(topic_metadata, &messages_metadata, &messages)
+            .await
+    }
+
+    async fn close(&mut self) -> Result<(), Error> {
+        tracing::info!(sink_id = self.id, "closing Redshift sink connector");
+
+        if let Some(pool) = self.pool.take() {
+            pool.close().await;
+
+            tracing::debug!(sink_id = self.id, "database pool closed");
+        }
+
+        let state = self.state.lock().await;
+
+        tracing::info!(
+            sink_id = self.id,
+            messages_processed = state.messages_processed,
+            batches_loaded = state.batches_loaded,
+            insertion_errors = state.insertion_errors,
+            "Redshift sink connector closed",
+        );
+
+        Ok(())
+    }
+}
+
+impl RedshiftSink {
+    pub fn new(id: u32, config: RedshiftSinkConfig) -> Self {
+        let verbose = config.verbose_logging.unwrap_or(false);
+
+        Self {
+            id,
+            config,
+            pool: None,
+            state: Mutex::new(State::default()),
+            verbose,
+            bucket: None,
+        }
+    }
+
+    async fn connect(&mut self) -> Result<(), Error> {
+        let max_connections = self
+            .config
+            .max_connections
+            .unwrap_or(DEFAULT_MAX_CONNECTIONS);
+
+        let redacted = 
redact_connection_string(self.config.connection_string.expose_secret());
+
+        tracing::info!(max_connections, dsn = %redacted, "connecting to 
Redshift");
+
+        let pool = PgPoolOptions::new()
+            .max_connections(max_connections)
+            .connect(self.config.connection_string.expose_secret())
+            .await
+            .map_err(|e| Error::InitError(format!("Failed to connect to 
Redshift: {e}")))?;
+
+        sqlx::query("SELECT 1").execute(&pool).await.map_err(|e| {
+            tracing::error!("Tracing failed: {:#?}", e);
+            Error::InitError(format!("Warehouse connectivity test failed: 
{e}"))
+        })?;
+
+        self.pool = Some(pool);
+        tracing::debug!("Redshift connection pool established");
+
+        let region = self.build_region()?;
+
+        let credentials = Credentials::new(
+            Some(self.config.aws_access_key_id.expose_secret()),
+            Some(self.config.aws_secret_access_key.expose_secret()),
+            None,
+            None,
+            None,
+        )
+        .map_err(|e| {
+            tracing::error!("Failed to create S3 credentials: {e}");
+            Error::InvalidConfig
+        })?;
+
+        let mut bucket = Bucket::new(&self.config.s3_bucket, region, 
credentials).map_err(|e| {
+            tracing::error!("Failed to create S3 bucket client: {e}");
+            Error::InvalidConfig
+        })?;
+
+        if self.config.s3_endpoint.is_some() {
+            bucket = bucket.with_path_style();
+        }
+
+        self.bucket = Some(bucket);
+
+        tracing::info!("Redshift sink connector ready");
+
+        Ok(())
+    }
+
+    fn build_region(&self) -> Result<Region, Error> {
+        if let Some(endpoint) = &self.config.s3_endpoint {
+            tracing::debug!(endpoint = %endpoint, "using custom S3 endpoint");
+            Ok(Region::Custom {
+                region: self.config.aws_region.clone(),
+                endpoint: endpoint.clone(),
+            })
+        } else {
+            Region::from_str(&self.config.aws_region).map_err(|_| 
Error::InvalidConfig)
+        }
+    }
+
+    async fn ensure_table_exists(&self) -> Result<(), Error> {
+        let pool = self.get_pool()?;
+
+        let table_name = &self.config.target_table;
+        let payload_type = self.payload_format().sql_type();
+
+        let (query, _) = self.build_create_table_sql()?;
+
+        tracing::debug!("ensuring target table exists");
+
+        sqlx::query(AssertSqlSafe(query))
+            .execute(pool)
+            .await
+            .map_err(|e| {
+                tracing::error!(error = %e);
+                Error::InitError(format!("Failed to create table 
'{table_name}': {e}"))
+            })?;
+
+        tracing::info!(table = %table_name, payload_type, "target table 
ready");
+
+        Ok(())
+    }
+
+    async fn process_messages(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+        messages: &[ConsumedMessage],
+    ) -> Result<(), Error> {
+        let batch_size = self.config.batch_size.unwrap_or(100) as usize;
+
+        for batch in messages.chunks(batch_size) {
+            match self
+                .insert_batch(batch, topic_metadata, messages_metadata)
+                .await
+            {
+                Ok(()) => {
+                    self.state.lock().await.batches_loaded += 1;
+                }
+                Err(e) => {
+                    self.state.lock().await.insertion_errors += batch.len() as 
u64;
+                    tracing::error!(error = %e, batch_size = batch.len(), 
"failed to insert batch");
+                }
+            }
+        }
+
+        let mut state = self.state.lock().await;
+        state.messages_processed += messages.len() as u64;
+
+        if self.verbose {
+            tracing::info!(
+                sink_id = self.id,
+                total_processed = state.messages_processed,
+                batch_received = messages.len(),
+                table = %self.config.target_table,
+                batches_loaded = state.batches_loaded,
+                "processed message batch"
+            );
+        } else {
+            tracing::debug!(
+                sink_id = self.id,
+                total_processed = state.messages_processed,
+                table = %self.config.target_table,
+                "processed message batch"
+            );
+        }
+
+        Ok(())
+    }
+
+    async fn insert_batch(
+        &self,
+        messages: &[ConsumedMessage],
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+    ) -> Result<(), Error> {
+        if messages.is_empty() {
+            return Ok(());
+        }
+
+        let include_metadata = self.config.include_metadata.unwrap_or(true);
+        let include_checksum = self.config.include_checksum.unwrap_or(true);
+        let include_origin_timestamp = 
self.config.include_origin_timestamp.unwrap_or(true);
+        let payload_format = self.payload_format();
+
+        let record_batch = create_record_batch(
+            topic_metadata,
+            messages_metadata,
+            messages,
+            include_metadata,
+            include_checksum,
+            include_origin_timestamp,
+            payload_format,
+        )?;
+
+        let content = encode_parquet(&record_batch)?;
+
+        tracing::debug!(
+            bytes = content.len(),
+            rows = record_batch.num_rows(),
+            "encoded parquet batch"
+        );
+
+        let s3_path = self.upload_parquet(&content).await?;
+        self.copy_parquet(&s3_path).await?;
+        self.archive_parquet(&s3_path).await?;
+
+        tracing::info!(count = messages.len(), path = %s3_path, "batch 
inserted into Redshift");
+
+        Ok(())
+    }
+
+    async fn copy_parquet(&self, s3_path: &str) -> Result<(), Error> {
+        let max_retries = self.get_max_retries();
+        let retry_delay = self.get_retry_delay();
+        let sql = self.build_copy_sql(s3_path);
+        let pool = self.get_pool()?;
+
+        tracing::debug!(table = %self.config.target_table, s3_path, "issuing 
Redshift COPY");
+
+        retry_with_backoff(
+            "redshift COPY",
+            max_retries,
+            retry_delay,
+            is_transient_error,
+            || async {
+                sqlx::query(AssertSqlSafe(sql.as_str()))
+                    .execute(pool)
+                    .await
+                    .map(|_| ())
+            },
+        )
+        .await?;
+
+        tracing::debug!(table = %self.config.target_table, "Redshift COPY 
completed");
+
+        Ok(())
+    }
+
+    fn build_create_table_sql(&self) -> Result<(String, u32), Error> {
+        let table_name = &self.config.target_table;
+        let quoted_table = quote_identifier(table_name)?;
+
+        let include_metadata = self.config.include_metadata.unwrap_or(true);
+        let include_checksum = self.config.include_checksum.unwrap_or(true);
+        let include_origin_timestamp = 
self.config.include_origin_timestamp.unwrap_or(true);
+        let payload_type = self.payload_format().sql_type();
+
+        let mut params_per_row: u32 = 1; // id
+
+        let mut query =
+            format!("CREATE TABLE IF NOT EXISTS {quoted_table} (id DECIMAL(39, 
0) PRIMARY KEY");
+
+        if include_metadata {
+            query.push_str(", iggy_offset BIGINT, iggy_timestamp TIMESTAMPTZ, 
iggy_stream TEXT, iggy_topic TEXT, iggy_partition_id INTEGER");
+            params_per_row += 5;
+        }
+
+        if include_checksum {
+            query.push_str(", iggy_checksum VARCHAR");
+            params_per_row += 1;
+        }
+
+        if include_origin_timestamp {
+            query.push_str(", iggy_origin_timestamp TIMESTAMPTZ");
+            params_per_row += 1;
+        }
+
+        query.push_str(&format!(", payload {payload_type}"));
+        query.push_str(", created_at TIMESTAMPTZ DEFAULT GETDATE());");
+        params_per_row += 2;
+
+        Ok((query, params_per_row))
+    }
+
+    fn build_copy_sql(&self, s3_path: &str) -> String {
+        // Built via format! (not sqlx binds) because the Redshift/Pgwire 
endpoint here

Review Comment:
   @ryerraguntla @hubcio 
   I've been thinking about this with the proposed design where id is 
VARCHAR(40), PKs are dropped, and uniqueness is enforced via MERGE from staging 
into the target.
   
   The sink only emits a small, fixed set of Redshift queries. SELECT 1, CREATE 
TABLE, COPY, and MERGE are the only statements requiring dynamic column lists, 
which seems reasonable to validate with the pgwire mock.
   
   The main gaps I see are:
   - Malformed Parquet – I don't think this can be meaningfully tested without 
a real Redshift instance.
   - Schema mismatch – This can be validated during open(), as pointed 
elsewhere in this review. And help clarify why the sibling postgres_sink does 
not enforce this? My assumption is the auto_create flag transfers the burden to 
the user to ensure there's no schema mismatch(by means of migrations)
   - IAM/S3 permissions – This is more of an user environment/configuration 
issue than connector behaviour unless we want to check for S3 and Redshift 
permissions from the iam_role, access_key and secret_key config provide.
   
   Given that, I'm wondering how the smoke test against a real Redshift 
instance would be to cover the 4 queries semantics.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to