1use 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
25const MAX_REDSHIFT_PARAMS: usize = 32_767;
28
29pub struct RedshiftSink {
31 config: RedshiftSinkConfig,
32 pool: PgPool,
33 s3: Option<S3Client>,
35}
36
37impl RedshiftSink {
38 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 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 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 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 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 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 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 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 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 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 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
229fn 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 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 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 &[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 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 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 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 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}