Skip to main content

faucet_sink_redshift/
sink.rs

1//! Amazon Redshift sink implementation.
2//!
3//! Two load paths, selected by `write_strategy`:
4//! - `copy` (default) — stage each page to S3 (JSONL or CSV) and bulk-load it
5//!   with `COPY … FROM 's3://…' IAM_ROLE '…'`, then best-effort delete the
6//!   staged object. This is Redshift's recommended, fastest load path.
7//! - `insert` — multi-row `INSERT INTO … VALUES (…), (…)`. Portable, no S3, but
8//!   slower for bulk data.
9//!
10//! Append-only (`supported_write_modes` = `[Append]`): Redshift has no
11//! `ON CONFLICT`, and `COPY` cannot upsert.
12
13use async_trait::async_trait;
14use aws_sdk_s3::Client as S3Client;
15use faucet_core::FaucetError;
16use serde_json::Value;
17use sqlx::{PgPool, Row};
18
19use crate::config::{RedshiftCopyFormat, RedshiftSinkConfig, RedshiftWriteStrategy};
20use crate::copy::{
21    columns_present, copy_statement, insert_statement, qualified_table_ref, s3_uri, serialize_csv,
22    serialize_jsonl,
23};
24
25/// Redshift caps bind parameters per statement; keep multi-row `INSERT`s under
26/// this by sub-chunking.
27const MAX_REDSHIFT_PARAMS: usize = 32_767;
28
29/// A sink that loads JSON records into an Amazon Redshift table.
30pub struct RedshiftSink {
31    config: RedshiftSinkConfig,
32    pool: PgPool,
33    /// S3 client, built only for the `copy` strategy.
34    s3: Option<S3Client>,
35}
36
37impl RedshiftSink {
38    /// Create a new sink. Validates config, builds a lazily-connected pool (no
39    /// DB I/O), and — for the `copy` strategy — an S3 client.
40    pub async fn new(config: RedshiftSinkConfig) -> Result<Self, FaucetError> {
41        config.validate()?;
42        let pool =
43            faucet_common_redshift::build_pool_lazy(&config.connection, config.max_connections)?;
44        let s3 = if config.write_strategy == RedshiftWriteStrategy::Copy {
45            Some(Self::build_s3_client(&config).await)
46        } else {
47            None
48        };
49        Ok(Self { config, pool, s3 })
50    }
51
52    /// Build an S3 client honouring the optional region / endpoint overrides.
53    async fn build_s3_client(config: &RedshiftSinkConfig) -> S3Client {
54        let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
55        if let Some(region) = &config.region {
56            loader = loader.region(aws_config::Region::new(region.clone()));
57        }
58        if let Some(endpoint) = &config.endpoint_url {
59            loader = loader.endpoint_url(endpoint);
60        }
61        let sdk_config = loader.load().await;
62        S3Client::new(&sdk_config)
63    }
64
65    fn table_ref(&self) -> String {
66        qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name)
67    }
68
69    /// Generate a unique staging object key for one page.
70    fn staging_key(&self, ext: &str) -> String {
71        let id = uuid::Uuid::new_v4();
72        format!("{}{}.{}", self.config.staging_prefix, id, ext)
73    }
74
75    /// Discover the destination table's column names in ordinal order via
76    /// `information_schema.columns`. Used by the `insert` path and the CSV
77    /// `copy` path (both need the column set/order).
78    async fn discover_columns(&self) -> Result<Vec<String>, FaucetError> {
79        let rows = sqlx::query(
80            "SELECT column_name FROM information_schema.columns \
81             WHERE table_name = $1 AND ($2::text IS NULL OR table_schema = $2) \
82             ORDER BY ordinal_position",
83        )
84        .bind(&self.config.table_name)
85        .bind(self.config.schema.as_deref())
86        .fetch_all(&self.pool)
87        .await
88        .map_err(|e| FaucetError::Sink(format!("redshift: column discovery failed: {e}")))?;
89
90        let cols: Vec<String> = rows
91            .iter()
92            .map(|r| r.get::<String, _>("column_name"))
93            .collect();
94        if cols.is_empty() {
95            return Err(FaucetError::Sink(format!(
96                "redshift: table {} has no columns or does not exist",
97                self.config.table_name
98            )));
99        }
100        Ok(cols)
101    }
102
103    /// Load one chunk via the `COPY`-from-S3 fast path.
104    async fn copy_chunk(&self, records: &[Value]) -> Result<usize, FaucetError> {
105        if records.is_empty() {
106            return Ok(0);
107        }
108        let s3 = self.s3.as_ref().ok_or_else(|| {
109            FaucetError::Sink("redshift: S3 client not initialized for copy strategy".into())
110        })?;
111        let bucket = self.config.staging_bucket.as_deref().ok_or_else(|| {
112            FaucetError::Sink("redshift: staging_bucket is required for copy strategy".into())
113        })?;
114        let iam_role = self.config.iam_role.as_deref().ok_or_else(|| {
115            FaucetError::Sink("redshift: iam_role is required for copy strategy".into())
116        })?;
117
118        // Serialize the page and, for CSV, learn the destination column order.
119        let (body, columns): (Vec<u8>, Option<Vec<String>>) = match self.config.copy_format {
120            RedshiftCopyFormat::Jsonl => (serialize_jsonl(records)?, None),
121            RedshiftCopyFormat::Csv => {
122                let cols = self.discover_columns().await?;
123                (serialize_csv(records, &cols)?, Some(cols))
124            }
125        };
126        let ext = match self.config.copy_format {
127            RedshiftCopyFormat::Jsonl => "jsonl",
128            RedshiftCopyFormat::Csv => "csv",
129        };
130        let key = self.staging_key(ext);
131
132        // 1. Upload the staged object.
133        s3.put_object()
134            .bucket(bucket)
135            .key(&key)
136            .body(body.into())
137            .send()
138            .await
139            .map_err(|e| {
140                FaucetError::Sink(format!("redshift: S3 upload failed for key '{key}': {e}"))
141            })?;
142
143        // 2. COPY it into the table.
144        let sql = copy_statement(
145            &self.table_ref(),
146            columns.as_deref(),
147            &s3_uri(bucket, &key),
148            iam_role,
149            self.config.region.as_deref(),
150            self.config.copy_format,
151        );
152        let copy_result = sqlx::query(&sql).execute(&self.pool).await;
153
154        // 3. Best-effort cleanup of the staged object (regardless of COPY
155        //    outcome — a failed COPY still leaves the object behind).
156        if let Err(e) = s3.delete_object().bucket(bucket).key(&key).send().await {
157            tracing::warn!(key = %key, error = %e, "redshift: failed to delete staged S3 object (best-effort)");
158        }
159
160        copy_result.map_err(|e| FaucetError::Sink(format!("redshift: COPY failed: {e}")))?;
161        Ok(records.len())
162    }
163
164    /// Load one chunk via multi-row `INSERT`, sub-chunked to respect the bind
165    /// parameter cap.
166    async fn insert_chunk(&self, records: &[Value]) -> Result<usize, FaucetError> {
167        if records.is_empty() {
168            return Ok(0);
169        }
170        let table_columns = self.discover_columns().await?;
171        let present: Vec<String> = columns_present(records, &table_columns)
172            .into_iter()
173            .cloned()
174            .collect();
175        if present.is_empty() {
176            tracing::warn!(
177                table = %self.config.table_name,
178                "redshift: no record keys match table columns; skipping insert"
179            );
180            return Ok(0);
181        }
182
183        // Drop records that share *no* column with the table. `present` is the
184        // union of table columns across the page, so binding such a record would
185        // emit an all-NULL row rather than the data the caller sent — the DuckDB
186        // and SQLite sinks skip it, and so do we (#466 L1). A record with at
187        // least one matching column is still inserted (missing columns → NULL).
188        let insertable: Vec<&Value> = records
189            .iter()
190            .filter(|r| crate::copy::shares_a_column(r, &present))
191            .collect();
192        let skipped = records.len() - insertable.len();
193        if skipped > 0 {
194            tracing::warn!(
195                table = %self.config.table_name,
196                skipped,
197                "redshift: skipped record(s) with no column matching the table \
198                 (would have inserted an all-NULL row)"
199            );
200        }
201        if insertable.is_empty() {
202            return Ok(0);
203        }
204
205        let num_cols = present.len();
206        let max_rows = (MAX_REDSHIFT_PARAMS / num_cols).max(1);
207        let mut total = 0usize;
208
209        for sub in insertable.chunks(max_rows) {
210            let sql = insert_statement(&self.table_ref(), &present, sub.len());
211            let mut q = sqlx::query(&sql);
212            for record in sub {
213                let obj = record.as_object().ok_or_else(|| {
214                    FaucetError::Sink("redshift: insert requires JSON object records".into())
215                })?;
216                for col in &present {
217                    q = bind_json(q, obj.get(col), col)?;
218                }
219            }
220            q.execute(&self.pool)
221                .await
222                .map_err(|e| FaucetError::Sink(format!("redshift: INSERT failed: {e}")))?;
223            total += sub.len();
224        }
225        Ok(total)
226    }
227}
228
229/// Bind one JSON value onto a `sqlx` query as a native scalar type (so it lands
230/// in a typed Redshift column instead of being coerced from `jsonb`). Missing /
231/// null binds SQL NULL.
232fn bind_json<'q>(
233    query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
234    v: Option<&Value>,
235    column: &str,
236) -> Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>, FaucetError> {
237    Ok(match v {
238        None | Some(Value::Null) => query.bind(None::<String>),
239        Some(Value::String(s)) => query.bind(s.clone()),
240        Some(Value::Bool(b)) => query.bind(*b),
241        Some(Value::Number(n)) => {
242            if n.is_i64() {
243                query.bind(n.as_i64().unwrap())
244            } else if n.is_u64() {
245                // Above `i64::MAX`. Redshift's BIGINT is signed and `as i64`
246                // would *wrap*, writing the id as a large negative number with
247                // nothing raised. Refuse instead (#462).
248                query.bind(faucet_core::util::u64_to_signed(
249                    n.as_u64().unwrap(),
250                    &format!("column {}", faucet_core::util::quote_ident(column)),
251                )?)
252            } else {
253                query.bind(n.as_f64().unwrap_or(0.0))
254            }
255        }
256        Some(other) => query.bind(other.to_string()),
257    })
258}
259
260#[async_trait]
261impl faucet_core::Sink for RedshiftSink {
262    fn connector_name(&self) -> &'static str {
263        "redshift"
264    }
265
266    /// Redshift bulk-loads via `COPY … FROM 's3://…'` under `write_strategy: copy`
267    /// (#528). The `staging` capability is advertised so the CLI + tooling can
268    /// surface it.
269    fn supports_staged_load(&self) -> bool {
270        true
271    }
272
273    fn config_schema(&self) -> Value {
274        serde_json::to_value(faucet_core::schema_for!(RedshiftSinkConfig))
275            .expect("schema serialization")
276    }
277
278    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
279        // Append-only: Redshift has no ON CONFLICT and COPY cannot upsert.
280        &[faucet_core::WriteMode::Append]
281    }
282
283    fn dataset_uri(&self) -> String {
284        let table = match &self.config.schema {
285            Some(s) => format!("{}.{}", s, self.config.table_name),
286            None => self.config.table_name.clone(),
287        };
288        format!(
289            "redshift://{}:{}/{}?table={}",
290            self.config.connection.host,
291            self.config.connection.port,
292            self.config.connection.database,
293            table
294        )
295    }
296
297    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
298        if records.is_empty() {
299            return Ok(0);
300        }
301        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
302            vec![records]
303        } else {
304            records.chunks(self.config.batch_size).collect()
305        };
306
307        let mut total = 0;
308        for chunk in chunks {
309            total += match self.config.write_strategy {
310                RedshiftWriteStrategy::Copy => self.copy_chunk(chunk).await?,
311                RedshiftWriteStrategy::Insert => self.insert_chunk(chunk).await?,
312            };
313        }
314
315        tracing::info!(
316            table = %self.config.table_name,
317            rows = total,
318            strategy = self.config.write_strategy.as_str(),
319            "Redshift write complete"
320        );
321        Ok(total)
322    }
323
324    /// Preflight connectivity probe (`faucet doctor`): acquire a connection and
325    /// run `SELECT 1`. Non-mutating and idempotent.
326    async fn check(
327        &self,
328        ctx: &faucet_core::check::CheckContext,
329    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
330        use faucet_core::check::{CheckReport, Probe};
331
332        let started = std::time::Instant::now();
333        let probe =
334            match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
335                .await
336            {
337                Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
338                Ok(Err(e)) => Probe::fail_hint(
339                    "auth",
340                    started.elapsed(),
341                    e.to_string(),
342                    "check host/port/database/user/credentials and that the cluster is reachable",
343                ),
344                Err(_) => Probe::fail_hint(
345                    "auth",
346                    started.elapsed(),
347                    "timed out",
348                    "check host/port/database/user/credentials and that the cluster is reachable",
349                ),
350            };
351        Ok(CheckReport::single(probe))
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::config::{RedshiftCopyFormat, RedshiftWriteStrategy};
359    use faucet_common_redshift::RedshiftConnection;
360    use faucet_core::Sink as _;
361    use serde_json::json;
362
363    fn insert_config() -> RedshiftSinkConfig {
364        RedshiftSinkConfig {
365            connection: RedshiftConnection::new("host", "db", "user", "pw"),
366            table_name: "events".into(),
367            schema: Some("public".into()),
368            write_strategy: RedshiftWriteStrategy::Insert,
369            copy_format: RedshiftCopyFormat::Jsonl,
370            staging_bucket: None,
371            staging_prefix: String::new(),
372            iam_role: None,
373            region: None,
374            endpoint_url: None,
375            batch_size: 1000,
376            max_connections: 5,
377        }
378    }
379
380    fn copy_config() -> RedshiftSinkConfig {
381        RedshiftSinkConfig {
382            write_strategy: RedshiftWriteStrategy::Copy,
383            staging_bucket: Some("stage".into()),
384            staging_prefix: "rs/".into(),
385            iam_role: Some("arn:aws:iam::1:role/r".into()),
386            // Explicit region so building the S3 client resolves without the
387            // default region provider probing IMDS (hermetic, fast tests).
388            region: Some("us-east-1".into()),
389            ..insert_config()
390        }
391    }
392
393    async fn sink(c: RedshiftSinkConfig) -> RedshiftSink {
394        RedshiftSink::new(c).await.unwrap()
395    }
396
397    #[tokio::test]
398    async fn new_insert_has_no_s3_client() {
399        let s = sink(insert_config()).await;
400        assert!(s.s3.is_none());
401    }
402
403    #[tokio::test]
404    async fn new_copy_builds_s3_client() {
405        let s = sink(copy_config()).await;
406        assert!(s.s3.is_some());
407    }
408
409    #[tokio::test]
410    async fn new_rejects_copy_without_bucket() {
411        let mut c = copy_config();
412        c.staging_bucket = None;
413        assert!(matches!(
414            RedshiftSink::new(c).await,
415            Err(FaucetError::Config(_))
416        ));
417    }
418
419    #[tokio::test]
420    async fn new_surfaces_unsupported_credentials() {
421        let mut c = insert_config();
422        c.connection.credentials = faucet_common_redshift::RedshiftCredentials::RedshiftDataApi {
423            region: None,
424            cluster_identifier: None,
425            workgroup_name: None,
426            secret_arn: None,
427            db_user: None,
428        };
429        assert!(matches!(
430            RedshiftSink::new(c).await,
431            Err(FaucetError::Config(_))
432        ));
433    }
434
435    #[tokio::test]
436    async fn connector_name_is_redshift() {
437        assert_eq!(sink(insert_config()).await.connector_name(), "redshift");
438    }
439
440    #[tokio::test]
441    async fn supported_write_modes_is_append_only() {
442        let s = sink(insert_config()).await;
443        assert_eq!(
444            s.supported_write_modes(),
445            [faucet_core::WriteMode::Append].as_slice()
446        );
447    }
448
449    #[tokio::test]
450    async fn dataset_uri_schema_qualified() {
451        let s = sink(insert_config()).await;
452        assert_eq!(
453            s.dataset_uri(),
454            "redshift://host:5439/db?table=public.events"
455        );
456    }
457
458    #[tokio::test]
459    async fn config_schema_reports_required_fields() {
460        let s = sink(insert_config()).await;
461        let schema = s.config_schema();
462        assert!(schema["properties"]["table_name"].is_object());
463        let required = schema["required"].as_array().expect("required array");
464        assert!(required.iter().any(|v| v == "table_name"));
465    }
466
467    #[tokio::test]
468    async fn write_batch_empty_is_zero() {
469        let s = sink(insert_config()).await;
470        assert_eq!(s.write_batch(&[]).await.unwrap(), 0);
471    }
472
473    #[tokio::test]
474    async fn table_ref_and_staging_key() {
475        let s = sink(copy_config()).await;
476        assert_eq!(s.table_ref(), "\"public\".\"events\"");
477        let key = s.staging_key("jsonl");
478        assert!(key.starts_with("rs/"));
479        assert!(key.ends_with(".jsonl"));
480    }
481
482    #[test]
483    fn bind_json_covers_all_scalar_kinds() {
484        // Smoke: binding must not panic for every JSON scalar kind. The actual
485        // wire encoding is exercised by the live integration test.
486        let q = sqlx::query("SELECT $1, $2, $3, $4, $5, $6");
487        let q = bind_json(q, Some(&json!("s")), "c").unwrap();
488        let q = bind_json(q, Some(&json!(7)), "c").unwrap();
489        let q = bind_json(q, Some(&json!(7.5)), "c").unwrap();
490        let q = bind_json(q, Some(&json!(true)), "c").unwrap();
491        let q = bind_json(q, Some(&Value::Null), "c").unwrap();
492        let _q = bind_json(q, None, "c").unwrap();
493
494        // #462: a u64 above i64::MAX must be refused, not wrapped negative.
495        let q = sqlx::query("SELECT 1");
496        let err = match bind_json(q, Some(&json!(u64::MAX)), "big_id") {
497            Err(e) => e.to_string(),
498            Ok(_) => panic!("u64::MAX must not bind to a signed BIGINT"),
499        };
500        assert!(err.contains("big_id"), "{err}");
501        assert!(err.contains(&u64::MAX.to_string()), "{err}");
502    }
503}