faucet_source_mysql_cdc/
config.rs1use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::time::Duration;
8
9fn default_idle_timeout() -> Duration {
10 Duration::from_secs(30)
11}
12fn default_batch_size() -> usize {
13 DEFAULT_BATCH_SIZE
14}
15fn default_include_columns() -> bool {
16 true
17}
18
19#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum StartPosition {
23 #[default]
25 Current,
26 Earliest,
28 GtidSet { value: String },
30 FilePos { file: String, pos: u64 },
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(tag = "mode", rename_all = "snake_case")]
37pub enum CdcTls {
38 #[default]
40 Disable,
41 Require,
43 VerifyCa {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 ca_path: Option<String>,
47 },
48 VerifyFull {
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 ca_path: Option<String>,
52 },
53}
54
55#[derive(Clone, Serialize, Deserialize, JsonSchema)]
57pub struct MysqlCdcSourceConfig {
58 pub connection_url: String,
60 pub server_id: u32,
62 #[serde(default)]
64 pub include_tables: Vec<String>,
65 #[serde(default)]
67 pub exclude_tables: Vec<String>,
68 #[serde(default)]
70 pub start_position: StartPosition,
71 #[serde(default = "default_include_columns")]
73 pub include_columns: bool,
74 #[serde(default)]
76 pub emit_schema_changes: bool,
77 #[serde(default)]
79 pub tls: CdcTls,
80 #[serde(
82 default = "default_idle_timeout",
83 with = "faucet_core::config::duration_secs"
84 )]
85 #[schemars(with = "u64")]
86 pub idle_timeout: Duration,
87 #[serde(default)]
90 pub max_staged_records: Option<usize>,
91 #[serde(default = "default_batch_size")]
93 pub batch_size: usize,
94}
95
96impl MysqlCdcSourceConfig {
97 pub fn validate(&self) -> Result<(), FaucetError> {
99 if self.connection_url.trim().is_empty() {
100 return Err(FaucetError::Config(
101 "mysql-cdc: connection_url must not be empty".into(),
102 ));
103 }
104 if self.server_id == 0 {
105 return Err(FaucetError::Config(
106 "mysql-cdc: server_id must be a non-zero value unique across replicas".into(),
107 ));
108 }
109 if self.idle_timeout.is_zero() {
110 return Err(FaucetError::Config(
111 "mysql-cdc: idle_timeout must be > 0".into(),
112 ));
113 }
114 for t in self.include_tables.iter().chain(self.exclude_tables.iter()) {
115 if !t.contains('.') {
116 return Err(FaucetError::Config(format!(
117 "mysql-cdc: table filter '{t}' must be fully qualified as 'database.table'"
118 )));
119 }
120 }
121 faucet_core::validate_batch_size(self.batch_size)?;
122 let key = crate::state::state_key(self.server_id);
123 faucet_core::state::validate_state_key(&key)?;
124 Ok(())
125 }
126
127 pub fn table_included(&self, db: &str, table: &str) -> bool {
129 let q = format!("{db}.{table}");
130 if !self.include_tables.is_empty() {
131 return self.include_tables.iter().any(|t| t == &q);
132 }
133 !self.exclude_tables.iter().any(|t| t == &q)
134 }
135}
136
137impl fmt::Debug for MysqlCdcSourceConfig {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("MysqlCdcSourceConfig")
140 .field("connection_url", &"***")
141 .field("server_id", &self.server_id)
142 .field("include_tables", &self.include_tables)
143 .field("exclude_tables", &self.exclude_tables)
144 .field("start_position", &self.start_position)
145 .field("include_columns", &self.include_columns)
146 .field("emit_schema_changes", &self.emit_schema_changes)
147 .field("tls", &self.tls)
148 .field("idle_timeout", &self.idle_timeout)
149 .field("max_staged_records", &self.max_staged_records)
150 .field("batch_size", &self.batch_size)
151 .finish()
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use serde_json::json;
159
160 fn minimal() -> MysqlCdcSourceConfig {
161 serde_json::from_value(json!({
162 "connection_url": "mysql://repl:pass@localhost:3306/db",
163 "server_id": 1001
164 }))
165 .unwrap()
166 }
167
168 #[test]
169 fn defaults_via_serde() {
170 let c = minimal();
171 assert_eq!(c.idle_timeout.as_secs(), 30);
172 assert_eq!(c.batch_size, DEFAULT_BATCH_SIZE);
173 assert_eq!(c.start_position, StartPosition::Current);
174 assert!(c.include_columns);
175 assert!(!c.emit_schema_changes);
176 assert_eq!(c.tls, CdcTls::Disable);
177 }
178
179 #[test]
180 fn start_position_tagged_enum() {
181 let c: MysqlCdcSourceConfig = serde_json::from_value(json!({
182 "connection_url": "mysql://h/db", "server_id": 5,
183 "start_position": { "type": "file_pos", "file": "binlog.000003", "pos": 4 }
184 }))
185 .unwrap();
186 assert_eq!(
187 c.start_position,
188 StartPosition::FilePos {
189 file: "binlog.000003".into(),
190 pos: 4
191 }
192 );
193 }
194
195 #[test]
196 fn rejects_zero_server_id() {
197 let mut c = minimal();
198 c.server_id = 0;
199 assert!(c.validate().is_err());
200 }
201
202 #[test]
203 fn rejects_unqualified_table_filter() {
204 let mut c = minimal();
205 c.include_tables = vec!["users".into()];
206 assert!(c.validate().is_err());
207 }
208
209 #[test]
210 fn table_filter_allowlist_wins() {
211 let mut c = minimal();
212 c.include_tables = vec!["db.users".into()];
213 c.exclude_tables = vec!["db.users".into()];
214 assert!(c.table_included("db", "users"));
215 assert!(!c.table_included("db", "orders"));
216 }
217
218 #[test]
219 fn table_filter_blocklist() {
220 let mut c = minimal();
221 c.exclude_tables = vec!["db.secret".into()];
222 assert!(c.table_included("db", "users"));
223 assert!(!c.table_included("db", "secret"));
224 }
225
226 #[test]
227 fn debug_redacts_connection_url() {
228 let c: MysqlCdcSourceConfig = serde_json::from_value(json!({
229 "connection_url": "mysql://repl:secret@h:3306/db", "server_id": 1
230 }))
231 .unwrap();
232 let dbg = format!("{c:?}");
233 assert!(dbg.contains("***"));
234 assert!(!dbg.contains("secret"));
235 }
236
237 #[test]
238 fn accepts_minimal() {
239 assert!(minimal().validate().is_ok());
240 }
241}