Skip to main content

faucet_source_mysql_cdc/
stream.rs

1//! `MysqlCdcSource` — public `Source` implementation.
2//!
3//! Tails the MySQL binary log via `mysql_async`'s async [`BinlogStream`] and
4//! emits per-row change events as CDC envelopes.  Transactions are buffered
5//! in memory (BEGIN → ROWS → COMMIT) and emitted atomically as one
6//! [`StreamPage`] per commit, matching the postgres-cdc durability contract.
7//!
8//! **Target engine:** primarily InnoDB / transactional tables, where commits
9//! arrive as `XidEvent`.  Explicit `COMMIT` statements (emitted by
10//! non-transactional / mixed-engine workloads as `QueryEvent("COMMIT")`) are
11//! also handled as commit boundaries with identical durability semantics.
12//!
13//! **Bookmark strategy:** all persisted bookmarks use `{file, pos}` (the
14//! end-position of the commit event).  Even when `start_position` is
15//! `GtidSet`, the session resume is via file/pos after the first commit.
16//! Assembling the full executed-GTID set from raw `GtidEvent` messages
17//! across multiple sessions is fiddly (needs SID→interval accumulation
18//! across runs), whereas file/pos is always available, unambiguous, and
19//! fully resumable — the server still honours `gtid_mode` ordering
20//! guarantees on the other side.  This choice is documented in the crate
21//! README.
22
23use 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
37/// A configured MySQL CDC (binlog replication) source.
38///
39/// Bookmarks are file/pos coordinates — see module-level note for the
40/// rationale behind the always-file/pos bookmark strategy.
41pub struct MysqlCdcSource {
42    config: MysqlCdcSourceConfig,
43    opts: Opts,
44    state_key_value: String,
45    /// Bookmark provided by `apply_start_bookmark`, applied at the start of
46    /// the next fetch cycle to skip already-consumed events.
47    pending_bookmark: Mutex<Option<Bookmark>>,
48}
49
50impl MysqlCdcSource {
51    /// Build and preflight-check the source.
52    ///
53    /// Runs `config.validate()`, builds TLS-aware `Opts`, then opens a
54    /// throwaway connection to verify binlog variables and user grants.
55    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        // Preflight: open + query + drop a throwaway connection.
62        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// ──────────────────────────────────────────────────────────────────────────────
79// Source impl
80// ──────────────────────────────────────────────────────────────────────────────
81
82#[async_trait]
83impl Source for MysqlCdcSource {
84    /// Drain the stream using the per-source `batch_size = 0` sentinel so
85    /// all transactions are accumulated into a single trailing page —
86    /// matching the historical convenience API contract.
87    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    /// Per-transaction streaming.  Each committed transaction is emitted as
101    /// its own [`StreamPage`] with `bookmark = Some(file_pos)`.  The
102    /// trait-level `batch_size` argument is ignored in favour of the config
103    /// field.
104    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        // Durable monotonic binlog file/pos + deterministic replay from it +
128        // per-transaction (per-page) bookmarks — the requirements for
129        // exactly-once delivery.
130        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    /// Preflight probe that does **not** open the binlog stream.
147    ///
148    /// Runs two probes bounded by `ctx.timeout`:
149    /// - `connection`: can we connect + authenticate?
150    /// - `binlog-config`: are the required server variables set?
151    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
200// ──────────────────────────────────────────────────────────────────────────────
201// Stream loop
202// ──────────────────────────────────────────────────────────────────────────────
203
204impl 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            // 1. Resolve start position for this fetch cycle.
217            let pending = self.pending_bookmark.lock().await.take();
218            let resolved = resolve_start(&self.config.start_position, pending.as_ref());
219
220            // 2. Open a connection and build the binlog stream request.
221            let mut conn = Conn::new(self.opts.clone())
222                .await
223                .map_err(|e| FaucetError::Source(format!("mysql-cdc: connect failed: {e}")))?;
224
225            // Resolve Current → FilePos by querying SHOW MASTER STATUS (fills in the
226            // actual file/pos before we build the request that borrows from `resolved`).
227            let resolved = resolve_current(resolved, &mut conn).await?;
228            let req = build_request(self.config.server_id, &resolved)?;
229
230            // 3. Start the binlog stream.
231            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            // 4. Per-event tracking state.
238            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            // Bookmark of the last successfully committed transaction.
246            let mut last_commit_bookmark: Option<Bookmark> = None;
247            // Aggregate buffer used when batch_size == 0.
248            let mut agg_records: Vec<Value> = Vec::new();
249
250            // commit_buffer!($bm) — factors out the emit/accumulate logic shared by
251            // XidEvent (InnoDB), QueryEvent("COMMIT") (non-transactional/mixed), and
252            // the desync-guard flush that runs when a new transaction starts while the
253            // buffer is unexpectedly non-empty.
254            //
255            // The macro does NOT update `in_txn` or `txid`; the caller is responsible
256            // for those so that desync-guard callers (which immediately set `in_txn =
257            // true` afterward) don't trigger a dead-assignment lint.
258            //
259            // Behaviour:
260            //  - per_transaction mode: yields one StreamPage per commit (even if the
261            //    buffer is empty, so the bookmark still advances).
262            //  - aggregate mode (batch_size == 0): extends agg_records and records the
263            //    bookmark for the trailing flush at stream end; skips empty buffers.
264            //
265            // Must be a macro (not a closure) because it needs to `yield` inside the
266            // async_stream::try_stream! block and capture locals by mutable reference.
267            macro_rules! commit_buffer {
268                ($bm:expr) => {{
269                    let bm: Bookmark = $bm;
270                    if per_transaction {
271                        // Always yield — even an empty page advances the bookmark.
272                        yield StreamPage {
273                            records: std::mem::take(&mut buffer),
274                            bookmark: Some(bm.to_value()?),
275                        };
276                    } else if !buffer.is_empty() {
277                        // Aggregate mode: accumulate; last_commit_bookmark records
278                        // the bookmark for the trailing flush at stream end.
279                        // Only guard assignment in aggregate mode — per_transaction
280                        // already yields the bookmark directly.
281                        last_commit_bookmark = Some(bm);
282                        agg_records.extend(std::mem::take(&mut buffer));
283                    }
284                }};
285            }
286
287            // 5. Drain loop.
288            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                                // A GtidEvent precedes BEGIN in GTID mode.
307                                // We don't need the SID/GNO here because we
308                                // bookmark with file/pos on commit (see module note).
309                                //
310                                // Desync guard: if the buffer is non-empty when a new
311                                // transaction starts, the previous transaction ended
312                                // without an explicit commit boundary event — flush it
313                                // now to prevent silent conflation into the next txid.
314                                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                                    // Desync guard: same reasoning as GtidEvent.
329                                    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                                    // Non-transactional / mixed-engine explicit COMMIT.
340                                    // MySQL emits QueryEvent("COMMIT") instead of XidEvent
341                                    // for non-InnoDB engines; treat it identically.
342                                    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                                    // DDL statement — auto-commits implicitly.
351                                    if self.config.emit_schema_changes {
352                                        let envelope = build_ddl_envelope(
353                                            q.as_ref(),
354                                            ts_ms,
355                                            &current_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                                    // Skip filtered tables but still mark as in_txn.
388                                    continue;
389                                }
390
391                                let op = op_from_rows_event(&re);
392                                let lsn = json!({ "file": &current_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                                    // Fix 4: use let-chain (stable in edition 2024).
421                                    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                                // InnoDB COMMIT — emit the buffered transaction.
436                                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                                // FormatDescriptionEvent, PreviousGtidsEvent, etc. — ignored.
446                            }
447                        }
448                    }
449                    Ok(Some(Err(e))) => {
450                        Err(FaucetError::Source(format!("mysql-cdc: stream error: {e}")))?;
451                    }
452                    // Idle timeout or stream closed: flush remaining buffer and end.
453                    Ok(None) | Err(_) => {
454                        // Drop any uncommitted partial transaction — the server will
455                        // redeliver it from the last persisted bookmark on the next run.
456                        let _ = in_txn;
457
458                        // Aggregate mode: emit one trailing page with all accumulated
459                        // records across the fetch window.  If every transaction was
460                        // filtered (all rows excluded by table_included), agg_records
461                        // stays empty and last_commit_bookmark is None, so the bookmark
462                        // is not advanced — those transactions are harmlessly re-scanned
463                        // on the next cycle (aggregate mode is for test/snapshot use only).
464                        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// ──────────────────────────────────────────────────────────────────────────────
488// Helpers (pure, unit-testable)
489// ──────────────────────────────────────────────────────────────────────────────
490
491/// Resolved start position for a single fetch cycle.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub(crate) enum ResolvedStart {
494    /// Start at the server's current position (fresh run, no history).
495    Current { file: String, pos: u64 },
496    /// Start at the oldest available binlog (errors if purged).
497    Earliest,
498    /// Resume from a persisted file/pos bookmark.
499    FilePos { file: String, pos: u64 },
500    /// Start after a specific GTID set.  The string is parsed into `Sid`s
501    /// for the `BinlogStreamRequest`.
502    GtidSet { value: String },
503}
504
505/// Determine the effective start for this fetch cycle.
506///
507/// **Precedence:** a persisted bookmark (set via `apply_start_bookmark`) always
508/// wins over the config's `start_position` — this is the CDC durability
509/// invariant: we only advance past a position once the pipeline has persisted
510/// the bookmark downstream.
511///
512/// - `FilePos` bookmark → `ResolvedStart::FilePos`
513/// - `GtidSet` bookmark (from a previous session that used GTID start) →
514///   treated as `FilePos` since all our bookmarks are file/pos after the first commit.
515///
516/// Note: all persisted bookmarks are `Bookmark::FilePos` (see module-level
517/// note on the bookmark strategy), so the `GtidSet` arm is defensive.
518pub(crate) fn resolve_start(
519    start_position: &StartPosition,
520    pending: Option<&Bookmark>,
521) -> ResolvedStart {
522    if let Some(bm) = pending {
523        // A persisted bookmark always wins.
524        return match bm {
525            Bookmark::FilePos { file, pos } => ResolvedStart::FilePos {
526                file: file.clone(),
527                pos: *pos,
528            },
529            // Defensive: a GtidSet bookmark from a previous session that
530            // DID persist GTID coordinates — start from the GTID set.
531            Bookmark::GtidSet { gtid_set } => ResolvedStart::GtidSet {
532                value: gtid_set.clone(),
533            },
534        };
535    }
536
537    // No persisted bookmark — use the config.
538    match start_position {
539        StartPosition::Current => {
540            // Placeholder; real file/pos filled in by `build_request` via SHOW MASTER STATUS.
541            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
557/// If `resolved` is `Current`, query the server for its current binlog
558/// position and return a `FilePos` variant with the real coordinates.
559/// All other variants pass through unchanged.
560///
561/// Splitting this out of `build_request` ensures `resolved` is fully owned
562/// before we build a request that borrows from it, avoiding the need to leak
563/// any byte buffers.
564async 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
591/// Build a `BinlogStreamRequest` from the resolved start position.
592///
593/// No heap memory is leaked: filenames borrow from `resolved` (which the
594/// caller holds for the duration of the call), and GTID SIDs are parsed into
595/// fully-owned data structures — `Sid::from_str` produces `Sid<'static>`
596/// because all internal fields (`Seq` / `Tag`) are stored as `Cow::Owned`.
597fn 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        // resolve_current() converts Current → FilePos before we get here;
606        // this arm is only hit if a caller skips that step (defensive).
607        ResolvedStart::Current { .. } => Ok(BinlogStreamRequest::new(server_id)),
608        ResolvedStart::Earliest => {
609            // No filename/pos — server starts from the oldest available binlog.
610            Ok(BinlogStreamRequest::new(server_id))
611        }
612        ResolvedStart::FilePos { file, pos } => {
613            // Borrow the filename bytes directly from the owned `String` in
614            // `resolved` — no copy, no leak.
615            Ok(BinlogStreamRequest::new(server_id)
616                .with_filename(file.as_bytes())
617                .with_pos(*pos))
618        }
619        ResolvedStart::GtidSet { value } => {
620            // `Sid::from_str` parses into fully-owned data (intervals as
621            // `Cow::Owned`, tag as `Tag<'static>` via `to_owned()`), so the
622            // resulting `Sid<'static>` does not borrow the input string.
623            // No leaking required.
624            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
641/// Map a `RowsEventData` variant to its CDC operation string.
642pub(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/// Build a CDC change-event envelope.
653///
654/// ```json
655/// { "op": "c", "ts_ms": 1234, "schema": "mydb", "table": "users",
656///   "before": null, "after": {"id": 1, "name": "alice"},
657///   "lsn": {"file": "binlog.000001", "pos": 4567}, "txid": 0 }
658/// ```
659#[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
682/// Build a DDL change-event envelope.
683fn 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
692// ──────────────────────────────────────────────────────────────────────────────
693// TLS + Opts construction
694// ──────────────────────────────────────────────────────────────────────────────
695
696fn 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
724// ──────────────────────────────────────────────────────────────────────────────
725// Preflight helpers
726// ──────────────────────────────────────────────────────────────────────────────
727
728/// Run preflight checks and return a human-readable summary on success, or an
729/// error message on the first failing check.
730async fn run_preflight_probes(
731    conn: &mut Conn,
732    config: &MysqlCdcSourceConfig,
733) -> Result<String, String> {
734    // Check binlog_format = ROW
735    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    // Check binlog_row_image = FULL
753    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    // Check binlog_row_metadata = FULL (required for column names)
771    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    // Check REPLICATION grants
789    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 start_position is GtidSet, gtid_mode must be ON. Checked here (rather
806    // than only in `new()`) so `faucet doctor`'s binlog-config probe catches it
807    // too.
808    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
830/// Run preflight checks, mapping a failure to a typed `FaucetError::Source`.
831async 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// ──────────────────────────────────────────────────────────────────────────────
839// Unit tests
840// ──────────────────────────────────────────────────────────────────────────────
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::state::Bookmark;
846    use serde_json::json;
847
848    // ── resolve_start precedence ──────────────────────────────────────────────
849
850    #[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    // ── op_from_rows_event ────────────────────────────────────────────────────
932    //
933    // `op_from_rows_event` maps a `RowsEventData` variant → "c"/"u"/"d". A
934    // `RowsEventData` cannot be constructed without raw binlog bytes, so this
935    // mapping is exercised end-to-end by the Docker integration test
936    // (`tests/integration.rs`), which asserts an INSERT/UPDATE/DELETE produce
937    // ops c/u/d. A unit test asserting string literals against themselves would
938    // give false confidence, so it is intentionally omitted here.
939
940    // ── envelope assembly ─────────────────────────────────────────────────────
941
942    #[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    // ── build_opts TLS ────────────────────────────────────────────────────────
1021
1022    #[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    // dataset_uri is a pure-config method; the source requires a live DB to
1065    // construct so we verify the logic directly using config deserialization.
1066    #[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        // No include_tables → base URI only.
1076        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    // ── build_request ─────────────────────────────────────────────────────────
1102    //
1103    // `BinlogStreamRequest` exposes no public getters and does not derive
1104    // `Debug`, so the only observable outcome of `build_request` for the
1105    // non-erroring arms is `Ok` vs `Err` (the builder is side-effect-free).
1106    // The GtidSet arm additionally has an observable error path, which is
1107    // asserted exactly below.
1108
1109    #[test]
1110    fn build_request_current_arm_succeeds() {
1111        // Defensive arm: `resolve_current` normally converts Current → FilePos
1112        // before `build_request`, but a caller that skips it must still get a
1113        // plain request rather than an error.
1114        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        // A well-formed `uuid:interval` GTID parses into `Sid`s; multiple
1139        // comma-separated entries are each trimmed and parsed.
1140        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        // A malformed GTID set surfaces as a typed `FaucetError::Source` whose
1151        // message names the offending fragment.
1152        let resolved = ResolvedStart::GtidSet {
1153            value: "totally-not-a-gtid".into(),
1154        };
1155        // `BinlogStreamRequest` is not `Debug`, so match the `Result` directly
1156        // rather than via `expect_err` (which would require `Ok: Debug`).
1157        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    // ── build_ddl_envelope ────────────────────────────────────────────────────
1170
1171    #[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        // DDL envelopes carry no before/after/schema/table keys.
1182        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}