use faucet_core::Source;
use faucet_core::check::{CheckContext, ProbeStatus};
use faucet_source_mysql_cdc::{MysqlCdcSource, MysqlCdcSourceConfig};
use futures::StreamExt;
use mysql_async::{Conn, Opts, prelude::Queryable};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Duration;
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
use testcontainers_modules::mysql::Mysql;
fn startup_limit() -> &'static tokio::sync::Semaphore {
static SEM: OnceLock<tokio::sync::Semaphore> = OnceLock::new();
SEM.get_or_init(|| tokio::sync::Semaphore::new(2))
}
async fn start_mysql_cdc() -> (ContainerAsync<Mysql>, String) {
start_mysql_cdc_tagged("8.1").await
}
async fn start_mysql_cdc_tagged(tag: &str) -> (ContainerAsync<Mysql>, String) {
let _permit = startup_limit()
.acquire()
.await
.expect("startup semaphore closed");
let container = Mysql::default()
.with_tag(tag)
.with_cmd([
"--server-id=1",
"--log-bin=mysql-bin",
"--binlog-format=ROW",
"--binlog-row-image=FULL",
"--binlog-row-metadata=FULL",
])
.start()
.await
.expect("mysql CDC container start");
let port = container
.get_host_port_ipv4(3306)
.await
.expect("mysql port");
let url = format!("mysql://root@127.0.0.1:{port}/test");
(container, url)
}
fn build_config(url: &str) -> MysqlCdcSourceConfig {
serde_json::from_value(json!({
"connection_url": url,
"server_id": 1001,
"start_position": { "type": "current" },
"idle_timeout": 5,
"batch_size": 0
}))
.expect("config")
}
async fn connect(url: &str) -> Conn {
Conn::new(Opts::from_url(url).expect("parse URL"))
.await
.expect("connect")
}
async fn drain(source: &MysqlCdcSource) -> (Vec<Value>, Option<Value>) {
let ctx: HashMap<String, Value> = HashMap::new();
let mut pages = source.stream_pages(&ctx, 0);
let mut records = Vec::new();
let mut bookmark = None;
while let Some(page) = pages.next().await {
let page = page.expect("page");
records.extend(page.records);
if page.bookmark.is_some() {
bookmark = page.bookmark;
}
}
(records, bookmark)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cdc_captures_crud_then_resumes_without_replay() {
let (_container, url) = start_mysql_cdc().await;
{
let mut conn = connect(&url).await;
conn.query_drop("CREATE TABLE test.users (id INT PRIMARY KEY, name VARCHAR(64))")
.await
.expect("create table");
}
let source = MysqlCdcSource::new(build_config(&url))
.await
.expect("source new");
let writer_url = url.clone();
let writer = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let mut conn = connect(&writer_url).await;
conn.query_drop("INSERT INTO test.users (id, name) VALUES (1, 'alice')")
.await
.expect("insert");
conn.query_drop("UPDATE test.users SET name = 'bob' WHERE id = 1")
.await
.expect("update");
conn.query_drop("DELETE FROM test.users WHERE id = 1")
.await
.expect("delete");
});
let (records, bookmark) = drain(&source).await;
writer.await.expect("writer task");
let ops: Vec<&str> = records
.iter()
.map(|r| r["op"].as_str().unwrap_or(""))
.collect();
assert!(ops.contains(&"c"), "expected a create op, got {ops:?}");
assert!(ops.contains(&"u"), "expected an update op, got {ops:?}");
assert!(ops.contains(&"d"), "expected a delete op, got {ops:?}");
let create = records
.iter()
.find(|r| r["op"] == "c")
.expect("create record");
assert_eq!(create["schema"], "test", "schema must be 'test'");
assert_eq!(create["table"], "users", "table must be 'users'");
assert_eq!(
create["after"]["name"], "alice",
"after.name must be 'alice'; envelope: {create:?}"
);
assert!(
create["lsn"]["file"].is_string(),
"lsn.file must be a string; envelope: {create:?}"
);
assert!(
create["lsn"]["pos"].is_number(),
"lsn.pos must be a number; envelope: {create:?}"
);
let bookmark = bookmark.expect("cycle 1 must produce a bookmark");
source
.apply_start_bookmark(bookmark)
.await
.expect("apply bookmark");
let writer2_url = url.clone();
let writer2 = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let mut conn = connect(&writer2_url).await;
conn.query_drop("INSERT INTO test.users (id, name) VALUES (2, 'carol')")
.await
.expect("insert2");
});
let (records2, _bm2) = drain(&source).await;
writer2.await.expect("writer2 task");
assert!(
!records2.is_empty(),
"expected the post-bookmark insert to be delivered"
);
for r in &records2 {
let id = &r["after"]["id"];
assert_eq!(id, &json!(2), "resume replayed a pre-bookmark event: {r:?}");
}
assert!(
records2.iter().any(|r| r["op"] == "c"),
"expected a create op in cycle 2, got: {records2:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn source_trait_metadata_and_check_against_live_server() {
let (_container, url) = start_mysql_cdc().await;
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": url,
"server_id": 2002,
"include_tables": ["test.orders"],
"start_position": { "type": "current" },
"idle_timeout": 5,
"batch_size": 0
}))
.expect("config");
let source = MysqlCdcSource::new(config).await.expect("source new");
assert_eq!(source.connector_name(), "mysql-cdc");
assert!(
source.supports_exactly_once(),
"mysql-cdc bookmarks file/pos + per-page → exactly-once capable"
);
assert_eq!(source.state_key().as_deref(), Some("mysql-cdc:2002"));
let uri = source.dataset_uri();
assert!(
!uri.contains("root@") && !uri.contains("@127.0.0.1"),
"credentials must be stripped from dataset_uri: {uri}"
);
assert!(
uri.ends_with("?tables=test.orders"),
"dataset_uri must append include_tables: {uri}"
);
let schema = source.config_schema();
assert!(
schema.get("properties").is_some(),
"config_schema must be a JSON object schema: {schema}"
);
let report = source
.check(&CheckContext::default())
.await
.expect("check report");
assert_eq!(report.failed_count(), 0, "all probes must pass: {report:?}");
let names: Vec<&str> = report.probes.iter().map(|p| p.name).collect();
assert!(
names.contains(&"connection"),
"expected a connection probe, got {names:?}"
);
assert!(
names.contains(&"binlog-config"),
"expected a binlog-config probe, got {names:?}"
);
for probe in &report.probes {
assert!(
matches!(probe.status, ProbeStatus::Pass),
"probe {} must pass: {:?}",
probe.name,
probe.status
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn check_reports_binlog_config_failure_when_row_metadata_minimal() {
let _permit = startup_limit()
.acquire()
.await
.expect("startup semaphore closed");
let container = Mysql::default()
.with_cmd(["--server-id=1", "--log-bin=mysql-bin"]) .start()
.await
.expect("mysql start");
let port = container
.get_host_port_ipv4(3306)
.await
.expect("mysql port");
let url = format!("mysql://root@127.0.0.1:{port}/test");
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": url,
"server_id": 3003,
"idle_timeout": 5,
"batch_size": 0
}))
.expect("config");
let msg = match MysqlCdcSource::new(config).await {
Ok(_) => panic!("new() must reject MINIMAL binlog_row_metadata"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("binlog_row_metadata"),
"error must name the offending binlog variable; got: {msg}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fetch_with_context_aggregates_and_emit_schema_changes_yields_ddl() {
let (_container, url) = start_mysql_cdc().await;
{
let mut conn = connect(&url).await;
conn.query_drop("CREATE TABLE test.t (id INT PRIMARY KEY, n VARCHAR(32))")
.await
.expect("create table");
}
let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
"connection_url": url,
"server_id": 4004,
"start_position": { "type": "current" },
"emit_schema_changes": true,
"idle_timeout": 5,
"batch_size": 0
}))
.expect("config");
let source = MysqlCdcSource::new(config).await.expect("source new");
let writer_url = url.clone();
let writer = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let mut conn = connect(&writer_url).await;
conn.query_drop("INSERT INTO test.t (id, n) VALUES (1, 'x')")
.await
.expect("insert");
conn.query_drop("ALTER TABLE test.t ADD COLUMN extra INT")
.await
.expect("alter");
});
let ctx: HashMap<String, Value> = HashMap::new();
let records = source.fetch_with_context(&ctx).await.expect("fetch");
writer.await.expect("writer");
let ops: Vec<&str> = records
.iter()
.map(|r| r["op"].as_str().unwrap_or(""))
.collect();
assert!(
ops.contains(&"c"),
"fetch_with_context must aggregate the insert, got {ops:?}"
);
let ddl = records
.iter()
.find(|r| r["op"] == "ddl")
.expect("emit_schema_changes must produce a ddl envelope");
assert!(
ddl["statement"]
.as_str()
.unwrap_or("")
.contains("ALTER TABLE"),
"ddl envelope must carry the statement text: {ddl:?}"
);
assert!(
ddl["lsn"]["file"].is_string() && ddl["lsn"]["pos"].is_number(),
"ddl envelope must carry a file/pos lsn: {ddl:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capture_resume_position_returns_file_and_pos() {
let (_container, url) = start_mysql_cdc().await;
let source = MysqlCdcSource::new(build_config(&url))
.await
.expect("source");
let pos = source
.capture_resume_position()
.await
.expect("capture")
.expect("mysql-cdc must support capture");
assert!(
pos.get("file").and_then(|v| v.as_str()).is_some(),
"file present: {pos}"
);
assert!(
pos.get("pos").and_then(|v| v.as_u64()).is_some(),
"pos present: {pos}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn capture_resume_position_works_on_mysql_8_4() {
let (_container, url) = start_mysql_cdc_tagged("8.4").await;
let source = MysqlCdcSource::new(build_config(&url))
.await
.expect("source new on MySQL 8.4");
let pos = source
.capture_resume_position()
.await
.expect("capture_resume_position must succeed on MySQL 8.4")
.expect("mysql-cdc must capture a position");
assert!(
pos.get("file")
.and_then(|v| v.as_str())
.is_some_and(|f| !f.is_empty()),
"binlog file present: {pos}"
);
assert!(
pos.get("pos").and_then(|v| v.as_u64()).is_some(),
"binlog pos present: {pos}"
);
}