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


##########
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:
   @realonbebeto 
   
   it took me a while to actually understand the question. so, thing is the 
mock isn't really validating those 4 statements. `map_type` rewrites 
`DECIMAL(_)` to VARCHAR and `VARBYTE` to BYTEA, and between `strip_created_at` 
and the column list rebuilt from `information_schema`, the COPY that runs is a 
postgres one with an explicit column list, not the columnless `FORMAT AS 
PARQUET` you emit. VARCHAR(40) fixes the decimal but not that.
   
   maybe it'd better if you rather `map_type` error on what it can't represent 
than substitute? plusofc a readme line on what the suite actually covers.
   
   schema mismatch in open(), yeah. postgres_sink isn't precedent, just missing 
there too, and a bound INSERT names the bad column, whereas a failed COPY just 
points you at the load-error views.
   
   iam/s3 is environment mostly, but `validate()` requires static keys and 
`Credentials::new` gets None for both token slots, so temp creds and instance 
profiles are out. from whatisee aws-creds already falls back to 
env/profile/container when the access key is None, so making those two fields 
`Option` and dropping the validate() check is most of it.
   
   nothing per-PR works for a real cluster, fork PRs get no secrets. before 
merge i'd want you running it once on serverless and pasting it - CREATE TABLE 
verbatim, one COPY of a parquet the connector made, same file again for MERGE, 
rows read back. then `#[ignore]`d behind `REDSHIFT_TEST_DSN`.
   
   dropping PKs i agree with, but the mock's postgres was the only uniqueness 
check in the suite, so MERGE needs its own test.
   
   TLDR:
   1. fail loudly, no quiet translation
   2. update readme with test suite descr
   3. maybe run it once against redshift serverless if you have access
   4. make aws keys optional
   5. merge dedup test, not sure about that



-- 
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