Skip to main content

faucet_source_redshift/
stream.rs

1//! Amazon Redshift query source implementation.
2//!
3//! Redshift is PostgreSQL wire-compatible, so the source streams rows through
4//! `sqlx`'s Postgres cursor, re-framing them into `batch_size`-sized pages. It
5//! supports full and incremental replication: the incremental cursor is pushed
6//! down via a `${bookmark}` bind when present and always re-checked client-side.
7
8use 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
25/// A source that executes a SQL query against Amazon Redshift and returns rows
26/// as JSON objects.
27pub struct RedshiftSource {
28    config: RedshiftSourceConfig,
29    pool: PgPool,
30    /// Bookmark applied via [`Source::apply_start_bookmark`] (incremental only).
31    start_bookmark: Mutex<Option<Value>>,
32}
33
34/// Client-side incremental filter context (column + effective lower bound).
35struct IncrementalCtx {
36    column: String,
37    start: Value,
38}
39
40impl RedshiftSource {
41    /// Create a new source. Validates config and builds a lazily-connected pool
42    /// (no I/O — connectivity is verified on first query or via
43    /// [`Source::check`]).
44    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    /// The effective incremental start bookmark (persisted bookmark, else the
56    /// configured `initial_value`), or `None` for full replication.
57    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    /// Build the effective SQL and ordered positional bind values for a parent
79    /// context + optional incremental cursor.
80    ///
81    /// Bind order is: static [`config.params`](RedshiftSourceConfig::params),
82    /// then parent-context `{key}` values, then the `${bookmark}` value (when
83    /// the query contains that token).
84    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
111/// Derive a stable state key from the host, database, and query.
112fn 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
121/// Apply the client-side incremental filter to a page (no-op for full runs).
122fn 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    /// Stream rows from the `sqlx` cursor without buffering the full result set.
184    /// Each emitted [`StreamPage`] holds up to
185    /// [`RedshiftSourceConfig::batch_size`] rows.
186    ///
187    /// For incremental replication the running maximum of the cursor column is
188    /// tracked over the *full* scan (before the client-side filter) and emitted
189    /// as the bookmark on the final page.
190    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                // Track the running max BEFORE the client-side filter so the
216                // persisted bookmark reflects the full scan.
217                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            // Final page carries the new bookmark (incremental only).
237            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    /// Preflight probe for `faucet doctor`: acquire a connection and run
254    /// `SELECT 1` (non-scanning — never executes the configured query).
255    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        // $1 = static param, $2 = context value, $3 = bookmark.
423        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}