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