1use crate::config::{CdcTls, MysqlCdcSourceConfig, StartPosition};
24use crate::convert::binlog_row_to_json;
25use crate::state::{Bookmark, state_key};
26use async_trait::async_trait;
27use faucet_core::{FaucetError, Source, Stream, StreamPage};
28use mysql_async::binlog::events::{EventData, RowsEventData};
29use mysql_async::prelude::Queryable;
30use mysql_async::{BinlogStreamRequest, Conn, Opts, OptsBuilder, Row, SslOpts};
31use serde_json::{Map, Value, json};
32use std::collections::HashMap;
33use std::path::PathBuf;
34use std::pin::Pin;
35use tokio::sync::Mutex;
36
37pub struct MysqlCdcSource {
42 config: MysqlCdcSourceConfig,
43 opts: Opts,
44 state_key_value: String,
45 pending_bookmark: Mutex<Option<Bookmark>>,
48}
49
50impl MysqlCdcSource {
51 pub async fn new(config: MysqlCdcSourceConfig) -> Result<Self, FaucetError> {
56 config.validate()?;
57
58 let opts = build_opts(&config)?;
59 let key = state_key(config.server_id);
60
61 let mut conn = Conn::new(opts.clone())
63 .await
64 .map_err(|e| FaucetError::Source(format!("mysql-cdc: cannot connect: {e}")))?;
65
66 run_preflight(&mut conn, &config).await?;
67 drop(conn);
68
69 Ok(Self {
70 config,
71 opts,
72 state_key_value: key,
73 pending_bookmark: Mutex::new(None),
74 })
75 }
76}
77
78#[async_trait]
83impl Source for MysqlCdcSource {
84 async fn fetch_with_context(
88 &self,
89 ctx: &HashMap<String, Value>,
90 ) -> Result<Vec<Value>, FaucetError> {
91 use futures::StreamExt;
92 let mut pages = self.stream_pages_impl(ctx, 0);
93 let mut all = Vec::new();
94 while let Some(page) = pages.next().await {
95 all.extend(page?.records);
96 }
97 Ok(all)
98 }
99
100 fn stream_pages<'a>(
105 &'a self,
106 ctx: &'a HashMap<String, Value>,
107 _batch_size: usize,
108 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
109 self.stream_pages_impl(ctx, self.config.batch_size)
110 }
111
112 fn config_schema(&self) -> Value {
113 serde_json::to_value(schemars::schema_for!(MysqlCdcSourceConfig)).unwrap_or(Value::Null)
114 }
115
116 fn state_key(&self) -> Option<String> {
117 Some(self.state_key_value.clone())
118 }
119
120 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
121 let b = Bookmark::from_value(bookmark)?;
122 *self.pending_bookmark.lock().await = Some(b);
123 Ok(())
124 }
125
126 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
127 let mut conn = Conn::new(self.opts.clone()).await.map_err(|e| {
128 FaucetError::Source(format!("mysql-cdc: capture_position connect: {e}"))
129 })?;
130 let (file, pos) = current_binlog_position(&mut conn).await?;
131 drop(conn);
132 Ok(Some(Bookmark::FilePos { file, pos }.to_value()?))
133 }
134
135 fn supports_exactly_once(&self) -> bool {
136 true
140 }
141
142 fn connector_name(&self) -> &'static str {
143 "mysql-cdc"
144 }
145
146 fn dataset_uri(&self) -> String {
147 let base = faucet_core::redact_uri_credentials(&self.config.connection_url);
148 if self.config.include_tables.is_empty() {
149 base
150 } else {
151 format!("{base}?tables={}", self.config.include_tables.join(","))
152 }
153 }
154
155 async fn check(
161 &self,
162 ctx: &faucet_core::check::CheckContext,
163 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
164 use faucet_core::check::{CheckReport, Probe};
165 let start = std::time::Instant::now();
166
167 let probe_result = tokio::time::timeout(ctx.timeout, async {
168 let mut conn = Conn::new(self.opts.clone()).await.map_err(|e| {
169 Probe::fail_hint(
170 "connection",
171 start.elapsed(),
172 format!("could not connect: {e}"),
173 "verify the host is reachable and credentials are valid",
174 )
175 })?;
176
177 let connection = Probe::pass("connection", start.elapsed());
178
179 let binlog_config = match run_preflight_probes(&mut conn, &self.config).await {
180 Ok(_summary) => Probe::pass("binlog-config", start.elapsed()),
181 Err(msg) => Probe::fail_hint(
182 "binlog-config",
183 start.elapsed(),
184 msg,
185 "Set binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL \
186 and grant REPLICATION SLAVE + REPLICATION CLIENT",
187 ),
188 };
189
190 Ok::<(Probe, Probe), Probe>((connection, binlog_config))
191 })
192 .await;
193
194 match probe_result {
195 Ok(Ok((conn_probe, cfg_probe))) => Ok(CheckReport {
196 probes: vec![conn_probe, cfg_probe],
197 }),
198 Ok(Err(probe)) => Ok(CheckReport::single(probe)),
199 Err(_elapsed) => Ok(CheckReport::single(Probe::fail_hint(
200 "connection",
201 start.elapsed(),
202 "connection timed out",
203 "the database did not respond within the check timeout",
204 ))),
205 }
206 }
207}
208
209impl MysqlCdcSource {
214 fn stream_pages_impl<'a>(
215 &'a self,
216 _ctx: &'a HashMap<String, Value>,
217 batch_size: usize,
218 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
219 let idle_timeout = self.config.idle_timeout;
220 let per_transaction = batch_size != 0;
221
222 Box::pin(async_stream::try_stream! {
223 use futures::StreamExt;
224
225 let pending = self.pending_bookmark.lock().await.take();
227 let resolved = resolve_start(&self.config.start_position, pending.as_ref());
228
229 let mut conn = Conn::new(self.opts.clone())
231 .await
232 .map_err(|e| FaucetError::Source(format!("mysql-cdc: connect failed: {e}")))?;
233
234 let resolved = resolve_current(resolved, &mut conn).await?;
238 let req = build_request(self.config.server_id, &resolved)?;
239
240 let binlog_stream = conn
242 .get_binlog_stream(req)
243 .await
244 .map_err(|e| FaucetError::Source(format!("mysql-cdc: get_binlog_stream: {e}")))?;
245 let mut stream = std::pin::pin!(binlog_stream);
246
247 let mut current_file = match &resolved {
249 ResolvedStart::FilePos { file, .. } => file.clone(),
250 _ => String::new(),
251 };
252 let mut buffer: Vec<Value> = Vec::new();
253 let mut in_txn = false;
254 let mut txid: u64 = 0;
255 let mut last_commit_bookmark: Option<Bookmark> = None;
257 let mut agg_records: Vec<Value> = Vec::new();
259
260 macro_rules! commit_buffer {
278 ($bm:expr) => {{
279 let bm: Bookmark = $bm;
280 if per_transaction {
281 yield StreamPage {
283 records: std::mem::take(&mut buffer),
284 bookmark: Some(bm.to_value()?),
285 };
286 } else if !buffer.is_empty() {
287 last_commit_bookmark = Some(bm);
292 agg_records.extend(std::mem::take(&mut buffer));
293 }
294 }};
295 }
296
297 loop {
299 match tokio::time::timeout(idle_timeout, stream.next()).await {
300 Ok(Some(Ok(event))) => {
301 let header = event.header();
302 let ts_ms = u64::from(header.timestamp()) * 1_000;
303 let log_pos = u64::from(header.log_pos());
304
305 let event_data = event
306 .read_data()
307 .map_err(|e| FaucetError::Source(format!(
308 "mysql-cdc: read_data failed: {e}"
309 )))?;
310
311 match event_data {
312 Some(EventData::RotateEvent(re)) => {
313 current_file = re.name().into_owned();
314 }
315 Some(EventData::GtidEvent(_g)) => {
316 if !buffer.is_empty() {
325 let bm = Bookmark::FilePos {
326 file: current_file.clone(),
327 pos: log_pos,
328 };
329 commit_buffer!(bm);
330 txid = txid.wrapping_add(1);
331 }
332 in_txn = true;
333 }
334 Some(EventData::QueryEvent(qe)) => {
335 let q = qe.query();
336 let q_upper = q.trim().to_ascii_uppercase();
337 if q_upper == "BEGIN" {
338 if !buffer.is_empty() {
340 let bm = Bookmark::FilePos {
341 file: current_file.clone(),
342 pos: log_pos,
343 };
344 commit_buffer!(bm);
345 txid = txid.wrapping_add(1);
346 }
347 in_txn = true;
348 } else if q_upper == "COMMIT" {
349 let bm = Bookmark::FilePos {
353 file: current_file.clone(),
354 pos: log_pos,
355 };
356 commit_buffer!(bm);
357 in_txn = false;
358 txid = txid.wrapping_add(1);
359 } else {
360 if should_flush_buffer_before_ddl(buffer.is_empty()) {
372 let bm = Bookmark::FilePos {
373 file: current_file.clone(),
374 pos: log_pos,
375 };
376 commit_buffer!(bm);
377 txid = txid.wrapping_add(1);
378 }
379
380 if self.config.emit_schema_changes {
381 let envelope = build_ddl_envelope(
382 q.as_ref(),
383 ts_ms,
384 ¤t_file,
385 log_pos,
386 );
387 let bm = Bookmark::FilePos {
388 file: current_file.clone(),
389 pos: log_pos,
390 };
391 if per_transaction {
392 yield StreamPage {
393 records: vec![envelope],
394 bookmark: Some(bm.to_value()?),
395 };
396 } else {
397 last_commit_bookmark = Some(bm);
398 agg_records.push(envelope);
399 }
400 }
401 in_txn = false;
402 }
403 }
404 Some(EventData::RowsEvent(re)) => {
405 let table_id = re.table_id();
406 let tme = stream
407 .get_tme(table_id)
408 .ok_or_else(|| FaucetError::Source(format!(
409 "mysql-cdc: missing TableMapEvent for table_id={table_id}"
410 )))?;
411
412 let db = tme.database_name().into_owned();
413 let table = tme.table_name().into_owned();
414
415 if !self.config.table_included(&db, &table) {
416 continue;
418 }
419
420 let op = op_from_rows_event(&re);
421 let lsn = json!({ "file": ¤t_file, "pos": log_pos });
422
423 for row_result in re.rows(tme) {
424 let (before_row, after_row) = row_result.map_err(|e| {
425 FaucetError::Source(format!(
426 "mysql-cdc: row decode error: {e}"
427 ))
428 })?;
429
430 let before_json = if self.config.include_columns {
431 match &before_row {
432 Some(r) => binlog_row_to_json(r)?,
433 None => Value::Null,
434 }
435 } else {
436 Value::Null
437 };
438 let after_json = match &after_row {
439 Some(r) => binlog_row_to_json(r)?,
440 None => Value::Null,
441 };
442
443 let envelope = build_envelope(
444 op, ts_ms, &db, &table,
445 before_json, after_json,
446 lsn.clone(), txid,
447 );
448
449 if let Some(max) = self.config.max_staged_records
451 && buffer.len() >= max
452 {
453 Err(FaucetError::Source(format!(
454 "mysql-cdc: in-progress transaction exceeded \
455 max_staged_records ({max}); aborting to avoid \
456 unbounded memory growth. Raise \
457 max_staged_records or split the source transaction."
458 )))?;
459 }
460 buffer.push(envelope);
461 }
462 }
463 Some(EventData::XidEvent(_xid)) => {
464 let bm = Bookmark::FilePos {
466 file: current_file.clone(),
467 pos: log_pos,
468 };
469 commit_buffer!(bm);
470 in_txn = false;
471 txid = txid.wrapping_add(1);
472 }
473 _ => {
474 }
476 }
477 }
478 Ok(Some(Err(e))) => {
479 Err(FaucetError::Source(format!("mysql-cdc: stream error: {e}")))?;
480 }
481 Ok(None) | Err(_) => {
483 let _ = in_txn;
486
487 if !per_transaction
494 && let Some(bm) = last_commit_bookmark.take()
495 && !agg_records.is_empty()
496 {
497 yield StreamPage {
498 records: std::mem::take(&mut agg_records),
499 bookmark: Some(bm.to_value()?),
500 };
501 }
502 break;
503 }
504 }
505 }
506
507 tracing::info!(
508 connector = "mysql-cdc",
509 server_id = self.config.server_id,
510 "binlog fetch cycle complete",
511 );
512 })
513 }
514}
515
516#[derive(Debug, Clone, PartialEq, Eq)]
522pub(crate) enum ResolvedStart {
523 Current { file: String, pos: u64 },
525 Earliest,
527 FilePos { file: String, pos: u64 },
529 GtidSet { value: String },
532}
533
534pub(crate) fn resolve_start(
548 start_position: &StartPosition,
549 pending: Option<&Bookmark>,
550) -> ResolvedStart {
551 if let Some(bm) = pending {
552 return match bm {
554 Bookmark::FilePos { file, pos } => ResolvedStart::FilePos {
555 file: file.clone(),
556 pos: *pos,
557 },
558 Bookmark::GtidSet { gtid_set } => ResolvedStart::GtidSet {
561 value: gtid_set.clone(),
562 },
563 };
564 }
565
566 match start_position {
568 StartPosition::Current => {
569 ResolvedStart::Current {
571 file: String::new(),
572 pos: 0,
573 }
574 }
575 StartPosition::Earliest => ResolvedStart::Earliest,
576 StartPosition::FilePos { file, pos } => ResolvedStart::FilePos {
577 file: file.clone(),
578 pos: *pos,
579 },
580 StartPosition::GtidSet { value } => ResolvedStart::GtidSet {
581 value: value.clone(),
582 },
583 }
584}
585
586async fn resolve_current(
594 resolved: ResolvedStart,
595 conn: &mut Conn,
596) -> Result<ResolvedStart, FaucetError> {
597 if !matches!(resolved, ResolvedStart::Current { .. }) {
598 return Ok(resolved);
599 }
600 let (file, pos) = current_binlog_position(conn).await?;
601 Ok(ResolvedStart::FilePos { file, pos })
602}
603
604async fn current_binlog_position(conn: &mut Conn) -> Result<(String, u64), FaucetError> {
615 let (row, stmt): (Option<Row>, &str) = match conn.query_first("SHOW BINARY LOG STATUS").await {
616 Ok(row) => (row, "SHOW BINARY LOG STATUS"),
617 Err(new_err) => match conn.query_first("SHOW MASTER STATUS").await {
619 Ok(row) => (row, "SHOW MASTER STATUS"),
620 Err(old_err) => return Err(binlog_position_error(new_err, old_err)),
621 },
622 };
623 let extracted = row.map(|r| (r.get::<String, _>(0), r.get::<u64, _>(1)));
627 finalize_binlog_position(extracted, stmt)
628}
629
630fn binlog_position_error(
634 new_err: impl std::fmt::Display,
635 old_err: impl std::fmt::Display,
636) -> FaucetError {
637 FaucetError::Source(format!(
638 "mysql-cdc: reading current binlog position failed — \
639 `SHOW BINARY LOG STATUS`: {new_err}; `SHOW MASTER STATUS`: {old_err}"
640 ))
641}
642
643fn finalize_binlog_position(
649 extracted: Option<(Option<String>, Option<u64>)>,
650 stmt: &str,
651) -> Result<(String, u64), FaucetError> {
652 let (file, pos) = extracted.ok_or_else(|| {
653 FaucetError::Source(format!(
654 "mysql-cdc: {stmt} returned no rows; is binary logging enabled?"
655 ))
656 })?;
657 let file =
658 file.ok_or_else(|| FaucetError::Source(format!("mysql-cdc: {stmt}: missing File column")))?;
659 let pos = pos.ok_or_else(|| {
660 FaucetError::Source(format!("mysql-cdc: {stmt}: missing Position column"))
661 })?;
662 Ok((file, pos))
663}
664
665fn build_request<'r>(
672 server_id: u32,
673 resolved: &'r ResolvedStart,
674) -> Result<BinlogStreamRequest<'r>, FaucetError> {
675 use mysql_async::Sid;
676 use std::str::FromStr;
677
678 match resolved {
679 ResolvedStart::Current { .. } => Ok(BinlogStreamRequest::new(server_id)),
682 ResolvedStart::Earliest => {
683 Ok(BinlogStreamRequest::new(server_id))
685 }
686 ResolvedStart::FilePos { file, pos } => {
687 Ok(BinlogStreamRequest::new(server_id)
690 .with_filename(file.as_bytes())
691 .with_pos(*pos))
692 }
693 ResolvedStart::GtidSet { value } => {
694 let sids: Vec<Sid<'static>> = value
699 .split(',')
700 .map(|part| {
701 let trimmed = part.trim();
702 Sid::from_str(trimmed).map_err(|e| {
703 FaucetError::Source(format!("mysql-cdc: invalid GTID set '{trimmed}': {e}"))
704 })
705 })
706 .collect::<Result<Vec<_>, _>>()?;
707
708 Ok(BinlogStreamRequest::new(server_id)
709 .with_gtid()
710 .with_gtid_set(sids))
711 }
712 }
713}
714
715pub(crate) fn should_flush_buffer_before_ddl(buffer_is_empty: bool) -> bool {
728 !buffer_is_empty
729}
730
731#[cfg(test)]
737#[derive(Debug, Clone, PartialEq, Eq)]
738pub(crate) struct PlannedPage {
739 pub record_count: usize,
741 pub is_ddl: bool,
743 pub bookmark_pos: Option<u64>,
744}
745
746#[cfg(test)]
755pub(crate) fn plan_ddl_pages(
756 buffered_rows: usize,
757 emit_schema_changes: bool,
758 log_pos: u64,
759) -> Vec<PlannedPage> {
760 let mut pages = Vec::new();
761 if should_flush_buffer_before_ddl(buffered_rows == 0) {
763 pages.push(PlannedPage {
764 record_count: buffered_rows,
765 is_ddl: false,
766 bookmark_pos: Some(log_pos),
767 });
768 }
769 if emit_schema_changes {
770 pages.push(PlannedPage {
771 record_count: 1,
772 is_ddl: true,
773 bookmark_pos: Some(log_pos),
774 });
775 }
776 pages
777}
778
779pub(crate) fn op_from_rows_event(re: &RowsEventData<'_>) -> &'static str {
781 match re {
782 RowsEventData::WriteRowsEvent(_) | RowsEventData::WriteRowsEventV1(_) => "c",
783 RowsEventData::UpdateRowsEvent(_)
784 | RowsEventData::UpdateRowsEventV1(_)
785 | RowsEventData::PartialUpdateRowsEvent(_) => "u",
786 RowsEventData::DeleteRowsEvent(_) | RowsEventData::DeleteRowsEventV1(_) => "d",
787 }
788}
789
790#[allow(clippy::too_many_arguments)]
798pub(crate) fn build_envelope(
799 op: &str,
800 ts_ms: u64,
801 schema: &str,
802 table: &str,
803 before: Value,
804 after: Value,
805 lsn: Value,
806 txid: u64,
807) -> Value {
808 let mut obj = Map::new();
809 obj.insert("op".into(), json!(op));
810 obj.insert("ts_ms".into(), json!(ts_ms));
811 obj.insert("schema".into(), json!(schema));
812 obj.insert("table".into(), json!(table));
813 obj.insert("before".into(), before);
814 obj.insert("after".into(), after);
815 obj.insert("lsn".into(), lsn);
816 obj.insert("txid".into(), json!(txid));
817 Value::Object(obj)
818}
819
820fn build_ddl_envelope(statement: &str, ts_ms: u64, file: &str, pos: u64) -> Value {
822 json!({
823 "op": "ddl",
824 "ts_ms": ts_ms,
825 "statement": statement,
826 "lsn": { "file": file, "pos": pos },
827 })
828}
829
830fn build_opts(config: &MysqlCdcSourceConfig) -> Result<Opts, FaucetError> {
835 let base = Opts::from_url(&config.connection_url)
836 .map_err(|e| FaucetError::Config(format!("mysql-cdc: invalid connection URL: {e}")))?;
837
838 let ssl = match &config.tls {
839 CdcTls::Disable => return Ok(base),
840 CdcTls::Require => SslOpts::default()
841 .with_danger_accept_invalid_certs(true)
842 .with_danger_skip_domain_validation(true),
843 CdcTls::VerifyCa { ca_path } => {
844 let mut s = SslOpts::default().with_danger_skip_domain_validation(true);
845 if let Some(p) = ca_path {
846 s = s.with_root_certs(vec![PathBuf::from(p).into()]);
847 }
848 s
849 }
850 CdcTls::VerifyFull { ca_path } => {
851 let mut s = SslOpts::default();
852 if let Some(p) = ca_path {
853 s = s.with_root_certs(vec![PathBuf::from(p).into()]);
854 }
855 s
856 }
857 };
858
859 Ok(OptsBuilder::from_opts(base).ssl_opts(ssl).into())
860}
861
862fn binlog_row_value_options_ok(value: &str) -> bool {
874 value.trim().is_empty()
875}
876
877async fn run_preflight_probes(
880 conn: &mut Conn,
881 config: &MysqlCdcSourceConfig,
882) -> Result<String, String> {
883 let fmt: Option<(String, String)> = conn
885 .query_first("SHOW VARIABLES LIKE 'binlog_format'")
886 .await
887 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_format' failed: {e}"))?;
888 match fmt.as_ref() {
889 Some((_, v)) if !v.eq_ignore_ascii_case("ROW") => {
890 return Err(format!(
891 "binlog_format is '{v}', must be ROW. \
892 Set binlog_format=ROW in your MySQL config."
893 ));
894 }
895 None => {
896 return Err("binlog_format variable not found. Is binary logging enabled?".into());
897 }
898 _ => {}
899 }
900
901 let img: Option<(String, String)> = conn
903 .query_first("SHOW VARIABLES LIKE 'binlog_row_image'")
904 .await
905 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_image' failed: {e}"))?;
906 match img.as_ref() {
907 Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
908 return Err(format!(
909 "binlog_row_image is '{v}', must be FULL. \
910 Set binlog_row_image=FULL in your MySQL config."
911 ));
912 }
913 None => {
914 return Err("binlog_row_image variable not found.".into());
915 }
916 _ => {}
917 }
918
919 let meta: Option<(String, String)> = conn
921 .query_first("SHOW VARIABLES LIKE 'binlog_row_metadata'")
922 .await
923 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_metadata' failed: {e}"))?;
924 match meta.as_ref() {
925 Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
926 return Err(format!(
927 "binlog_row_metadata is '{v}', must be FULL. \
928 Set binlog_row_metadata=FULL in your MySQL config."
929 ));
930 }
931 None => {
932 return Err("binlog_row_metadata variable not found.".into());
933 }
934 _ => {}
935 }
936
937 let value_opts: Option<(String, String)> = conn
942 .query_first("SHOW VARIABLES LIKE 'binlog_row_value_options'")
943 .await
944 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_value_options' failed: {e}"))?;
945 if let Some((_, v)) = value_opts.as_ref()
947 && !binlog_row_value_options_ok(v)
948 {
949 return Err(format!(
950 "binlog_row_value_options is '{v}', must be empty (full JSON). \
951 Partial JSON diffs cannot be reconstructed for CDC. \
952 Set binlog_row_value_options='' on the MySQL server."
953 ));
954 }
955
956 let grants: Vec<String> = conn
958 .query("SHOW GRANTS FOR CURRENT_USER()")
959 .await
960 .map_err(|e| format!("SHOW GRANTS failed: {e}"))?;
961 let grants_combined = grants.join(" ").to_uppercase();
962 let has_replication = grants_combined.contains("ALL PRIVILEGES")
963 || (grants_combined.contains("REPLICATION SLAVE")
964 && grants_combined.contains("REPLICATION CLIENT"));
965 if !has_replication {
966 return Err(
967 "user lacks REPLICATION SLAVE and/or REPLICATION CLIENT privileges. \
968 Grant them with: GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host';"
969 .into(),
970 );
971 }
972
973 if matches!(config.start_position, StartPosition::GtidSet { .. }) {
977 let gtid: Option<(String, String)> = conn
978 .query_first("SHOW VARIABLES LIKE 'gtid_mode'")
979 .await
980 .map_err(|e| format!("SHOW VARIABLES LIKE 'gtid_mode' failed: {e}"))?;
981 match gtid.as_ref() {
982 Some((_, v)) if !v.eq_ignore_ascii_case("ON") => {
983 return Err(format!(
984 "start_position is GtidSet but gtid_mode is '{v}' (must be ON). \
985 Enable GTID mode: --gtid-mode=ON --enforce-gtid-consistency=ON"
986 ));
987 }
988 None => {
989 return Err("gtid_mode variable not found".into());
990 }
991 _ => {}
992 }
993 }
994
995 Ok(
996 "binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL, \
997 binlog_row_value_options=full, grants OK"
998 .into(),
999 )
1000}
1001
1002async fn run_preflight(conn: &mut Conn, config: &MysqlCdcSourceConfig) -> Result<(), FaucetError> {
1004 run_preflight_probes(conn, config)
1005 .await
1006 .map(|_| ())
1007 .map_err(|m| FaucetError::Source(format!("mysql-cdc: {m}")))
1008}
1009
1010#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use crate::state::Bookmark;
1018 use serde_json::json;
1019
1020 #[test]
1023 fn binlog_row_value_options_empty_is_ok() {
1024 assert!(binlog_row_value_options_ok(""));
1025 assert!(binlog_row_value_options_ok(" "));
1026 }
1027
1028 #[test]
1029 fn binlog_row_value_options_partial_json_rejected() {
1030 assert!(!binlog_row_value_options_ok("PARTIAL_JSON"));
1031 assert!(!binlog_row_value_options_ok("partial_json"));
1032 assert!(!binlog_row_value_options_ok(" PARTIAL_JSON "));
1033 }
1034
1035 #[test]
1038 fn finalize_binlog_position_returns_file_and_pos() {
1039 let got = finalize_binlog_position(Some((Some("mysql-bin.000007".into()), Some(154))), "S")
1040 .expect("valid row decodes");
1041 assert_eq!(got, ("mysql-bin.000007".to_string(), 154));
1042 }
1043
1044 #[test]
1045 fn finalize_binlog_position_no_rows_means_binlogging_disabled() {
1046 let err = finalize_binlog_position(None, "SHOW BINARY LOG STATUS")
1047 .expect_err("no rows must error");
1048 let msg = err.to_string();
1049 assert!(msg.contains("returned no rows"), "{msg}");
1050 assert!(msg.contains("SHOW BINARY LOG STATUS"), "{msg}");
1051 }
1052
1053 #[test]
1054 fn finalize_binlog_position_missing_file_column() {
1055 let err = finalize_binlog_position(Some((None, Some(4))), "SHOW MASTER STATUS")
1056 .expect_err("missing File must error");
1057 assert!(err.to_string().contains("missing File column"), "{err}");
1058 }
1059
1060 #[test]
1061 fn finalize_binlog_position_missing_position_column() {
1062 let err = finalize_binlog_position(
1063 Some((Some("mysql-bin.1".into()), None)),
1064 "SHOW MASTER STATUS",
1065 )
1066 .expect_err("missing Position must error");
1067 assert!(err.to_string().contains("missing Position column"), "{err}");
1068 }
1069
1070 #[test]
1071 fn binlog_position_error_names_both_statements() {
1072 let msg = binlog_position_error("parse error NEW", "parse error OLD").to_string();
1073 assert!(
1074 msg.contains("SHOW BINARY LOG STATUS") && msg.contains("parse error NEW"),
1075 "{msg}"
1076 );
1077 assert!(
1078 msg.contains("SHOW MASTER STATUS") && msg.contains("parse error OLD"),
1079 "{msg}"
1080 );
1081 }
1082
1083 #[test]
1086 fn file_pos_bookmark_overrides_current() {
1087 let bm = Bookmark::FilePos {
1088 file: "binlog.000003".into(),
1089 pos: 4567,
1090 };
1091 let resolved = resolve_start(&StartPosition::Current, Some(&bm));
1092 assert_eq!(
1093 resolved,
1094 ResolvedStart::FilePos {
1095 file: "binlog.000003".into(),
1096 pos: 4567
1097 }
1098 );
1099 }
1100
1101 #[test]
1102 fn file_pos_bookmark_overrides_gtid_config() {
1103 let bm = Bookmark::FilePos {
1104 file: "binlog.000010".into(),
1105 pos: 123,
1106 };
1107 let resolved = resolve_start(
1108 &StartPosition::GtidSet {
1109 value: "abc:1-100".into(),
1110 },
1111 Some(&bm),
1112 );
1113 assert_eq!(
1114 resolved,
1115 ResolvedStart::FilePos {
1116 file: "binlog.000010".into(),
1117 pos: 123
1118 }
1119 );
1120 }
1121
1122 #[test]
1123 fn gtid_bookmark_overrides_current() {
1124 let bm = Bookmark::GtidSet {
1125 gtid_set: "abc:1-100".into(),
1126 };
1127 let resolved = resolve_start(&StartPosition::Current, Some(&bm));
1128 assert_eq!(
1129 resolved,
1130 ResolvedStart::GtidSet {
1131 value: "abc:1-100".into()
1132 }
1133 );
1134 }
1135
1136 #[test]
1137 fn no_bookmark_current_yields_current() {
1138 let resolved = resolve_start(&StartPosition::Current, None);
1139 assert!(matches!(resolved, ResolvedStart::Current { .. }));
1140 }
1141
1142 #[test]
1143 fn no_bookmark_earliest_yields_earliest() {
1144 let resolved = resolve_start(&StartPosition::Earliest, None);
1145 assert_eq!(resolved, ResolvedStart::Earliest);
1146 }
1147
1148 #[test]
1149 fn no_bookmark_file_pos_config_passes_through() {
1150 let resolved = resolve_start(
1151 &StartPosition::FilePos {
1152 file: "binlog.000001".into(),
1153 pos: 4,
1154 },
1155 None,
1156 );
1157 assert_eq!(
1158 resolved,
1159 ResolvedStart::FilePos {
1160 file: "binlog.000001".into(),
1161 pos: 4
1162 }
1163 );
1164 }
1165
1166 #[test]
1178 fn envelope_shape_insert() {
1179 let lsn = json!({ "file": "binlog.000001", "pos": 4567_u64 });
1180 let after = json!({ "id": 1, "name": "alice" });
1181 let env = build_envelope(
1182 "c",
1183 1_000,
1184 "mydb",
1185 "users",
1186 Value::Null,
1187 after.clone(),
1188 lsn.clone(),
1189 0,
1190 );
1191
1192 assert_eq!(env["op"], "c");
1193 assert_eq!(env["ts_ms"], 1_000_u64);
1194 assert_eq!(env["schema"], "mydb");
1195 assert_eq!(env["table"], "users");
1196 assert_eq!(env["before"], Value::Null);
1197 assert_eq!(env["after"], after);
1198 assert_eq!(env["lsn"], lsn);
1199 assert_eq!(env["txid"], 0_u64);
1200 }
1201
1202 #[test]
1203 fn envelope_shape_update() {
1204 let before = json!({ "id": 1, "name": "alice" });
1205 let after = json!({ "id": 1, "name": "bob" });
1206 let lsn = json!({ "file": "binlog.000002", "pos": 9999_u64 });
1207 let env = build_envelope(
1208 "u",
1209 2_000,
1210 "db",
1211 "tbl",
1212 before.clone(),
1213 after.clone(),
1214 lsn,
1215 3,
1216 );
1217
1218 assert_eq!(env["op"], "u");
1219 assert_eq!(env["before"], before);
1220 assert_eq!(env["after"], after);
1221 assert_eq!(env["txid"], 3_u64);
1222 }
1223
1224 #[test]
1225 fn envelope_shape_delete() {
1226 let before = json!({ "id": 42 });
1227 let lsn = json!({ "file": "binlog.000003", "pos": 100_u64 });
1228 let env = build_envelope("d", 3_000, "db", "tbl", before.clone(), Value::Null, lsn, 7);
1229
1230 assert_eq!(env["op"], "d");
1231 assert_eq!(env["before"], before);
1232 assert_eq!(env["after"], Value::Null);
1233 }
1234
1235 #[test]
1236 fn envelope_has_all_expected_keys() {
1237 let env = build_envelope(
1238 "c",
1239 0,
1240 "s",
1241 "t",
1242 Value::Null,
1243 Value::Null,
1244 json!({ "file": "f", "pos": 0_u64 }),
1245 0,
1246 );
1247 let obj = env.as_object().unwrap();
1248 for key in &[
1249 "op", "ts_ms", "schema", "table", "before", "after", "lsn", "txid",
1250 ] {
1251 assert!(obj.contains_key(*key), "missing key: {key}");
1252 }
1253 }
1254
1255 #[test]
1258 fn build_opts_disable_succeeds() {
1259 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1260 "connection_url": "mysql://repl:pass@localhost:3306/db",
1261 "server_id": 1001
1262 }))
1263 .unwrap();
1264 assert!(build_opts(&config).is_ok());
1265 }
1266
1267 #[test]
1268 fn build_opts_require_tls_succeeds() {
1269 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1270 "connection_url": "mysql://repl:pass@localhost:3306/db",
1271 "server_id": 1002,
1272 "tls": { "mode": "require" }
1273 }))
1274 .unwrap();
1275 assert!(build_opts(&config).is_ok());
1276 }
1277
1278 #[test]
1279 fn build_opts_verify_ca_no_path() {
1280 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1281 "connection_url": "mysql://repl:pass@localhost:3306/db",
1282 "server_id": 1003,
1283 "tls": { "mode": "verify_ca" }
1284 }))
1285 .unwrap();
1286 assert!(build_opts(&config).is_ok());
1287 }
1288
1289 #[test]
1290 fn build_opts_invalid_url_errors() {
1291 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1292 "connection_url": "not-a-valid-url",
1293 "server_id": 1
1294 }))
1295 .unwrap();
1296 assert!(build_opts(&config).is_err());
1297 }
1298
1299 #[test]
1302 fn dataset_uri_strips_credentials_no_tables() {
1303 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1304 "connection_url": "mysql://repl:pass@h:3306/db",
1305 "server_id": 1
1306 }))
1307 .unwrap();
1308 let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
1309 assert_eq!(redacted, "mysql://h:3306/db");
1310 let uri = if config.include_tables.is_empty() {
1312 redacted
1313 } else {
1314 format!("{redacted}?tables={}", config.include_tables.join(","))
1315 };
1316 assert_eq!(uri, "mysql://h:3306/db");
1317 }
1318
1319 #[test]
1320 fn dataset_uri_appends_tables_when_present() {
1321 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1322 "connection_url": "mysql://repl:pass@h:3306/db",
1323 "server_id": 1,
1324 "include_tables": ["db.orders", "db.users"]
1325 }))
1326 .unwrap();
1327 let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
1328 let uri = if config.include_tables.is_empty() {
1329 redacted
1330 } else {
1331 format!("{redacted}?tables={}", config.include_tables.join(","))
1332 };
1333 assert_eq!(uri, "mysql://h:3306/db?tables=db.orders,db.users");
1334 }
1335
1336 #[test]
1345 fn build_request_current_arm_succeeds() {
1346 let resolved = ResolvedStart::Current {
1350 file: String::new(),
1351 pos: 0,
1352 };
1353 assert!(build_request(42, &resolved).is_ok());
1354 }
1355
1356 #[test]
1357 fn build_request_earliest_arm_succeeds() {
1358 let resolved = ResolvedStart::Earliest;
1359 assert!(build_request(7, &resolved).is_ok());
1360 }
1361
1362 #[test]
1363 fn build_request_file_pos_arm_succeeds() {
1364 let resolved = ResolvedStart::FilePos {
1365 file: "binlog.000007".into(),
1366 pos: 8192,
1367 };
1368 assert!(build_request(1001, &resolved).is_ok());
1369 }
1370
1371 #[test]
1372 fn build_request_valid_gtid_set_succeeds() {
1373 let resolved = ResolvedStart::GtidSet {
1376 value: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5, \
1377 8a1d3a7c-71ca-11e1-9e33-c80aa9429562:1-10"
1378 .into(),
1379 };
1380 assert!(build_request(1001, &resolved).is_ok());
1381 }
1382
1383 #[test]
1384 fn build_request_invalid_gtid_set_errors_with_source_variant() {
1385 let resolved = ResolvedStart::GtidSet {
1388 value: "totally-not-a-gtid".into(),
1389 };
1390 match build_request(1001, &resolved) {
1393 Err(FaucetError::Source(msg)) => {
1394 assert!(
1395 msg.contains("invalid GTID set 'totally-not-a-gtid'"),
1396 "message must name the bad fragment; got: {msg}"
1397 );
1398 }
1399 Err(other) => panic!("expected FaucetError::Source, got {other:?}"),
1400 Ok(_) => panic!("invalid GTID must error"),
1401 }
1402 }
1403
1404 #[test]
1407 fn ddl_envelope_shape() {
1408 let env = build_ddl_envelope("ALTER TABLE t ADD c INT", 1_700, "binlog.000004", 512);
1409 assert_eq!(env["op"], "ddl");
1410 assert_eq!(env["ts_ms"], 1_700_u64);
1411 assert_eq!(env["statement"], "ALTER TABLE t ADD c INT");
1412 assert_eq!(
1413 env["lsn"],
1414 json!({ "file": "binlog.000004", "pos": 512_u64 })
1415 );
1416 let obj = env.as_object().unwrap();
1418 assert!(!obj.contains_key("before"));
1419 assert!(!obj.contains_key("after"));
1420 assert!(!obj.contains_key("schema"));
1421 assert!(!obj.contains_key("table"));
1422 }
1423
1424 #[test]
1427 fn should_flush_buffer_before_ddl_decision() {
1428 assert!(should_flush_buffer_before_ddl(false));
1430 assert!(!should_flush_buffer_before_ddl(true));
1431 }
1432
1433 #[test]
1434 fn ddl_with_buffered_rows_flushes_them_first() {
1435 let pages = plan_ddl_pages(3, true, 4567);
1439 assert_eq!(
1440 pages.len(),
1441 2,
1442 "expected a buffer-flush page then a DDL page"
1443 );
1444
1445 assert_eq!(pages[0].record_count, 3);
1447 assert!(
1448 !pages[0].is_ddl,
1449 "first page must be the buffered rows, not the DDL"
1450 );
1451 assert_eq!(
1452 pages[0].bookmark_pos,
1453 Some(4567),
1454 "buffered rows must advance the bookmark — no silent loss"
1455 );
1456
1457 assert!(pages[1].is_ddl);
1459 assert_eq!(pages[1].record_count, 1);
1460 assert_eq!(pages[1].bookmark_pos, Some(4567));
1461 }
1462
1463 #[test]
1464 fn ddl_with_empty_buffer_emits_only_the_ddl() {
1465 let pages = plan_ddl_pages(0, true, 100);
1467 assert_eq!(pages.len(), 1);
1468 assert!(pages[0].is_ddl);
1469 assert_eq!(pages[0].bookmark_pos, Some(100));
1470 }
1471
1472 #[test]
1473 fn ddl_with_buffered_rows_flushes_even_when_schema_changes_disabled() {
1474 let pages = plan_ddl_pages(2, false, 888);
1478 assert_eq!(pages.len(), 1, "buffered rows must still be flushed");
1479 assert!(!pages[0].is_ddl);
1480 assert_eq!(pages[0].record_count, 2);
1481 assert_eq!(pages[0].bookmark_pos, Some(888));
1482 }
1483
1484 #[test]
1485 fn ddl_with_empty_buffer_and_no_schema_changes_emits_nothing() {
1486 let pages = plan_ddl_pages(0, false, 12);
1487 assert!(
1488 pages.is_empty(),
1489 "nothing to emit: empty buffer + schema changes off"
1490 );
1491 }
1492}