faucet_source_redshift/config.rs
1//! Amazon Redshift source configuration.
2
3use faucet_common_redshift::RedshiftConnection;
4use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// How the Redshift source replicates rows across runs.
10///
11/// Serializes as `{ type: full }` or
12/// `{ type: incremental, column: "...", initial_value: ... }`.
13#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum RedshiftReplication {
16 /// Every run re-fetches the full result set (default).
17 #[default]
18 Full,
19 /// Only rows not yet read, in `column` order, resuming from the stored
20 /// bookmark (or `initial_value` on the first run).
21 ///
22 /// The source wraps the query as
23 /// `SELECT * FROM (<query>) AS q WHERE q."column" >= CAST($n AS <type>) ORDER BY q."column"`
24 /// (the column's type as the server describes it) and drops the rows it
25 /// already emitted at the bookmark value, so rows that share a cursor
26 /// value (batch loads stamped with one `updated_at`, COPY jobs landing in
27 /// several commits) are never skipped. Every page carries a bookmark, so
28 /// a crash replays at most one page. Write `${bookmark}` in the query
29 /// (with `>=`) to apply the cursor further inside it; a strict `>` is
30 /// refused. Rows whose `column` is NULL are never read.
31 Incremental {
32 /// Column whose value is the replication cursor (e.g. `updated_at`).
33 column: String,
34 /// Lower bound (inclusive) used on the first run, before any bookmark
35 /// is stored.
36 initial_value: Value,
37 },
38}
39
40fn default_max_connections() -> u32 {
41 10
42}
43
44fn default_batch_size() -> usize {
45 DEFAULT_BATCH_SIZE
46}
47
48/// Configuration for the Amazon Redshift query source.
49#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50pub struct RedshiftSourceConfig {
51 /// Connection block (host / port / database / user / credentials / tls),
52 /// flattened to the config top level.
53 #[serde(flatten)]
54 pub connection: RedshiftConnection,
55 /// SQL query to execute. May contain `${field.path}` parent-context tokens
56 /// (resolved to positional binds at runtime) and, for incremental
57 /// replication, a `${bookmark}` token.
58 #[schemars(extend("x-faucet-sql" = true))]
59 pub query: String,
60 /// Positional bind parameters for the query, applied in `$1, $2, …` order
61 /// before any context- or bookmark-derived values. Defaults to empty.
62 #[serde(default)]
63 pub params: Vec<Value>,
64 /// Maximum number of connections in the pool. Defaults to 10.
65 #[serde(default = "default_max_connections")]
66 pub max_connections: u32,
67 /// Records per emitted [`StreamPage`](faucet_core::StreamPage). Rows are
68 /// drained from the `sqlx` cursor and yielded whenever the buffer reaches
69 /// this size. Defaults to [`DEFAULT_BATCH_SIZE`].
70 ///
71 /// `batch_size = 0` is the "no batching" sentinel: the cursor is fully
72 /// drained and the entire result set is emitted in a single page.
73 #[serde(default = "default_batch_size")]
74 pub batch_size: usize,
75 /// Replication mode. Defaults to [`RedshiftReplication::Full`].
76 #[serde(default)]
77 pub replication: RedshiftReplication,
78 /// Explicit state-store key for the incremental bookmark. When unset, a key
79 /// is derived from the host / database and a query fingerprint.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub state_key: Option<String>,
82}
83
84impl RedshiftSourceConfig {
85 /// Validate the config; returns a human-readable error if invalid.
86 pub fn validate(&self) -> Result<(), FaucetError> {
87 if self.query.trim().is_empty() {
88 return Err(FaucetError::Config(
89 "redshift: `query` must not be empty".into(),
90 ));
91 }
92 faucet_core::validate_batch_size(self.batch_size)?;
93 let RedshiftReplication::Incremental {
94 column,
95 initial_value,
96 } = &self.replication
97 else {
98 return Ok(());
99 };
100 if column.trim().is_empty() {
101 return Err(FaucetError::Config(
102 "redshift: incremental replication `column` must not be empty".into(),
103 ));
104 }
105 if initial_value.is_null() {
106 return Err(FaucetError::Config(
107 "redshift: incremental replication requires a non-null `initial_value`".into(),
108 ));
109 }
110 if faucet_core::replication::cursor::strict_comparison(&self.query, BOOKMARK_TOKEN) {
111 return Err(FaucetError::Config(format!(
112 "redshift: compare `{BOOKMARK_TOKEN}` with `>=`, not `>`: the source re-reads \
113 the bookmark value and drops the rows it already emitted, and a strict \
114 comparison skips rows that share it"
115 )));
116 }
117 Ok(())
118 }
119}
120
121/// The token a query uses to read the incremental bookmark
122/// (`WHERE updated_at >= ${bookmark}`).
123pub const BOOKMARK_TOKEN: &str = "${bookmark}";
124
125#[cfg(test)]
126mod tests;