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