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 fn supports_exactly_once(&self) -> bool {
127 true
131 }
132
133 fn connector_name(&self) -> &'static str {
134 "mysql-cdc"
135 }
136
137 fn dataset_uri(&self) -> String {
138 let base = faucet_core::redact_uri_credentials(&self.config.connection_url);
139 if self.config.include_tables.is_empty() {
140 base
141 } else {
142 format!("{base}?tables={}", self.config.include_tables.join(","))
143 }
144 }
145
146 async fn check(
152 &self,
153 ctx: &faucet_core::check::CheckContext,
154 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
155 use faucet_core::check::{CheckReport, Probe};
156 let start = std::time::Instant::now();
157
158 let probe_result = tokio::time::timeout(ctx.timeout, async {
159 let mut conn = Conn::new(self.opts.clone()).await.map_err(|e| {
160 Probe::fail_hint(
161 "connection",
162 start.elapsed(),
163 format!("could not connect: {e}"),
164 "verify the host is reachable and credentials are valid",
165 )
166 })?;
167
168 let connection = Probe::pass("connection", start.elapsed());
169
170 let binlog_config = match run_preflight_probes(&mut conn, &self.config).await {
171 Ok(_summary) => Probe::pass("binlog-config", start.elapsed()),
172 Err(msg) => Probe::fail_hint(
173 "binlog-config",
174 start.elapsed(),
175 msg,
176 "Set binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL \
177 and grant REPLICATION SLAVE + REPLICATION CLIENT",
178 ),
179 };
180
181 Ok::<(Probe, Probe), Probe>((connection, binlog_config))
182 })
183 .await;
184
185 match probe_result {
186 Ok(Ok((conn_probe, cfg_probe))) => Ok(CheckReport {
187 probes: vec![conn_probe, cfg_probe],
188 }),
189 Ok(Err(probe)) => Ok(CheckReport::single(probe)),
190 Err(_elapsed) => Ok(CheckReport::single(Probe::fail_hint(
191 "connection",
192 start.elapsed(),
193 "connection timed out",
194 "the database did not respond within the check timeout",
195 ))),
196 }
197 }
198}
199
200impl MysqlCdcSource {
205 fn stream_pages_impl<'a>(
206 &'a self,
207 _ctx: &'a HashMap<String, Value>,
208 batch_size: usize,
209 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
210 let idle_timeout = self.config.idle_timeout;
211 let per_transaction = batch_size != 0;
212
213 Box::pin(async_stream::try_stream! {
214 use futures::StreamExt;
215
216 let pending = self.pending_bookmark.lock().await.take();
218 let resolved = resolve_start(&self.config.start_position, pending.as_ref());
219
220 let mut conn = Conn::new(self.opts.clone())
222 .await
223 .map_err(|e| FaucetError::Source(format!("mysql-cdc: connect failed: {e}")))?;
224
225 let resolved = resolve_current(resolved, &mut conn).await?;
228 let req = build_request(self.config.server_id, &resolved)?;
229
230 let binlog_stream = conn
232 .get_binlog_stream(req)
233 .await
234 .map_err(|e| FaucetError::Source(format!("mysql-cdc: get_binlog_stream: {e}")))?;
235 let mut stream = std::pin::pin!(binlog_stream);
236
237 let mut current_file = match &resolved {
239 ResolvedStart::FilePos { file, .. } => file.clone(),
240 _ => String::new(),
241 };
242 let mut buffer: Vec<Value> = Vec::new();
243 let mut in_txn = false;
244 let mut txid: u64 = 0;
245 let mut last_commit_bookmark: Option<Bookmark> = None;
247 let mut agg_records: Vec<Value> = Vec::new();
249
250 macro_rules! commit_buffer {
268 ($bm:expr) => {{
269 let bm: Bookmark = $bm;
270 if per_transaction {
271 yield StreamPage {
273 records: std::mem::take(&mut buffer),
274 bookmark: Some(bm.to_value()?),
275 };
276 } else if !buffer.is_empty() {
277 last_commit_bookmark = Some(bm);
282 agg_records.extend(std::mem::take(&mut buffer));
283 }
284 }};
285 }
286
287 loop {
289 match tokio::time::timeout(idle_timeout, stream.next()).await {
290 Ok(Some(Ok(event))) => {
291 let header = event.header();
292 let ts_ms = u64::from(header.timestamp()) * 1_000;
293 let log_pos = u64::from(header.log_pos());
294
295 let event_data = event
296 .read_data()
297 .map_err(|e| FaucetError::Source(format!(
298 "mysql-cdc: read_data failed: {e}"
299 )))?;
300
301 match event_data {
302 Some(EventData::RotateEvent(re)) => {
303 current_file = re.name().into_owned();
304 }
305 Some(EventData::GtidEvent(_g)) => {
306 if !buffer.is_empty() {
315 let bm = Bookmark::FilePos {
316 file: current_file.clone(),
317 pos: log_pos,
318 };
319 commit_buffer!(bm);
320 txid = txid.wrapping_add(1);
321 }
322 in_txn = true;
323 }
324 Some(EventData::QueryEvent(qe)) => {
325 let q = qe.query();
326 let q_upper = q.trim().to_ascii_uppercase();
327 if q_upper == "BEGIN" {
328 if !buffer.is_empty() {
330 let bm = Bookmark::FilePos {
331 file: current_file.clone(),
332 pos: log_pos,
333 };
334 commit_buffer!(bm);
335 txid = txid.wrapping_add(1);
336 }
337 in_txn = true;
338 } else if q_upper == "COMMIT" {
339 let bm = Bookmark::FilePos {
343 file: current_file.clone(),
344 pos: log_pos,
345 };
346 commit_buffer!(bm);
347 in_txn = false;
348 txid = txid.wrapping_add(1);
349 } else {
350 if self.config.emit_schema_changes {
352 let envelope = build_ddl_envelope(
353 q.as_ref(),
354 ts_ms,
355 ¤t_file,
356 log_pos,
357 );
358 let bm = Bookmark::FilePos {
359 file: current_file.clone(),
360 pos: log_pos,
361 };
362 if per_transaction {
363 yield StreamPage {
364 records: vec![envelope],
365 bookmark: Some(bm.to_value()?),
366 };
367 } else {
368 last_commit_bookmark = Some(bm);
369 agg_records.push(envelope);
370 }
371 }
372 in_txn = false;
373 }
374 }
375 Some(EventData::RowsEvent(re)) => {
376 let table_id = re.table_id();
377 let tme = stream
378 .get_tme(table_id)
379 .ok_or_else(|| FaucetError::Source(format!(
380 "mysql-cdc: missing TableMapEvent for table_id={table_id}"
381 )))?;
382
383 let db = tme.database_name().into_owned();
384 let table = tme.table_name().into_owned();
385
386 if !self.config.table_included(&db, &table) {
387 continue;
389 }
390
391 let op = op_from_rows_event(&re);
392 let lsn = json!({ "file": ¤t_file, "pos": log_pos });
393
394 for row_result in re.rows(tme) {
395 let (before_row, after_row) = row_result.map_err(|e| {
396 FaucetError::Source(format!(
397 "mysql-cdc: row decode error: {e}"
398 ))
399 })?;
400
401 let before_json = if self.config.include_columns {
402 match &before_row {
403 Some(r) => binlog_row_to_json(r)?,
404 None => Value::Null,
405 }
406 } else {
407 Value::Null
408 };
409 let after_json = match &after_row {
410 Some(r) => binlog_row_to_json(r)?,
411 None => Value::Null,
412 };
413
414 let envelope = build_envelope(
415 op, ts_ms, &db, &table,
416 before_json, after_json,
417 lsn.clone(), txid,
418 );
419
420 if let Some(max) = self.config.max_staged_records
422 && buffer.len() >= max
423 {
424 Err(FaucetError::Source(format!(
425 "mysql-cdc: in-progress transaction exceeded \
426 max_staged_records ({max}); aborting to avoid \
427 unbounded memory growth. Raise \
428 max_staged_records or split the source transaction."
429 )))?;
430 }
431 buffer.push(envelope);
432 }
433 }
434 Some(EventData::XidEvent(_xid)) => {
435 let bm = Bookmark::FilePos {
437 file: current_file.clone(),
438 pos: log_pos,
439 };
440 commit_buffer!(bm);
441 in_txn = false;
442 txid = txid.wrapping_add(1);
443 }
444 _ => {
445 }
447 }
448 }
449 Ok(Some(Err(e))) => {
450 Err(FaucetError::Source(format!("mysql-cdc: stream error: {e}")))?;
451 }
452 Ok(None) | Err(_) => {
454 let _ = in_txn;
457
458 if !per_transaction
465 && let Some(bm) = last_commit_bookmark.take()
466 && !agg_records.is_empty()
467 {
468 yield StreamPage {
469 records: std::mem::take(&mut agg_records),
470 bookmark: Some(bm.to_value()?),
471 };
472 }
473 break;
474 }
475 }
476 }
477
478 tracing::info!(
479 connector = "mysql-cdc",
480 server_id = self.config.server_id,
481 "binlog fetch cycle complete",
482 );
483 })
484 }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
493pub(crate) enum ResolvedStart {
494 Current { file: String, pos: u64 },
496 Earliest,
498 FilePos { file: String, pos: u64 },
500 GtidSet { value: String },
503}
504
505pub(crate) fn resolve_start(
519 start_position: &StartPosition,
520 pending: Option<&Bookmark>,
521) -> ResolvedStart {
522 if let Some(bm) = pending {
523 return match bm {
525 Bookmark::FilePos { file, pos } => ResolvedStart::FilePos {
526 file: file.clone(),
527 pos: *pos,
528 },
529 Bookmark::GtidSet { gtid_set } => ResolvedStart::GtidSet {
532 value: gtid_set.clone(),
533 },
534 };
535 }
536
537 match start_position {
539 StartPosition::Current => {
540 ResolvedStart::Current {
542 file: String::new(),
543 pos: 0,
544 }
545 }
546 StartPosition::Earliest => ResolvedStart::Earliest,
547 StartPosition::FilePos { file, pos } => ResolvedStart::FilePos {
548 file: file.clone(),
549 pos: *pos,
550 },
551 StartPosition::GtidSet { value } => ResolvedStart::GtidSet {
552 value: value.clone(),
553 },
554 }
555}
556
557async fn resolve_current(
565 resolved: ResolvedStart,
566 conn: &mut Conn,
567) -> Result<ResolvedStart, FaucetError> {
568 if !matches!(resolved, ResolvedStart::Current { .. }) {
569 return Ok(resolved);
570 }
571 let row: Option<Row> = conn
572 .query_first("SHOW MASTER STATUS")
573 .await
574 .map_err(|e| FaucetError::Source(format!("mysql-cdc: SHOW MASTER STATUS failed: {e}")))?;
575 let row = row.ok_or_else(|| {
576 FaucetError::Source(
577 "mysql-cdc: SHOW MASTER STATUS returned no rows; \
578 is binary logging enabled?"
579 .into(),
580 )
581 })?;
582 let file: String = row.get(0).ok_or_else(|| {
583 FaucetError::Source("mysql-cdc: SHOW MASTER STATUS: missing File column".into())
584 })?;
585 let pos: u64 = row.get(1).ok_or_else(|| {
586 FaucetError::Source("mysql-cdc: SHOW MASTER STATUS: missing Position column".into())
587 })?;
588 Ok(ResolvedStart::FilePos { file, pos })
589}
590
591fn build_request<'r>(
598 server_id: u32,
599 resolved: &'r ResolvedStart,
600) -> Result<BinlogStreamRequest<'r>, FaucetError> {
601 use mysql_async::Sid;
602 use std::str::FromStr;
603
604 match resolved {
605 ResolvedStart::Current { .. } => Ok(BinlogStreamRequest::new(server_id)),
608 ResolvedStart::Earliest => {
609 Ok(BinlogStreamRequest::new(server_id))
611 }
612 ResolvedStart::FilePos { file, pos } => {
613 Ok(BinlogStreamRequest::new(server_id)
616 .with_filename(file.as_bytes())
617 .with_pos(*pos))
618 }
619 ResolvedStart::GtidSet { value } => {
620 let sids: Vec<Sid<'static>> = value
625 .split(',')
626 .map(|part| {
627 let trimmed = part.trim();
628 Sid::from_str(trimmed).map_err(|e| {
629 FaucetError::Source(format!("mysql-cdc: invalid GTID set '{trimmed}': {e}"))
630 })
631 })
632 .collect::<Result<Vec<_>, _>>()?;
633
634 Ok(BinlogStreamRequest::new(server_id)
635 .with_gtid()
636 .with_gtid_set(sids))
637 }
638 }
639}
640
641pub(crate) fn op_from_rows_event(re: &RowsEventData<'_>) -> &'static str {
643 match re {
644 RowsEventData::WriteRowsEvent(_) | RowsEventData::WriteRowsEventV1(_) => "c",
645 RowsEventData::UpdateRowsEvent(_)
646 | RowsEventData::UpdateRowsEventV1(_)
647 | RowsEventData::PartialUpdateRowsEvent(_) => "u",
648 RowsEventData::DeleteRowsEvent(_) | RowsEventData::DeleteRowsEventV1(_) => "d",
649 }
650}
651
652#[allow(clippy::too_many_arguments)]
660pub(crate) fn build_envelope(
661 op: &str,
662 ts_ms: u64,
663 schema: &str,
664 table: &str,
665 before: Value,
666 after: Value,
667 lsn: Value,
668 txid: u64,
669) -> Value {
670 let mut obj = Map::new();
671 obj.insert("op".into(), json!(op));
672 obj.insert("ts_ms".into(), json!(ts_ms));
673 obj.insert("schema".into(), json!(schema));
674 obj.insert("table".into(), json!(table));
675 obj.insert("before".into(), before);
676 obj.insert("after".into(), after);
677 obj.insert("lsn".into(), lsn);
678 obj.insert("txid".into(), json!(txid));
679 Value::Object(obj)
680}
681
682fn build_ddl_envelope(statement: &str, ts_ms: u64, file: &str, pos: u64) -> Value {
684 json!({
685 "op": "ddl",
686 "ts_ms": ts_ms,
687 "statement": statement,
688 "lsn": { "file": file, "pos": pos },
689 })
690}
691
692fn build_opts(config: &MysqlCdcSourceConfig) -> Result<Opts, FaucetError> {
697 let base = Opts::from_url(&config.connection_url)
698 .map_err(|e| FaucetError::Config(format!("mysql-cdc: invalid connection URL: {e}")))?;
699
700 let ssl = match &config.tls {
701 CdcTls::Disable => return Ok(base),
702 CdcTls::Require => SslOpts::default()
703 .with_danger_accept_invalid_certs(true)
704 .with_danger_skip_domain_validation(true),
705 CdcTls::VerifyCa { ca_path } => {
706 let mut s = SslOpts::default().with_danger_skip_domain_validation(true);
707 if let Some(p) = ca_path {
708 s = s.with_root_certs(vec![PathBuf::from(p).into()]);
709 }
710 s
711 }
712 CdcTls::VerifyFull { ca_path } => {
713 let mut s = SslOpts::default();
714 if let Some(p) = ca_path {
715 s = s.with_root_certs(vec![PathBuf::from(p).into()]);
716 }
717 s
718 }
719 };
720
721 Ok(OptsBuilder::from_opts(base).ssl_opts(ssl).into())
722}
723
724async fn run_preflight_probes(
731 conn: &mut Conn,
732 config: &MysqlCdcSourceConfig,
733) -> Result<String, String> {
734 let fmt: Option<(String, String)> = conn
736 .query_first("SHOW VARIABLES LIKE 'binlog_format'")
737 .await
738 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_format' failed: {e}"))?;
739 match fmt.as_ref() {
740 Some((_, v)) if !v.eq_ignore_ascii_case("ROW") => {
741 return Err(format!(
742 "binlog_format is '{v}', must be ROW. \
743 Set binlog_format=ROW in your MySQL config."
744 ));
745 }
746 None => {
747 return Err("binlog_format variable not found. Is binary logging enabled?".into());
748 }
749 _ => {}
750 }
751
752 let img: Option<(String, String)> = conn
754 .query_first("SHOW VARIABLES LIKE 'binlog_row_image'")
755 .await
756 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_image' failed: {e}"))?;
757 match img.as_ref() {
758 Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
759 return Err(format!(
760 "binlog_row_image is '{v}', must be FULL. \
761 Set binlog_row_image=FULL in your MySQL config."
762 ));
763 }
764 None => {
765 return Err("binlog_row_image variable not found.".into());
766 }
767 _ => {}
768 }
769
770 let meta: Option<(String, String)> = conn
772 .query_first("SHOW VARIABLES LIKE 'binlog_row_metadata'")
773 .await
774 .map_err(|e| format!("SHOW VARIABLES LIKE 'binlog_row_metadata' failed: {e}"))?;
775 match meta.as_ref() {
776 Some((_, v)) if !v.eq_ignore_ascii_case("FULL") => {
777 return Err(format!(
778 "binlog_row_metadata is '{v}', must be FULL. \
779 Set binlog_row_metadata=FULL in your MySQL config."
780 ));
781 }
782 None => {
783 return Err("binlog_row_metadata variable not found.".into());
784 }
785 _ => {}
786 }
787
788 let grants: Vec<String> = conn
790 .query("SHOW GRANTS FOR CURRENT_USER()")
791 .await
792 .map_err(|e| format!("SHOW GRANTS failed: {e}"))?;
793 let grants_combined = grants.join(" ").to_uppercase();
794 let has_replication = grants_combined.contains("ALL PRIVILEGES")
795 || (grants_combined.contains("REPLICATION SLAVE")
796 && grants_combined.contains("REPLICATION CLIENT"));
797 if !has_replication {
798 return Err(
799 "user lacks REPLICATION SLAVE and/or REPLICATION CLIENT privileges. \
800 Grant them with: GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'user'@'host';"
801 .into(),
802 );
803 }
804
805 if matches!(config.start_position, StartPosition::GtidSet { .. }) {
809 let gtid: Option<(String, String)> = conn
810 .query_first("SHOW VARIABLES LIKE 'gtid_mode'")
811 .await
812 .map_err(|e| format!("SHOW VARIABLES LIKE 'gtid_mode' failed: {e}"))?;
813 match gtid.as_ref() {
814 Some((_, v)) if !v.eq_ignore_ascii_case("ON") => {
815 return Err(format!(
816 "start_position is GtidSet but gtid_mode is '{v}' (must be ON). \
817 Enable GTID mode: --gtid-mode=ON --enforce-gtid-consistency=ON"
818 ));
819 }
820 None => {
821 return Err("gtid_mode variable not found".into());
822 }
823 _ => {}
824 }
825 }
826
827 Ok("binlog_format=ROW, binlog_row_image=FULL, binlog_row_metadata=FULL, grants OK".into())
828}
829
830async fn run_preflight(conn: &mut Conn, config: &MysqlCdcSourceConfig) -> Result<(), FaucetError> {
832 run_preflight_probes(conn, config)
833 .await
834 .map(|_| ())
835 .map_err(|m| FaucetError::Source(format!("mysql-cdc: {m}")))
836}
837
838#[cfg(test)]
843mod tests {
844 use super::*;
845 use crate::state::Bookmark;
846 use serde_json::json;
847
848 #[test]
851 fn file_pos_bookmark_overrides_current() {
852 let bm = Bookmark::FilePos {
853 file: "binlog.000003".into(),
854 pos: 4567,
855 };
856 let resolved = resolve_start(&StartPosition::Current, Some(&bm));
857 assert_eq!(
858 resolved,
859 ResolvedStart::FilePos {
860 file: "binlog.000003".into(),
861 pos: 4567
862 }
863 );
864 }
865
866 #[test]
867 fn file_pos_bookmark_overrides_gtid_config() {
868 let bm = Bookmark::FilePos {
869 file: "binlog.000010".into(),
870 pos: 123,
871 };
872 let resolved = resolve_start(
873 &StartPosition::GtidSet {
874 value: "abc:1-100".into(),
875 },
876 Some(&bm),
877 );
878 assert_eq!(
879 resolved,
880 ResolvedStart::FilePos {
881 file: "binlog.000010".into(),
882 pos: 123
883 }
884 );
885 }
886
887 #[test]
888 fn gtid_bookmark_overrides_current() {
889 let bm = Bookmark::GtidSet {
890 gtid_set: "abc:1-100".into(),
891 };
892 let resolved = resolve_start(&StartPosition::Current, Some(&bm));
893 assert_eq!(
894 resolved,
895 ResolvedStart::GtidSet {
896 value: "abc:1-100".into()
897 }
898 );
899 }
900
901 #[test]
902 fn no_bookmark_current_yields_current() {
903 let resolved = resolve_start(&StartPosition::Current, None);
904 assert!(matches!(resolved, ResolvedStart::Current { .. }));
905 }
906
907 #[test]
908 fn no_bookmark_earliest_yields_earliest() {
909 let resolved = resolve_start(&StartPosition::Earliest, None);
910 assert_eq!(resolved, ResolvedStart::Earliest);
911 }
912
913 #[test]
914 fn no_bookmark_file_pos_config_passes_through() {
915 let resolved = resolve_start(
916 &StartPosition::FilePos {
917 file: "binlog.000001".into(),
918 pos: 4,
919 },
920 None,
921 );
922 assert_eq!(
923 resolved,
924 ResolvedStart::FilePos {
925 file: "binlog.000001".into(),
926 pos: 4
927 }
928 );
929 }
930
931 #[test]
943 fn envelope_shape_insert() {
944 let lsn = json!({ "file": "binlog.000001", "pos": 4567_u64 });
945 let after = json!({ "id": 1, "name": "alice" });
946 let env = build_envelope(
947 "c",
948 1_000,
949 "mydb",
950 "users",
951 Value::Null,
952 after.clone(),
953 lsn.clone(),
954 0,
955 );
956
957 assert_eq!(env["op"], "c");
958 assert_eq!(env["ts_ms"], 1_000_u64);
959 assert_eq!(env["schema"], "mydb");
960 assert_eq!(env["table"], "users");
961 assert_eq!(env["before"], Value::Null);
962 assert_eq!(env["after"], after);
963 assert_eq!(env["lsn"], lsn);
964 assert_eq!(env["txid"], 0_u64);
965 }
966
967 #[test]
968 fn envelope_shape_update() {
969 let before = json!({ "id": 1, "name": "alice" });
970 let after = json!({ "id": 1, "name": "bob" });
971 let lsn = json!({ "file": "binlog.000002", "pos": 9999_u64 });
972 let env = build_envelope(
973 "u",
974 2_000,
975 "db",
976 "tbl",
977 before.clone(),
978 after.clone(),
979 lsn,
980 3,
981 );
982
983 assert_eq!(env["op"], "u");
984 assert_eq!(env["before"], before);
985 assert_eq!(env["after"], after);
986 assert_eq!(env["txid"], 3_u64);
987 }
988
989 #[test]
990 fn envelope_shape_delete() {
991 let before = json!({ "id": 42 });
992 let lsn = json!({ "file": "binlog.000003", "pos": 100_u64 });
993 let env = build_envelope("d", 3_000, "db", "tbl", before.clone(), Value::Null, lsn, 7);
994
995 assert_eq!(env["op"], "d");
996 assert_eq!(env["before"], before);
997 assert_eq!(env["after"], Value::Null);
998 }
999
1000 #[test]
1001 fn envelope_has_all_expected_keys() {
1002 let env = build_envelope(
1003 "c",
1004 0,
1005 "s",
1006 "t",
1007 Value::Null,
1008 Value::Null,
1009 json!({ "file": "f", "pos": 0_u64 }),
1010 0,
1011 );
1012 let obj = env.as_object().unwrap();
1013 for key in &[
1014 "op", "ts_ms", "schema", "table", "before", "after", "lsn", "txid",
1015 ] {
1016 assert!(obj.contains_key(*key), "missing key: {key}");
1017 }
1018 }
1019
1020 #[test]
1023 fn build_opts_disable_succeeds() {
1024 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1025 "connection_url": "mysql://repl:pass@localhost:3306/db",
1026 "server_id": 1001
1027 }))
1028 .unwrap();
1029 assert!(build_opts(&config).is_ok());
1030 }
1031
1032 #[test]
1033 fn build_opts_require_tls_succeeds() {
1034 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1035 "connection_url": "mysql://repl:pass@localhost:3306/db",
1036 "server_id": 1002,
1037 "tls": { "mode": "require" }
1038 }))
1039 .unwrap();
1040 assert!(build_opts(&config).is_ok());
1041 }
1042
1043 #[test]
1044 fn build_opts_verify_ca_no_path() {
1045 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1046 "connection_url": "mysql://repl:pass@localhost:3306/db",
1047 "server_id": 1003,
1048 "tls": { "mode": "verify_ca" }
1049 }))
1050 .unwrap();
1051 assert!(build_opts(&config).is_ok());
1052 }
1053
1054 #[test]
1055 fn build_opts_invalid_url_errors() {
1056 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1057 "connection_url": "not-a-valid-url",
1058 "server_id": 1
1059 }))
1060 .unwrap();
1061 assert!(build_opts(&config).is_err());
1062 }
1063
1064 #[test]
1067 fn dataset_uri_strips_credentials_no_tables() {
1068 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1069 "connection_url": "mysql://repl:pass@h:3306/db",
1070 "server_id": 1
1071 }))
1072 .unwrap();
1073 let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
1074 assert_eq!(redacted, "mysql://h:3306/db");
1075 let uri = if config.include_tables.is_empty() {
1077 redacted
1078 } else {
1079 format!("{redacted}?tables={}", config.include_tables.join(","))
1080 };
1081 assert_eq!(uri, "mysql://h:3306/db");
1082 }
1083
1084 #[test]
1085 fn dataset_uri_appends_tables_when_present() {
1086 let config: MysqlCdcSourceConfig = serde_json::from_value(json!({
1087 "connection_url": "mysql://repl:pass@h:3306/db",
1088 "server_id": 1,
1089 "include_tables": ["db.orders", "db.users"]
1090 }))
1091 .unwrap();
1092 let redacted = faucet_core::redact_uri_credentials(&config.connection_url);
1093 let uri = if config.include_tables.is_empty() {
1094 redacted
1095 } else {
1096 format!("{redacted}?tables={}", config.include_tables.join(","))
1097 };
1098 assert_eq!(uri, "mysql://h:3306/db?tables=db.orders,db.users");
1099 }
1100
1101 #[test]
1110 fn build_request_current_arm_succeeds() {
1111 let resolved = ResolvedStart::Current {
1115 file: String::new(),
1116 pos: 0,
1117 };
1118 assert!(build_request(42, &resolved).is_ok());
1119 }
1120
1121 #[test]
1122 fn build_request_earliest_arm_succeeds() {
1123 let resolved = ResolvedStart::Earliest;
1124 assert!(build_request(7, &resolved).is_ok());
1125 }
1126
1127 #[test]
1128 fn build_request_file_pos_arm_succeeds() {
1129 let resolved = ResolvedStart::FilePos {
1130 file: "binlog.000007".into(),
1131 pos: 8192,
1132 };
1133 assert!(build_request(1001, &resolved).is_ok());
1134 }
1135
1136 #[test]
1137 fn build_request_valid_gtid_set_succeeds() {
1138 let resolved = ResolvedStart::GtidSet {
1141 value: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5, \
1142 8a1d3a7c-71ca-11e1-9e33-c80aa9429562:1-10"
1143 .into(),
1144 };
1145 assert!(build_request(1001, &resolved).is_ok());
1146 }
1147
1148 #[test]
1149 fn build_request_invalid_gtid_set_errors_with_source_variant() {
1150 let resolved = ResolvedStart::GtidSet {
1153 value: "totally-not-a-gtid".into(),
1154 };
1155 match build_request(1001, &resolved) {
1158 Err(FaucetError::Source(msg)) => {
1159 assert!(
1160 msg.contains("invalid GTID set 'totally-not-a-gtid'"),
1161 "message must name the bad fragment; got: {msg}"
1162 );
1163 }
1164 Err(other) => panic!("expected FaucetError::Source, got {other:?}"),
1165 Ok(_) => panic!("invalid GTID must error"),
1166 }
1167 }
1168
1169 #[test]
1172 fn ddl_envelope_shape() {
1173 let env = build_ddl_envelope("ALTER TABLE t ADD c INT", 1_700, "binlog.000004", 512);
1174 assert_eq!(env["op"], "ddl");
1175 assert_eq!(env["ts_ms"], 1_700_u64);
1176 assert_eq!(env["statement"], "ALTER TABLE t ADD c INT");
1177 assert_eq!(
1178 env["lsn"],
1179 json!({ "file": "binlog.000004", "pos": 512_u64 })
1180 );
1181 let obj = env.as_object().unwrap();
1183 assert!(!obj.contains_key("before"));
1184 assert!(!obj.contains_key("after"));
1185 assert!(!obj.contains_key("schema"));
1186 assert!(!obj.contains_key("table"));
1187 }
1188}