1use std::collections::HashMap;
9use std::pin::Pin;
10use std::sync::Mutex;
11
12use async_trait::async_trait;
13use faucet_core::replication::{filter_incremental, max_value};
14use faucet_core::util::substitute_context_bind_params;
15use faucet_core::{FaucetError, Source, Stream, StreamPage};
16use futures::TryStreamExt;
17use serde_json::Value;
18use sqlx::PgPool;
19
20use crate::config::{RedshiftReplication, RedshiftSourceConfig};
21use crate::convert::{bind_params, row_to_json};
22
23pub struct RedshiftSource {
26 config: RedshiftSourceConfig,
27 pool: PgPool,
28 start_bookmark: Mutex<Option<Value>>,
30}
31
32struct IncrementalCtx {
34 column: String,
35 start: Value,
36}
37
38impl RedshiftSource {
39 pub fn new(config: RedshiftSourceConfig) -> Result<Self, FaucetError> {
43 config.validate()?;
44 let pool =
45 faucet_common_redshift::build_pool_lazy(&config.connection, config.max_connections)?;
46 Ok(Self {
47 config,
48 pool,
49 start_bookmark: Mutex::new(None),
50 })
51 }
52
53 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
56 match &self.config.replication {
57 RedshiftReplication::Full => None,
58 RedshiftReplication::Incremental {
59 column,
60 initial_value,
61 } => {
62 let start = self
63 .start_bookmark
64 .lock()
65 .expect("start_bookmark mutex poisoned")
66 .clone()
67 .unwrap_or_else(|| initial_value.clone());
68 Some(IncrementalCtx {
69 column: column.clone(),
70 start,
71 })
72 }
73 }
74 }
75
76 fn resolve_query(
83 &self,
84 context: &HashMap<String, Value>,
85 incr: Option<&IncrementalCtx>,
86 ) -> (String, Vec<Value>) {
87 let mut binds = self.config.params.clone();
88 let mut sql = self.config.query.clone();
89
90 if !context.is_empty() {
91 let (rewritten, ctx_values) =
92 substitute_context_bind_params(&sql, context, binds.len() + 1, |i| format!("${i}"));
93 sql = rewritten;
94 binds.extend(ctx_values);
95 }
96
97 if let Some(ctx) = incr
98 && sql.contains("${bookmark}")
99 {
100 let marker = format!("${}", binds.len() + 1);
101 sql = sql.replace("${bookmark}", &marker);
102 binds.push(ctx.start.clone());
103 }
104
105 (sql, binds)
106 }
107}
108
109fn default_state_key(config: &RedshiftSourceConfig) -> String {
111 let material = format!(
113 "{}\u{1f}{}\u{1f}{}\u{1f}{}",
114 config.connection.host, config.connection.port, config.connection.database, config.query
115 );
116 format!(
117 "redshift:{:016x}",
118 faucet_core::shard::shard_hash(&material)
119 )
120}
121
122fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
124 match incr {
125 Some(ic) => filter_incremental(page, &ic.column, &ic.start),
126 None => page,
127 }
128}
129
130#[async_trait]
131impl Source for RedshiftSource {
132 fn connector_name(&self) -> &'static str {
133 "redshift"
134 }
135
136 fn config_schema(&self) -> Value {
137 serde_json::to_value(faucet_core::schema_for!(RedshiftSourceConfig))
138 .expect("schema serialization")
139 }
140
141 fn dataset_uri(&self) -> String {
142 format!(
143 "redshift://{}:{}/{}?query={}",
144 self.config.connection.host,
145 self.config.connection.port,
146 self.config.connection.database,
147 self.config.query
148 )
149 }
150
151 fn state_key(&self) -> Option<String> {
152 match &self.config.replication {
153 RedshiftReplication::Full => None,
154 RedshiftReplication::Incremental { .. } => Some(
155 self.config
156 .state_key
157 .clone()
158 .unwrap_or_else(|| default_state_key(&self.config)),
159 ),
160 }
161 }
162
163 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
164 *self
165 .start_bookmark
166 .lock()
167 .expect("start_bookmark mutex poisoned") = Some(bookmark);
168 Ok(())
169 }
170
171 async fn fetch_with_context(
172 &self,
173 context: &HashMap<String, Value>,
174 ) -> Result<Vec<Value>, FaucetError> {
175 use futures::StreamExt;
176 let mut out = Vec::new();
177 let mut s = self.stream_pages(context, self.config.batch_size);
178 while let Some(page) = s.next().await {
179 out.extend(page?.records);
180 }
181 Ok(out)
182 }
183
184 fn stream_pages<'a>(
192 &'a self,
193 context: &'a HashMap<String, Value>,
194 _batch_size: usize,
195 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
196 let batch_size = self.config.batch_size;
197
198 Box::pin(async_stream::try_stream! {
199 let incr = self.incremental_ctx();
200 let (query_str, binds) = self.resolve_query(context, incr.as_ref());
201 let query = bind_params(sqlx::query(&query_str), &binds)?;
202
203 let mut rows = query.fetch(&self.pool);
204 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
205 let cap = if batch_size == 0 { 1024 } else { batch_size };
206 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
207 let mut running_max: Option<Value> = incr.as_ref().map(|ic| ic.start.clone());
209 let mut total = 0usize;
210
211 while let Some(row) = rows
212 .try_next()
213 .await
214 .map_err(|e| FaucetError::Source(format!("redshift query failed: {e}")))?
215 {
216 let obj = row_to_json(&row)?;
217 if let Some(ic) = &incr
220 && let Some(v) = obj.get(&ic.column)
221 {
222 running_max = Some(match running_max.take() {
223 Some(m) => max_value(m, v.clone()),
224 None => v.clone(),
225 });
226 }
227 buffer.push(obj);
228 if buffer.len() >= chunk {
229 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
230 let kept = apply_incr_filter(page, incr.as_ref());
231 total += kept.len();
232 if !kept.is_empty() {
233 yield StreamPage { records: kept, bookmark: None };
234 }
235 }
236 }
237
238 let kept = apply_incr_filter(buffer, incr.as_ref());
240 total += kept.len();
241 let bookmark = if incr.is_some() { running_max } else { None };
242 if !kept.is_empty() || bookmark.is_some() {
243 yield StreamPage { records: kept, bookmark };
244 }
245
246 tracing::info!(
247 rows = total,
248 batch_size,
249 query = %self.config.query,
250 "Redshift source stream complete",
251 );
252 })
253 }
254
255 async fn check(
258 &self,
259 ctx: &faucet_core::check::CheckContext,
260 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
261 use faucet_core::check::{CheckReport, Probe};
262
263 let started = std::time::Instant::now();
264 let probe =
265 match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
266 .await
267 {
268 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
269 Ok(Err(e)) => Probe::fail_hint(
270 "auth",
271 started.elapsed(),
272 e.to_string(),
273 "check host/port/database/user/credentials and that the cluster is reachable",
274 ),
275 Err(_) => Probe::fail_hint(
276 "auth",
277 started.elapsed(),
278 "timed out",
279 "check host/port/database/user/credentials and that the cluster is reachable",
280 ),
281 };
282 Ok(CheckReport::single(probe))
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::config::RedshiftReplication;
290 use faucet_common_redshift::RedshiftConnection;
291 use serde_json::json;
292
293 fn base_config() -> RedshiftSourceConfig {
294 RedshiftSourceConfig {
295 connection: RedshiftConnection::new("host", "db", "user", "pw"),
296 query: "SELECT * FROM t".into(),
297 params: Vec::new(),
298 max_connections: 10,
299 batch_size: 1000,
300 replication: RedshiftReplication::Full,
301 state_key: None,
302 }
303 }
304
305 fn source(c: RedshiftSourceConfig) -> RedshiftSource {
306 RedshiftSource::new(c).unwrap()
307 }
308
309 #[test]
310 fn new_rejects_invalid_batch_size() {
311 let mut c = base_config();
312 c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
313 match RedshiftSource::new(c) {
314 Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
315 _ => panic!("expected a batch_size Config error"),
316 }
317 }
318
319 #[test]
320 fn new_surfaces_unsupported_credentials() {
321 let mut c = base_config();
322 c.connection.credentials = faucet_common_redshift::RedshiftCredentials::Iam {
323 region: None,
324 cluster_identifier: None,
325 db_user: None,
326 };
327 assert!(matches!(
328 RedshiftSource::new(c),
329 Err(FaucetError::Config(_))
330 ));
331 }
332
333 #[tokio::test]
334 async fn connector_name_is_redshift() {
335 assert_eq!(source(base_config()).connector_name(), "redshift");
336 }
337
338 #[tokio::test]
339 async fn dataset_uri_has_host_db_and_query() {
340 let s = source(base_config());
341 assert_eq!(
342 s.dataset_uri(),
343 "redshift://host:5439/db?query=SELECT * FROM t"
344 );
345 }
346
347 #[tokio::test]
348 async fn config_schema_reports_required_fields() {
349 let s = source(base_config());
350 let schema = s.config_schema();
351 assert!(schema["properties"]["query"].is_object());
352 let required = schema["required"].as_array().expect("required array");
353 assert!(required.iter().any(|v| v == "query"));
354 }
355
356 #[tokio::test]
357 async fn full_mode_has_no_state_key() {
358 assert!(source(base_config()).state_key().is_none());
359 }
360
361 #[tokio::test]
362 async fn incremental_state_key_derived_and_stable() {
363 let mut c = base_config();
364 c.replication = RedshiftReplication::Incremental {
365 column: "ts".into(),
366 initial_value: json!("2026-01-01"),
367 };
368 let k1 = source(c.clone()).state_key().unwrap();
369 let k2 = source(c).state_key().unwrap();
370 assert_eq!(k1, k2);
371 assert!(k1.starts_with("redshift:"));
372 let c = base_config();
373 let material = format!(
374 "{}\u{1f}{}\u{1f}{}\u{1f}{}",
375 c.connection.host, c.connection.port, c.connection.database, c.query
376 );
377 assert_eq!(
378 k1,
379 format!(
380 "redshift:{:016x}",
381 faucet_core::shard::shard_hash(&material)
382 )
383 );
384 }
385
386 #[tokio::test]
387 async fn explicit_state_key_wins_and_bookmark_overrides_initial() {
388 let mut c = base_config();
389 c.state_key = Some("my-key".into());
390 c.replication = RedshiftReplication::Incremental {
391 column: "ts".into(),
392 initial_value: json!("2026-01-01"),
393 };
394 let s = source(c);
395 assert_eq!(s.state_key().as_deref(), Some("my-key"));
396 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
397 assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
398 }
399
400 #[tokio::test]
401 async fn resolve_query_no_context_no_incremental_is_verbatim() {
402 let mut c = base_config();
403 c.params = vec![json!(7)];
404 let s = source(c);
405 let (sql, binds) = s.resolve_query(&HashMap::new(), None);
406 assert_eq!(sql, "SELECT * FROM t");
407 assert_eq!(binds, vec![json!(7)]);
408 }
409
410 #[tokio::test]
411 async fn resolve_query_substitutes_context_positionally() {
412 let mut c = base_config();
413 c.query = "SELECT * FROM t WHERE id = {parent.id}".into();
414 let s = source(c);
415 let mut ctx = HashMap::new();
416 ctx.insert("parent.id".to_string(), json!(42));
417 let (sql, binds) = s.resolve_query(&ctx, None);
418 assert_eq!(sql, "SELECT * FROM t WHERE id = $1");
419 assert_eq!(binds, vec![json!(42)]);
420 }
421
422 #[tokio::test]
423 async fn resolve_query_pushes_down_bookmark_after_params_and_context() {
424 let mut c = base_config();
425 c.params = vec![json!(1)];
426 c.query = "SELECT * FROM t WHERE tenant = {p.t} AND ts > ${bookmark}".into();
427 c.replication = RedshiftReplication::Incremental {
428 column: "ts".into(),
429 initial_value: json!("2026-01-01"),
430 };
431 let s = source(c);
432 let incr = s.incremental_ctx();
433 let mut ctx = HashMap::new();
434 ctx.insert("p.t".to_string(), json!("acme"));
435 let (sql, binds) = s.resolve_query(&ctx, incr.as_ref());
436 assert_eq!(sql, "SELECT * FROM t WHERE tenant = $2 AND ts > $3");
438 assert_eq!(binds, vec![json!(1), json!("acme"), json!("2026-01-01")]);
439 }
440
441 #[test]
442 fn apply_incr_filter_drops_records_at_or_below_start() {
443 let page = vec![
444 json!({"id": 1, "ts": "2026-01-01"}),
445 json!({"id": 2, "ts": "2026-06-01"}),
446 ];
447 let ic = IncrementalCtx {
448 column: "ts".into(),
449 start: json!("2026-01-01"),
450 };
451 let kept = apply_incr_filter(page, Some(&ic));
452 assert_eq!(kept.len(), 1);
453 assert_eq!(kept[0]["id"], 2);
454 }
455
456 #[test]
457 fn apply_incr_filter_is_noop_for_full_mode() {
458 let page = vec![json!({"id": 1})];
459 assert_eq!(apply_incr_filter(page.clone(), None), page);
460 }
461}