use crate::config::{CdcTls, MysqlCdcSourceConfig, StartPosition};
use crate::convert::binlog_row_to_json;
use crate::state::{Bookmark, state_key};
use async_trait::async_trait;
use faucet_core::{FaucetError, Source, Stream, StreamPage};
use mysql_async::binlog::events::{EventData, RowsEventData};
use mysql_async::prelude::Queryable;
use mysql_async::{BinlogStreamRequest, Conn, Opts, OptsBuilder, Row, SslOpts};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::pin::Pin;
use tokio::sync::Mutex;
pub struct MysqlCdcSource {
config: MysqlCdcSourceConfig,
opts: Opts,
state_key_value: String,
pending_bookmark: Mutex<Option<Bookmark>>,
}
impl MysqlCdcSource {
pub async fn new(config: MysqlCdcSourceConfig) -> Result<Self, FaucetError> {
config.validate()?;
let opts = build_opts(&config)?;
let key = state_key(config.server_id);
let mut conn = Conn::new(opts.clone())
.await
.map_err(|e| FaucetError::Source(format!("mysql-cdc: cannot connect: {e}")))?;
run_preflight(&mut conn, &config).await?;
drop(conn);
Ok(Self {
config,
opts,
state_key_value: key,
pending_bookmark: Mutex::new(None),
})
}
}
#[async_trait]
impl Source for MysqlCdcSource {
async fn fetch_with_context(
&self,
ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
use futures::StreamExt;
let mut pages = self.stream_pages_impl(ctx, 0);
let mut all = Vec::new();
while let Some(page) = pages.next().await {
all.extend(page?.records);
}
Ok(all)
}
fn stream_pages<'a>(
&'a self,
ctx: &'a HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
self.stream_pages_impl(ctx, self.config.batch_size)
}
fn config_schema(&self) -> Value {
serde_json::to_value(schemars::schema_for!(MysqlCdcSourceConfig)).unwrap_or(Value::Null)
}
fn state_key(&self) -> Option<String> {
Some(self.state_key_value.clone())
}
async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
let b = Bookmark::from_value(bookmark)?;
*self.pending_bookmark.lock().await = Some(b);
Ok(())
}
async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
let mut conn = Conn::new(self.opts.clone()).await.map_err(|e| {
FaucetError::Source(format!("mysql-cdc: capture_position connect: {e}"))
})?;
let (file, pos) = current_binlog_position(&mut conn).await?;
drop(conn);
Ok(Some(Bookmark::FilePos { file, pos }.to_value()?))
}
fn supports_exactly_once(&self) -> bool {
true
}
fn connector_name(&self) -> &'static str {
"mysql-cdc"
}
fn dataset_uri(&self) -> String {
let base = faucet_core::redact_uri_credentials(&self.config.connection_url);
if self.config.include_tables.is_empty() {
base
} else {
format!("{base}?tables={}", self.config.include_tables.join(","))
}
}
async fn check(
&self,
ctx: &faucet_core::check::CheckContext,
) -> Result<faucet_core::check::CheckReport, FaucetError> {
use faucet_core::check::{CheckReport, Probe};
let start = std::time::Instant::now();
let probe_result = tokio::time::timeout(ctx.timeout, async {
let mut conn = Conn::new(self.opts.clone()).await.map_err(|e| {
Probe::fail_hint(
"connection",
start.elapsed(),
format!("could not connect: {e}"),
"verify the host is reachable and credentials are valid",
)
})?;
let connection = Probe::pass("connection", start.elapsed());
let binlog_config = match run_preflight_probes(&mut conn, &self.config).await {
Ok(_summary) => Probe::pass("binlog-config", start.elapsed()),
Err(msg) => Probe::fail_hint(
"binlog-config",
start.elapsed(),
msg,
"Set binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL \
and grant REPLICATION SLAVE + REPLICATION CLIENT",
),
};
Ok::<(Probe, Probe), Probe>((connection, binlog_config))
})
.await;
match probe_result {
Ok(Ok((conn_probe, cfg_probe))) => Ok(CheckReport {
probes: vec![conn_probe, cfg_probe],
}),
Ok(Err(probe)) => Ok(CheckReport::single(probe)),
Err(_elapsed) => Ok(CheckReport::single(Probe::fail_hint(
"connection",
start.elapsed(),
"connection timed out",
"the database did not respond within the check timeout",
))),
}
}
}
impl MysqlCdcSource {
fn stream_pages_impl<'a>(
&'a self,
_ctx: &'a HashMap<String, Value>,
batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let idle_timeout = self.config.idle_timeout;
let per_transaction = batch_size != 0;
Box::pin(async_stream::try_stream! {
use futures::StreamExt;
let pending = self.pending_bookmark.lock().await.take();
let resolved = resolve_start(&self.config.start_position, pending.as_ref());
let mut conn = Conn::new(self.opts.clone())
.await
.map_err(|e| FaucetError::Source(format!("mysql-cdc: connect failed: {e}")))?;
let resolved = resolve_current(resolved, &mut conn).await?;
let req = build_request(self.config.server_id, &resolved)?;
let binlog_stream = conn
.get_binlog_stream(req)
.await
.map_err(|e| FaucetError::Source(format!("mysql-cdc: get_binlog_stream: {e}")))?;
let mut stream = std::pin::pin!(binlog_stream);
let mut current_file = match &resolved {
ResolvedStart::FilePos { file, .. } => file.clone(),
_ => String::new(),
};
let mut buffer: Vec<Value> = Vec::new();
let mut in_txn = false;
let mut txid: u64 = 0;
let mut last_commit_bookmark: Option<Bookmark> = None;
let mut agg_records: Vec<Value> = Vec::new();
macro_rules! commit_buffer {
($bm:expr) => {{
let bm: Bookmark = $bm;
if per_transaction {
yield StreamPage {
records: std::mem::take(&mut buffer),
bookmark: Some(bm.to_value()?),
};
} else if !buffer.is_empty() {
last_commit_bookmark = Some(bm);
agg_records.extend(std::mem::take(&mut buffer));
}
}};
}
loop {
match tokio::time::timeout(idle_timeout, stream.next()).await {
Ok(Some(Ok(event))) => {
let header = event.header();
let ts_ms = u64::from(header.timestamp()) * 1_000;
let log_pos = u64::from(header.log_pos());
let event_data = event
.read_data()
.map_err(|e| FaucetError::Source(format!(
"mysql-cdc: read_data failed: {e}"
)))?;
match event_data {
Some(EventData::RotateEvent(re)) => {
current_file = re.name().into_owned();
}
Some(EventData::GtidEvent(_g)) => {
if !buffer.is_empty() {
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
commit_buffer!(bm);
txid = txid.wrapping_add(1);
}
in_txn = true;
}
Some(EventData::QueryEvent(qe)) => {
let q = qe.query();
let q_upper = q.trim().to_ascii_uppercase();
if q_upper == "BEGIN" {
if !buffer.is_empty() {
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
commit_buffer!(bm);
txid = txid.wrapping_add(1);
}
in_txn = true;
} else if q_upper == "COMMIT" {
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
commit_buffer!(bm);
in_txn = false;
txid = txid.wrapping_add(1);
} else {
if should_flush_buffer_before_ddl(buffer.is_empty()) {
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
commit_buffer!(bm);
txid = txid.wrapping_add(1);
}
if self.config.emit_schema_changes {
let envelope = build_ddl_envelope(
q.as_ref(),
ts_ms,
¤t_file,
log_pos,
);
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
if per_transaction {
yield StreamPage {
records: vec![envelope],
bookmark: Some(bm.to_value()?),
};
} else {
last_commit_bookmark = Some(bm);
agg_records.push(envelope);
}
}
in_txn = false;
}
}
Some(EventData::RowsEvent(re)) => {
let table_id = re.table_id();
let tme = stream
.get_tme(table_id)
.ok_or_else(|| FaucetError::Source(format!(
"mysql-cdc: missing TableMapEvent for table_id={table_id}"
)))?;
let db = tme.database_name().into_owned();
let table = tme.table_name().into_owned();
if !self.config.table_included(&db, &table) {
continue;
}
let op = op_from_rows_event(&re);
let lsn = json!({ "file": ¤t_file, "pos": log_pos });
for row_result in re.rows(tme) {
let (before_row, after_row) = row_result.map_err(|e| {
FaucetError::Source(format!(
"mysql-cdc: row decode error: {e}"
))
})?;
let before_json = if self.config.include_columns {
match &before_row {
Some(r) => binlog_row_to_json(r)?,
None => Value::Null,
}
} else {
Value::Null
};
let after_json = match &after_row {
Some(r) => binlog_row_to_json(r)?,
None => Value::Null,
};
let envelope = build_envelope(
op, ts_ms, &db, &table,
before_json, after_json,
lsn.clone(), txid,
);
if let Some(max) = self.config.max_staged_records
&& buffer.len() >= max
{
Err(FaucetError::Source(format!(
"mysql-cdc: in-progress transaction exceeded \
max_staged_records ({max}); aborting to avoid \
unbounded memory growth. Raise \
max_staged_records or split the source transaction."
)))?;
}
buffer.push(envelope);
}
}
Some(EventData::XidEvent(_xid)) => {
let bm = Bookmark::FilePos {
file: current_file.clone(),
pos: log_pos,
};
commit_buffer!(bm);
in_txn = false;
txid = txid.wrapping_add(1);
}
_ => {
}
}
}
Ok(Some(Err(e))) => {
Err(FaucetError::Source(format!("mysql-cdc: stream error: {e}")))?;
}
Ok(None) | Err(_) => {
let _ = in_txn;
if !per_transaction
&& let Some(bm) = last_commit_bookmark.take()
&& !agg_records.is_empty()
{
yield StreamPage {
records: std::mem::take(&mut agg_records),
bookmark: Some(bm.to_value()?),
};
}
break;
}
}
}
tracing::info!(
connector = "mysql-cdc",
server_id = self.config.server_id,
"binlog fetch cycle complete",
);
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolvedStart {
Current { file: String, pos: u64 },
Earliest,
FilePos { file: String, pos: u64 },
GtidSet { value: String },
}
pub(crate) fn resolve_start(
start_position: &StartPosition,
pending: Option<&Bookmark>,
) -> ResolvedStart {
if let Some(bm) = pending {
return match bm {
Bookmark::FilePos { file, pos } => ResolvedStart::FilePos {
file: file.clone(),
pos: *pos,
},
Bookmark::GtidSet { gtid_set } => ResolvedStart::GtidSet {
value: gtid_set.clone(),
},
};
}
match start_position {
StartPosition::Current => {
ResolvedStart::Current {
file: String::new(),
pos: 0,
}
}
StartPosition::Earliest => ResolvedStart::Earliest,
StartPosition::FilePos { file, pos } => ResolvedStart::FilePos {
file: file.clone(),
pos: *pos,
},
StartPosition::GtidSet { value } => ResolvedStart::GtidSet {
value: value.clone(),
},
}
}
async fn resolve_current(
resolved: ResolvedStart,
conn: &mut Conn,
) -> Result<ResolvedStart, FaucetError> {
if !matches!(resolved, ResolvedStart::Current { .. }) {
return Ok(resolved);
}
let (file, pos) = current_binlog_position(conn).await?;
Ok(ResolvedStart::FilePos { file, pos })
}
async fn current_binlog_position(conn: &mut Conn) -> Result<(String, u64), FaucetError> {
let (row, stmt): (Option<Row>, &str) = match conn.query_first("SHOW BINARY LOG STATUS").await {
Ok(row) => (row, "SHOW BINARY LOG STATUS"),
Err(new_err) => match conn.query_first("SHOW MASTER STATUS").await {
Ok(row) => (row, "SHOW MASTER STATUS"),
Err(old_err) => return Err(binlog_position_error(new_err, old_err)),
},
};
let extracted = row.map(|r| (r.get::<String, _>(0), r.get::<u64, _>(1)));
finalize_binlog_position(extracted, stmt)
}
fn binlog_position_error(
new_err: impl std::fmt::Display,
old_err: impl std::fmt::Display,
) -> FaucetError {
FaucetError::Source(format!(
"mysql-cdc: reading current binlog position failed — \
`SHOW BINARY LOG STATUS`: {new_err}; `SHOW MASTER STATUS`: {old_err}"
))
}
fn finalize_binlog_position(
extracted: Option<(Option<String>, Option<u64>)>,
stmt: &str,
) -> Result<(String, u64), FaucetError> {
let (file, pos) = extracted.ok_or_else(|| {
FaucetError::Source(format!(
"mysql-cdc: {stmt} returned no rows; is binary logging enabled?"
))
})?;
let file =
file.ok_or_else(|| FaucetError::Source(format!("mysql-cdc: {stmt}: missing File column")))?;
let pos = pos.ok_or_else(|| {
FaucetError::Source(format!("mysql-cdc: {stmt}: missing Position column"))
})?;
Ok((file, pos))
}
fn build_request<'r>(
server_id: u32,
resolved: &'r ResolvedStart,
) -> Result<BinlogStreamRequest<'r>, FaucetError> {
use mysql_async::Sid;
use std::str::FromStr;
match resolved {
ResolvedStart::Current { .. } => Ok(BinlogStreamRequest::new(server_id)),
ResolvedStart::Earliest => {
Ok(BinlogStreamRequest::new(server_id))
}
ResolvedStart::FilePos { file, pos } => {
Ok(BinlogStreamRequest::new(server_id)
.with_filename(file.as_bytes())
.with_pos(*pos))
}
ResolvedStart::GtidSet { value } => {
let sids: Vec<Sid<'static>> = value
.split(',')
.map(|part| {
let trimmed = part.trim();
Sid::from_str(trimmed).map_err(|e| {
FaucetError::Source(format!("mysql-cdc: invalid GTID set '{trimmed}': {e}"))
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(BinlogStreamRequest::new(server_id)
.with_gtid()
.with_gtid_set(sids))
}
}
}
pub(crate) fn should_flush_buffer_before_ddl(buffer_is_empty: bool) -> bool {
!buffer_is_empty
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlannedPage {
pub record_count: usize,
pub is_ddl: bool,
pub bookmark_pos: Option<u64>,
}
#[cfg(test)]
pub(crate) fn plan_ddl_pages(
buffered_rows: usize,
emit_schema_changes: bool,
log_pos: u64,
) -> Vec<PlannedPage> {
let mut pages = Vec::new();
if should_flush_buffer_before_ddl(buffered_rows == 0) {
pages.push(PlannedPage {
record_count: buffered_rows,
is_ddl: false,
bookmark_pos: Some(log_pos),
});
}
if emit_schema_changes {
pages.push(PlannedPage {
record_count: 1,
is_ddl: true,
bookmark_pos: Some(log_pos),
});
}
pages
}
pub(crate) fn op_from_rows_event(re: &RowsEventData<'_>) -> &'static str {
match re {
RowsEventData::WriteRowsEvent(_) | RowsEventData::WriteRowsEventV1(_) => "c",
RowsEventData::UpdateRowsEvent(_)
| RowsEventData::UpdateRowsEventV1(_)
| RowsEventData::PartialUpdateRowsEvent(_) => "u",
RowsEventData::DeleteRowsEvent(_) | RowsEventData::DeleteRowsEventV1(_) => "d",
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_envelope(
op: &str,
ts_ms: u64,
schema: &str,
table: &str,
before: Value,
after: Value,
lsn: Value,
txid: u64,
) -> Value {
let mut obj = Map::new();
obj.insert("op".into(), json!(op));
obj.insert("ts_ms".into(), json!(ts_ms));
obj.insert("schema".into(), json!(schema));
obj.insert("table".into(), json!(table));
obj.insert("before".into(), before);
obj.insert("after".into(), after);
obj.insert("lsn".into(), lsn);
obj.insert("txid".into(), json!(txid));
Value::Object(obj)
}
fn build_ddl_envelope(statement: &str, ts_ms: u64, file: &str, pos: u64) -> Value {
json!({
"op": "ddl",
"ts_ms": ts_ms,
"statement": statement,
"lsn": { "file": file, "pos": pos },
})
}
fn build_opts(config: &MysqlCdcSourceConfig) -> Result<Opts, FaucetError> {
let base = Opts::from_url(&config.connection_url)
.map_err(|e| FaucetError::Config(format!("mysql-cdc: invalid connection URL: {e}")))?;
let ssl = match &config.tls {
CdcTls::Disable => return Ok(base),
CdcTls::Require => SslOpts::default()
.with_danger_accept_invalid_certs(true)
.with_danger_skip_domain_validation(true),
CdcTls::VerifyCa { ca_path } => {
let mut s = SslOpts::default().with_danger_skip_domain_validation(true);
if let Some(p) = ca_path {
s = s.with_root_certs(vec![PathBuf::from(p).into()]);
}
s
}
CdcTls::VerifyFull { ca_path } => {
let mut s = SslOpts::default();
if let Some(p) = ca_path {
s = s.with_root_certs(vec![PathBuf::from(p).into()]);
}
s
}
};
Ok(OptsBuilder::from_opts(base).ssl_opts(ssl).into())
}
fn binlog_row_value_options_ok(value: &str) -> bool {
value.trim().is_empty()
}
async fn run_preflight_probes(
conn: &mut Conn,
config: &MysqlCdcSourceConfig,
) -> Result<String, String> {
let fmt: Option<(String, String)> = conn
.query_first("SHOW VARIABLES LIKE 'binlog_format'")
.await
.map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_format' failed: {e}"))?;
match fmt.as_ref() {
Some((_, v)) if !v.eq_ignore_ascii_case("ROW") => {
return Err(format!(
"binlog_format is '{v}', must be ROW. \
Set binlog_format=ROW in your MySQL config."
));
}
None => {
return Err("binlog_format variable not found. Is binary logging enabled?".into());
}
_ => {}
}
let img: Option<(String, String)> = conn
.query_first("SHOW VARIABLES LIKE 'binlog_row_image'")
.await
.map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_image' failed: {e}"))?;
match img.as_ref() {
Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
return Err(format!(
"binlog_row_image is '{v}', must be FULL. \
Set binlog_row_image=FULL in your MySQL config."
));
}
None => {
return Err("binlog_row_image variable not found.".into());
}
_ => {}
}
let meta: Option<(String, String)> = conn
.query_first("SHOW VARIABLES LIKE 'binlog_row_metadata'")
.await
.map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_metadata' failed: {e}"))?;
match meta.as_ref() {
Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
return Err(format!(
"binlog_row_metadata is '{v}', must be FULL. \
Set binlog_row_metadata=FULL in your MySQL config."
));
}
None => {
return Err("binlog_row_metadata variable not found.".into());
}
_ => {}
}
let value_opts: Option<(String, String)> = conn
.query_first("SHOW VARIABLES LIKE 'binlog_row_value_options'")
.await
.map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_value_options' failed: {e}"))?;
if let Some((_, v)) = value_opts.as_ref()
&& !binlog_row_value_options_ok(v)
{
return Err(format!(
"binlog_row_value_options is '{v}', must be empty (full JSON). \
Partial JSON diffs cannot be reconstructed for CDC. \
Set binlog_row_value_options='' on the MySQL server."
));
}
let grants: Vec<String> = conn
.query("SHOW GRANTS FOR CURRENT_USER()")
.await
.map_err(|e| format!("SHOW GRANTS failed: {e}"))?;
let grants_combined = grants.join(" ").to_uppercase();
let has_replication = grants_combined.contains("ALL PRIVILEGES")
|| (grants_combined.contains("REPLICATION SLAVE")
&& grants_combined.contains("REPLICATION CLIENT"));
if !has_replication {
return Err(
"user lacks REPLICATION SLAVE and/or REPLICATION CLIENT privileges. \
Grant them with: GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host';"
.into(),
);
}
if matches!(config.start_position, StartPosition::GtidSet { .. }) {
let gtid: Option<(String, String)> = conn
.query_first("SHOW VARIABLES LIKE 'gtid_mode'")
.await
.map_err(|e| format!("SHOW VARIABLES LIKE 'gtid_mode' failed: {e}"))?;
match gtid.as_ref() {
Some((_, v)) if !v.eq_ignore_ascii_case("ON") => {
return Err(format!(
"start_position is GtidSet but gtid_mode is '{v}' (must be ON). \
Enable GTID mode: --gtid-mode=ON --enforce-gtid-consistency=ON"
));
}
None => {
return Err("gtid_mode variable not found".into());
}
_ => {}
}
}
Ok(
"binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL, \
binlog_row_value_options=full, grants OK"
.into(),
)
}
async fn run_preflight(conn: &mut Conn, config: &MysqlCdcSourceConfig) -> Result<(), FaucetError> {
run_preflight_probes(conn, config)
.await
.map(|_| ())
.map_err(|m| FaucetError::Source(format!("mysql-cdc: {m}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Bookmark;
use serde_json::json;
#[test]
fn binlog_row_value_options_empty_is_ok() {
assert!(binlog_row_value_options_ok(""));
assert!(binlog_row_value_options_ok(" "));
}
#[test]
fn binlog_row_value_options_partial_json_rejected() {
assert!(!binlog_row_value_options_ok("PARTIAL_JSON"));
assert!(!binlog_row_value_options_ok("partial_json"));
assert!(!binlog_row_value_options_ok(" PARTIAL_JSON "));
}
#[test]
fn finalize_binlog_position_returns_file_and_pos() {
let got = finalize_binlog_position(Some((Some("mysql-bin.000007".into()), Some(154))), "S")
.expect("valid row decodes");
assert_eq!(got, ("mysql-bin.000007".to_string(), 154));
}
#[test]
fn finalize_binlog_position_no_rows_means_binlogging_disabled() {
let err = finalize_binlog_position(None, "SHOW BINARY LOG STATUS")
.expect_err("no rows must error");
let msg = err.to_string();
assert!(msg.contains("returned no rows"), "{msg}");
assert!(msg.contains("SHOW BINARY LOG STATUS"), "{msg}");
}
#[test]
fn finalize_binlog_position_missing_file_column() {
let err = finalize_binlog_position(Some((None, Some(4))), "SHOW MASTER STATUS")
.expect_err("missing File must error");
assert!(err.to_string().contains("missing File column"), "{err}");
}
#[test]
fn finalize_binlog_position_missing_position_column() {
let err = finalize_binlog_position(
Some((Some("mysql-bin.1".into()), None)),
"SHOW MASTER STATUS",
)
.expect_err("missing Position must error");
assert!(err.to_string().contains("missing Position column"), "{err}");
}
#[test]
fn binlog_position_error_names_both_statements() {
let msg = binlog_position_error("parse error NEW", "parse error OLD").to_string();
assert!(
msg.contains("SHOW BINARY LOG STATUS") && msg.contains("parse error NEW"),
"{msg}"
);
assert!(
msg.contains("SHOW MASTER STATUS") && msg.contains("parse error OLD"),
"{msg}"
);
}
#[test]
fn file_pos_bookmark_overrides_current() {
let bm = Bookmark::FilePos {
file: "binlog.000003".into(),
pos: 4567,
};
let resolved = resolve_start(&StartPosition::Current, Some(&bm));
assert_eq!(
resolved,
ResolvedStart::FilePos {
file: "binlog.000003".into(),
pos: 4567
}
);
}
#[test]
fn file_pos_bookmark_overrides_gtid_config() {
let bm = Bookmark::FilePos {
file: "binlog.000010".into(),
pos: 123,
};
let resolved = resolve_start(
&StartPosition::GtidSet {
value: "abc:1-100".into(),
},
Some(&bm),
);
assert_eq!(
resolved,
ResolvedStart::FilePos {
file: "binlog.000010".into(),
pos: 123
}
);
}
#[test]
fn gtid_bookmark_overrides_current() {
let bm = Bookmark::GtidSet {
gtid_set: "abc:1-100".into(),
};
let resolved = resolve_start(&StartPosition::Current, Some(&bm));
assert_eq!(
resolved,
ResolvedStart::GtidSet {
value: "abc:1-100".into()
}
);
}
#[test]
fn no_bookmark_current_yields_current() {
let resolved = resolve_start(&StartPosition::Current, None);
assert!(matches!(resolved, ResolvedStart::Current { .. }));
}
#[test]
fn no_bookmark_earliest_yields_earliest() {
let resolved = resolve_start(&StartPosition::Earliest, None);
assert_eq!(resolved, ResolvedStart::Earliest);
}
#[test]
fn no_bookmark_file_pos_config_passes_through() {
let resolved = resolve_start(
&StartPosition::FilePos {
file: "binlog.000001".into(),
pos: 4,
},
None,
);
assert_eq!(
resolved,
ResolvedStart::FilePos {
file: "binlog.000001".into(),
pos: 4
}
);
}
#[test]
fn envelope_shape_insert() {
let lsn = json!({ "file": "binlog.000001", "pos": 4567_u64 });
let after = json!({ "id": 1, "name": "alice" });
let env = build_envelope(
"c",
1_000,
"mydb",
"users",
Value::Null,
after.clone(),
lsn.clone(),
0,
);
assert_eq!(env["op"], "c");
assert_eq!(env["ts_ms"], 1_000_u64);
assert_eq!(env["schema"], "mydb");
assert_eq!(env["table"], "users");
assert_eq!(env["before"], Value::Null);
assert_eq!(env["after"], after);
assert_eq!(env["lsn"], lsn);
assert_eq!(env["txid"], 0_u64);
}
#[test]
fn envelope_shape_update() {
let before = json!({ "id": 1, "name": "alice" });
let after = json!({ "id": 1, "name": "bob" });
let lsn = json!({ "file": "binlog.000002", "pos": 9999_u64 });
let env = build_envelope(
"u",
2_000,
"db",
"tbl",
before.clone(),
after.clone(),
lsn,
3,
);
assert_eq!(env["op"], "u");
assert_eq!(env["before"], before);
assert_eq!(env["after"], after);
assert_eq!(env["txid"], 3_u64);
}
#[test]
fn envelope_shape_delete() {
let before = json!({ "id": 42 });
let lsn = json!({ "file": "binlog.000003", "pos": 100_u64 });
let env = build_envelope("d", 3_000, "db", "tbl", before.clone(), Value::Null, lsn, 7);
assert_eq!(env["op"], "d");
assert_eq!(env["before"], before);
assert_eq!(env["after"], Value::Null);
}
#[test]
fn envelope_has_all_expected_keys() {
let env = build_envelope(
"c",
0,
"s",
"t",
Value::Null,
Value::Null,
json!({ "file": "f", "pos": 0_u64 }),
0,
);
let obj = env.as_object().unwrap();
for key in &[
"op", "ts_ms", "schema", "table", "before", "after", "lsn", "txid",
] {
assert!(obj.contains_key(*key), "missing key: {key}");
}
}
#[test]
fn build_opts_disable_succeeds() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@localhost:3306/db",
"server_id": 1001
}))
.unwrap();
assert!(build_opts(&config).is_ok());
}
#[test]
fn build_opts_require_tls_succeeds() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@localhost:3306/db",
"server_id": 1002,
"tls": { "mode": "require" }
}))
.unwrap();
assert!(build_opts(&config).is_ok());
}
#[test]
fn build_opts_verify_ca_no_path() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@localhost:3306/db",
"server_id": 1003,
"tls": { "mode": "verify_ca" }
}))
.unwrap();
assert!(build_opts(&config).is_ok());
}
#[test]
fn build_opts_invalid_url_errors() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "not-a-valid-url",
"server_id": 1
}))
.unwrap();
assert!(build_opts(&config).is_err());
}
#[test]
fn dataset_uri_strips_credentials_no_tables() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@h:3306/db",
"server_id": 1
}))
.unwrap();
let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
assert_eq!(redacted, "mysql://h:3306/db");
let uri = if config.include_tables.is_empty() {
redacted
} else {
format!("{redacted}?tables={}", config.include_tables.join(","))
};
assert_eq!(uri, "mysql://h:3306/db");
}
#[test]
fn dataset_uri_appends_tables_when_present() {
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": "mysql://repl:pass@h:3306/db",
"server_id": 1,
"include_tables": ["db.orders", "db.users"]
}))
.unwrap();
let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
let uri = if config.include_tables.is_empty() {
redacted
} else {
format!("{redacted}?tables={}", config.include_tables.join(","))
};
assert_eq!(uri, "mysql://h:3306/db?tables=db.orders,db.users");
}
#[test]
fn build_request_current_arm_succeeds() {
let resolved = ResolvedStart::Current {
file: String::new(),
pos: 0,
};
assert!(build_request(42, &resolved).is_ok());
}
#[test]
fn build_request_earliest_arm_succeeds() {
let resolved = ResolvedStart::Earliest;
assert!(build_request(7, &resolved).is_ok());
}
#[test]
fn build_request_file_pos_arm_succeeds() {
let resolved = ResolvedStart::FilePos {
file: "binlog.000007".into(),
pos: 8192,
};
assert!(build_request(1001, &resolved).is_ok());
}
#[test]
fn build_request_valid_gtid_set_succeeds() {
let resolved = ResolvedStart::GtidSet {
value: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5, \
8a1d3a7c-71ca-11e1-9e33-c80aa9429562:1-10"
.into(),
};
assert!(build_request(1001, &resolved).is_ok());
}
#[test]
fn build_request_invalid_gtid_set_errors_with_source_variant() {
let resolved = ResolvedStart::GtidSet {
value: "totally-not-a-gtid".into(),
};
match build_request(1001, &resolved) {
Err(FaucetError::Source(msg)) => {
assert!(
msg.contains("invalid GTID set 'totally-not-a-gtid'"),
"message must name the bad fragment; got: {msg}"
);
}
Err(other) => panic!("expected FaucetError::Source, got {other:?}"),
Ok(_) => panic!("invalid GTID must error"),
}
}
#[test]
fn ddl_envelope_shape() {
let env = build_ddl_envelope("ALTER TABLE t ADD c INT", 1_700, "binlog.000004", 512);
assert_eq!(env["op"], "ddl");
assert_eq!(env["ts_ms"], 1_700_u64);
assert_eq!(env["statement"], "ALTER TABLE t ADD c INT");
assert_eq!(
env["lsn"],
json!({ "file": "binlog.000004", "pos": 512_u64 })
);
let obj = env.as_object().unwrap();
assert!(!obj.contains_key("before"));
assert!(!obj.contains_key("after"));
assert!(!obj.contains_key("schema"));
assert!(!obj.contains_key("table"));
}
#[test]
fn should_flush_buffer_before_ddl_decision() {
assert!(should_flush_buffer_before_ddl(false));
assert!(!should_flush_buffer_before_ddl(true));
}
#[test]
fn ddl_with_buffered_rows_flushes_them_first() {
let pages = plan_ddl_pages(3, true, 4567);
assert_eq!(
pages.len(),
2,
"expected a buffer-flush page then a DDL page"
);
assert_eq!(pages[0].record_count, 3);
assert!(
!pages[0].is_ddl,
"first page must be the buffered rows, not the DDL"
);
assert_eq!(
pages[0].bookmark_pos,
Some(4567),
"buffered rows must advance the bookmark — no silent loss"
);
assert!(pages[1].is_ddl);
assert_eq!(pages[1].record_count, 1);
assert_eq!(pages[1].bookmark_pos, Some(4567));
}
#[test]
fn ddl_with_empty_buffer_emits_only_the_ddl() {
let pages = plan_ddl_pages(0, true, 100);
assert_eq!(pages.len(), 1);
assert!(pages[0].is_ddl);
assert_eq!(pages[0].bookmark_pos, Some(100));
}
#[test]
fn ddl_with_buffered_rows_flushes_even_when_schema_changes_disabled() {
let pages = plan_ddl_pages(2, false, 888);
assert_eq!(pages.len(), 1, "buffered rows must still be flushed");
assert!(!pages[0].is_ddl);
assert_eq!(pages[0].record_count, 2);
assert_eq!(pages[0].bookmark_pos, Some(888));
}
#[test]
fn ddl_with_empty_buffer_and_no_schema_changes_emits_nothing() {
let pages = plan_ddl_pages(0, false, 12);
assert!(
pages.is_empty(),
"nothing to emit: empty buffer + schema changes off"
);
}
}