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 serde_json::Value;
17use sqlx::PgPool;
18
19use crate::config::{RedshiftReplication, RedshiftSourceConfig};
20use crate::convert::{bind_params, row_to_json};
21
22/// A source that executes a SQL query against Amazon Redshift and returns rows
23/// as JSON objects.
24pub struct RedshiftSource {
25    config: RedshiftSourceConfig,
26    pool: PgPool,
27    /// Bookmark applied via [`Source::apply_start_bookmark`] (incremental only).
28    start_bookmark: Mutex<Option<Value>>,
29    /// Backend-call context (#880): the query and every page of rows run
30    /// through it.
31    calls: faucet_core::CallSlot,
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            calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
53        })
54    }
55
56    /// The effective incremental start bookmark (persisted bookmark, else the
57    /// configured `initial_value`), or `None` for full replication.
58    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    /// Build the effective SQL and ordered positional bind values for a parent
80    /// context + optional incremental cursor.
81    ///
82    /// Bind order is: static [`config.params`](RedshiftSourceConfig::params),
83    /// then parent-context `{key}` values, then the `${bookmark}` value (when
84    /// the query contains that token).
85    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
112/// Derive a stable state key from the host, database, and query.
113fn default_state_key(config: &RedshiftSourceConfig) -> String {
114    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
115    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
125/// Apply the client-side incremental filter to a page (no-op for full runs).
126fn 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    /// Stream rows from the `sqlx` cursor without buffering the full result set.
199    /// Each emitted [`StreamPage`] holds up to
200    /// [`RedshiftSourceConfig::batch_size`] rows.
201    ///
202    /// For incremental replication the running maximum of the cursor column is
203    /// tracked over the *full* scan (before the client-side filter) and emitted
204    /// as the bookmark on the final page.
205    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            // Seeded with the start bookmark so it can never move backwards.
225            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                // Track the running max BEFORE the client-side filter so the
241                // persisted bookmark reflects the full scan.
242                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            // Final page carries the new bookmark (incremental only).
266            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    /// Preflight probe for `faucet doctor`: acquire a connection and run
283    /// `SELECT 1` (non-scanning — never executes the configured query).
284    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        // $1 = static param, $2 = context value, $3 = bookmark.
468        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}