use faucet_core::{DEFAULT_BATCH_SIZE, FaucetError};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
fn default_idle_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
fn default_include_columns() -> bool {
true
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StartPosition {
#[default]
Current,
Earliest,
GtidSet { value: String },
FilePos { file: String, pos: u64 },
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum CdcTls {
#[default]
Disable,
Require,
VerifyCa {
#[serde(default, skip_serializing_if = "Option::is_none")]
ca_path: Option<String>,
},
VerifyFull {
#[serde(default, skip_serializing_if = "Option::is_none")]
ca_path: Option<String>,
},
}
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct MysqlCdcSourceConfig {
pub connection_url: String,
pub server_id: u32,
#[serde(default)]
pub include_tables: Vec<String>,
#[serde(default)]
pub exclude_tables: Vec<String>,
#[serde(default)]
pub start_position: StartPosition,
#[serde(default = "default_include_columns")]
pub include_columns: bool,
#[serde(default)]
pub emit_schema_changes: bool,
#[serde(default)]
pub tls: CdcTls,
#[serde(
default = "default_idle_timeout",
with = "faucet_core::config::duration_secs"
)]
#[schemars(with = "u64")]
pub idle_timeout: Duration,
#[serde(default)]
pub max_staged_records: Option<usize>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
}
impl MysqlCdcSourceConfig {
pub fn validate(&self) -> Result<(), FaucetError> {
if self.connection_url.trim().is_empty() {
return Err(FaucetError::Config(
"mysql-cdc: connection_url must not be empty".into(),
));
}
if self.server_id == 0 {
return Err(FaucetError::Config(
"mysql-cdc: server_id must be a non-zero value unique across replicas".into(),
));
}
if self.idle_timeout.is_zero() {
return Err(FaucetError::Config(
"mysql-cdc: idle_timeout must be > 0".into(),
));
}
for t in self.include_tables.iter().chain(self.exclude_tables.iter()) {
if !t.contains('.') {
return Err(FaucetError::Config(format!(
"mysql-cdc: table filter '{t}' must be fully qualified as 'database.table'"
)));
}
}
faucet_core::validate_batch_size(self.batch_size)?;
let key = crate::state::state_key(self.server_id);
faucet_core::state::validate_state_key(&key)?;
Ok(())
}
pub fn table_included(&self, db: &str, table: &str) -> bool {
let q = format!("{db}.{table}");
if !self.include_tables.is_empty() {
return self.include_tables.iter().any(|t| t == &q);
}
!self.exclude_tables.iter().any(|t| t == &q)
}
}
impl fmt::Debug for MysqlCdcSourceConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MysqlCdcSourceConfig")
.field("connection_url", &"***")
.field("server_id", &self.server_id)
.field("include_tables", &self.include_tables)
.field("exclude_tables", &self.exclude_tables)
.field("start_position", &self.start_position)
.field("include_columns", &self.include_columns)
.field("emit_schema_changes", &self.emit_schema_changes)
.field("tls", &self.tls)
.field("idle_timeout", &self.idle_timeout)
.field("max_staged_records", &self.max_staged_records)
.field("batch_size", &self.batch_size)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn minimal() -> MysqlCdcSourceConfig {
serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@localhost:3306/db",
"server_id": 1001
}))
.unwrap()
}
#[test]
fn defaults_via_serde() {
let c = minimal();
assert_eq!(c.idle_timeout.as_secs(), 30);
assert_eq!(c.batch_size, DEFAULT_BATCH_SIZE);
assert_eq!(c.start_position, StartPosition::Current);
assert!(c.include_columns);
assert!(!c.emit_schema_changes);
assert_eq!(c.tls, CdcTls::Disable);
}
#[test]
fn start_position_tagged_enum() {
let c: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://h/db", "server_id": 5,
"start_position": { "type": "file_pos", "file": "binlog.000003", "pos": 4 }
}))
.unwrap();
assert_eq!(
c.start_position,
StartPosition::FilePos {
file: "binlog.000003".into(),
pos: 4
}
);
}
#[test]
fn rejects_zero_server_id() {
let mut c = minimal();
c.server_id = 0;
assert!(c.validate().is_err());
}
#[test]
fn rejects_unqualified_table_filter() {
let mut c = minimal();
c.include_tables = vec!["users".into()];
assert!(c.validate().is_err());
}
#[test]
fn table_filter_allowlist_wins() {
let mut c = minimal();
c.include_tables = vec!["db.users".into()];
c.exclude_tables = vec!["db.users".into()];
assert!(c.table_included("db", "users"));
assert!(!c.table_included("db", "orders"));
}
#[test]
fn table_filter_blocklist() {
let mut c = minimal();
c.exclude_tables = vec!["db.secret".into()];
assert!(c.table_included("db", "users"));
assert!(!c.table_included("db", "secret"));
}
#[test]
fn debug_redacts_connection_url() {
let c: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:secret@h:3306/db", "server_id": 1
}))
.unwrap();
let dbg = format!("{c:?}");
assert!(dbg.contains("***"));
assert!(!dbg.contains("secret"));
}
#[test]
fn accepts_minimal() {
assert!(minimal().validate().is_ok());
}
}