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::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
23/// A source that executes a SQL query against Amazon Redshift and returns rows
24/// as JSON objects.
25pub struct RedshiftSource {
26    config: RedshiftSourceConfig,
27    pool: PgPool,
28    /// Bookmark applied via [`Source::apply_start_bookmark`] (incremental only).
29    start_bookmark: Mutex<Option<Value>>,
30}
31
32/// Client-side incremental filter context (column + effective lower bound).
33struct IncrementalCtx {
34    column: String,
35    start: Value,
36}
37
38impl RedshiftSource {
39    /// Create a new source. Validates config and builds a lazily-connected pool
40    /// (no I/O — connectivity is verified on first query or via
41    /// [`Source::check`]).
42    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    /// The effective incremental start bookmark (persisted bookmark, else the
54    /// configured `initial_value`), or `None` for full replication.
55    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    /// Build the effective SQL and ordered positional bind values for a parent
77    /// context + optional incremental cursor.
78    ///
79    /// Bind order is: static [`config.params`](RedshiftSourceConfig::params),
80    /// then parent-context `{key}` values, then the `${bookmark}` value (when
81    /// the query contains that token).
82    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
109/// Derive a stable state key from the host, database, and query.
110fn default_state_key(config: &RedshiftSourceConfig) -> String {
111    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
112    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
122/// Apply the client-side incremental filter to a page (no-op for full runs).
123fn 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    /// Stream rows from the `sqlx` cursor without buffering the full result set.
185    /// Each emitted [`StreamPage`] holds up to
186    /// [`RedshiftSourceConfig::batch_size`] rows.
187    ///
188    /// For incremental replication the running maximum of the cursor column is
189    /// tracked over the *full* scan (before the client-side filter) and emitted
190    /// as the bookmark on the final page.
191    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            // Seeded with the start bookmark so it can never move backwards.
208            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                // Track the running max BEFORE the client-side filter so the
218                // persisted bookmark reflects the full scan.
219                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            // Final page carries the new bookmark (incremental only).
239            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    /// Preflight probe for `faucet doctor`: acquire a connection and run
256    /// `SELECT 1` (non-scanning — never executes the configured query).
257    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        // $1 = static param, $2 = context value, $3 = bookmark.
437        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}