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    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        // Durable monotonic binlog file/pos + deterministic replay from it +
137        // per-transaction (per-page) bookmarks — the requirements for
138        // exactly-once delivery.
139        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    /// Preflight probe that does **not** open the binlog stream.
156    ///
157    /// Runs two probes bounded by `ctx.timeout`:
158    /// - `connection`: can we connect + authenticate?
159    /// - `binlog-config`: are the required server variables set?
160    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
209// ──────────────────────────────────────────────────────────────────────────────
210// Stream loop
211// ──────────────────────────────────────────────────────────────────────────────
212
213impl 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            // 1. Resolve start position for this fetch cycle.
226            let pending = self.pending_bookmark.lock().await.take();
227            let resolved = resolve_start(&self.config.start_position, pending.as_ref());
228
229            // 2. Open a connection and build the binlog stream request.
230            let mut conn = Conn::new(self.opts.clone())
231                .await
232                .map_err(|e| FaucetError::Source(format!("mysql-cdc: connect failed: {e}")))?;
233
234            // Resolve Current → FilePos by querying the server's current binlog
235            // position (fills in the actual file/pos before we build the request
236            // that borrows from `resolved`).
237            let resolved = resolve_current(resolved, &mut conn).await?;
238            let req = build_request(self.config.server_id, &resolved)?;
239
240            // 3. Start the binlog stream.
241            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            // 4. Per-event tracking state.
248            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            // Bookmark of the last successfully committed transaction.
256            let mut last_commit_bookmark: Option<Bookmark> = None;
257            // Aggregate buffer used when batch_size == 0.
258            let mut agg_records: Vec<Value> = Vec::new();
259
260            // commit_buffer!($bm) — factors out the emit/accumulate logic shared by
261            // XidEvent (InnoDB), QueryEvent("COMMIT") (non-transactional/mixed), and
262            // the desync-guard flush that runs when a new transaction starts while the
263            // buffer is unexpectedly non-empty.
264            //
265            // The macro does NOT update `in_txn` or `txid`; the caller is responsible
266            // for those so that desync-guard callers (which immediately set `in_txn =
267            // true` afterward) don't trigger a dead-assignment lint.
268            //
269            // Behaviour:
270            //  - per_transaction mode: yields one StreamPage per commit (even if the
271            //    buffer is empty, so the bookmark still advances).
272            //  - aggregate mode (batch_size == 0): extends agg_records and records the
273            //    bookmark for the trailing flush at stream end; skips empty buffers.
274            //
275            // Must be a macro (not a closure) because it needs to `yield` inside the
276            // async_stream::try_stream! block and capture locals by mutable reference.
277            macro_rules! commit_buffer {
278                ($bm:expr) => {{
279                    let bm: Bookmark = $bm;
280                    if per_transaction {
281                        // Always yield — even an empty page advances the bookmark.
282                        yield StreamPage {
283                            records: std::mem::take(&mut buffer),
284                            bookmark: Some(bm.to_value()?),
285                        };
286                    } else if !buffer.is_empty() {
287                        // Aggregate mode: accumulate; last_commit_bookmark records
288                        // the bookmark for the trailing flush at stream end.
289                        // Only guard assignment in aggregate mode — per_transaction
290                        // already yields the bookmark directly.
291                        last_commit_bookmark = Some(bm);
292                        agg_records.extend(std::mem::take(&mut buffer));
293                    }
294                }};
295            }
296
297            // 5. Drain loop.
298            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                                // A GtidEvent precedes BEGIN in GTID mode.
317                                // We don't need the SID/GNO here because we
318                                // bookmark with file/pos on commit (see module note).
319                                //
320                                // Desync guard: if the buffer is non-empty when a new
321                                // transaction starts, the previous transaction ended
322                                // without an explicit commit boundary event — flush it
323                                // now to prevent silent conflation into the next txid.
324                                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                                    // Desync guard: same reasoning as GtidEvent.
339                                    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                                    // Non-transactional / mixed-engine explicit COMMIT.
350                                    // MySQL emits QueryEvent("COMMIT") instead of XidEvent
351                                    // for non-InnoDB engines; treat it identically.
352                                    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                                    // DDL statement — auto-commits implicitly (F36).
361                                    //
362                                    // A DDL statement in MySQL forces an implicit commit
363                                    // of any in-progress transaction *before* it runs.
364                                    // If `buffer` still holds rows here, they belong to
365                                    // that just-committed transaction. We MUST flush them
366                                    // (advancing the bookmark atomically with their emit)
367                                    // before touching the DDL — otherwise the DDL's own
368                                    // bookmark below would advance past those un-emitted
369                                    // rows and they would be silently lost on resume.
370                                    // Mirror the BEGIN/COMMIT/desync flush exactly.
371                                    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                                            &current_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                                    // Skip filtered tables but still mark as in_txn.
417                                    continue;
418                                }
419
420                                let op = op_from_rows_event(&re);
421                                let lsn = json!({ "file": &current_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                                    // Fix 4: use let-chain (stable in edition 2024).
450                                    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                                // InnoDB COMMIT — emit the buffered transaction.
465                                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                                // FormatDescriptionEvent, PreviousGtidsEvent, etc. — ignored.
475                            }
476                        }
477                    }
478                    Ok(Some(Err(e))) => {
479                        Err(FaucetError::Source(format!("mysql-cdc: stream error: {e}")))?;
480                    }
481                    // Idle timeout or stream closed: flush remaining buffer and end.
482                    Ok(None) | Err(_) => {
483                        // Drop any uncommitted partial transaction — the server will
484                        // redeliver it from the last persisted bookmark on the next run.
485                        let _ = in_txn;
486
487                        // Aggregate mode: emit one trailing page with all accumulated
488                        // records across the fetch window.  If every transaction was
489                        // filtered (all rows excluded by table_included), agg_records
490                        // stays empty and last_commit_bookmark is None, so the bookmark
491                        // is not advanced — those transactions are harmlessly re-scanned
492                        // on the next cycle (aggregate mode is for test/snapshot use only).
493                        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// ──────────────────────────────────────────────────────────────────────────────
517// Helpers (pure, unit-testable)
518// ──────────────────────────────────────────────────────────────────────────────
519
520/// Resolved start position for a single fetch cycle.
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub(crate) enum ResolvedStart {
523    /// Start at the server's current position (fresh run, no history).
524    Current { file: String, pos: u64 },
525    /// Start at the oldest available binlog (errors if purged).
526    Earliest,
527    /// Resume from a persisted file/pos bookmark.
528    FilePos { file: String, pos: u64 },
529    /// Start after a specific GTID set.  The string is parsed into `Sid`s
530    /// for the `BinlogStreamRequest`.
531    GtidSet { value: String },
532}
533
534/// Determine the effective start for this fetch cycle.
535///
536/// **Precedence:** a persisted bookmark (set via `apply_start_bookmark`) always
537/// wins over the config's `start_position` — this is the CDC durability
538/// invariant: we only advance past a position once the pipeline has persisted
539/// the bookmark downstream.
540///
541/// - `FilePos` bookmark → `ResolvedStart::FilePos`
542/// - `GtidSet` bookmark (from a previous session that used GTID start) →
543///   treated as `FilePos` since all our bookmarks are file/pos after the first commit.
544///
545/// Note: all persisted bookmarks are `Bookmark::FilePos` (see module-level
546/// note on the bookmark strategy), so the `GtidSet` arm is defensive.
547pub(crate) fn resolve_start(
548    start_position: &StartPosition,
549    pending: Option<&Bookmark>,
550) -> ResolvedStart {
551    if let Some(bm) = pending {
552        // A persisted bookmark always wins.
553        return match bm {
554            Bookmark::FilePos { file, pos } => ResolvedStart::FilePos {
555                file: file.clone(),
556                pos: *pos,
557            },
558            // Defensive: a GtidSet bookmark from a previous session that
559            // DID persist GTID coordinates — start from the GTID set.
560            Bookmark::GtidSet { gtid_set } => ResolvedStart::GtidSet {
561                value: gtid_set.clone(),
562            },
563        };
564    }
565
566    // No persisted bookmark — use the config.
567    match start_position {
568        StartPosition::Current => {
569            // Placeholder; real file/pos filled in later by `current_binlog_position`.
570            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
586/// If `resolved` is `Current`, query the server for its current binlog
587/// position and return a `FilePos` variant with the real coordinates.
588/// All other variants pass through unchanged.
589///
590/// Splitting this out of `build_request` ensures `resolved` is fully owned
591/// before we build a request that borrows from it, avoiding the need to leak
592/// any byte buffers.
593async 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
604/// Read the server's current binlog coordinates.
605///
606/// MySQL 8.4 removed `SHOW MASTER STATUS` in favour of `SHOW BINARY LOG STATUS`
607/// (same `File` / `Position` columns). We try the 8.4+ spelling first and fall
608/// back to the legacy statement when the server rejects it (5.7 / 8.0 raise a
609/// parse error), so one code path works across 5.7 / 8.0 / 8.4+ without version
610/// parsing. Only invoked at start/capture time, never on the per-event hot path.
611///
612/// A statement that *runs* but returns no rows means binary logging is disabled
613/// — that is a definitive answer, so we surface it rather than falling back.
614async 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        // The 8.4+ spelling was rejected (older server) — fall back.
618        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    // Pull the raw `File` / `Position` columns out of the row (the only
624    // server-dependent step), then hand the pure optionals to a unit-testable
625    // decoder so the no-rows / missing-column branches need no live server.
626    let extracted = row.map(|r| (r.get::<String, _>(0), r.get::<u64, _>(1)));
627    finalize_binlog_position(extracted, stmt)
628}
629
630/// Error returned when *both* binlog-status statements are rejected — which
631/// should never happen on a connected server, since at least one spelling is
632/// valid for any supported version. Pulled out so its message is unit-testable.
633fn 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
643/// Turn the raw `(File, Position)` columns of a binlog-status row into binlog
644/// coordinates. `extracted` is `None` when the statement returned no rows
645/// (binary logging disabled); the inner options are `None` when a column is
646/// absent. Pure (no I/O) so every branch — no-rows, missing-column, success —
647/// is unit-testable without a live server. `stmt` names the statement for errors.
648fn 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
665/// Build a `BinlogStreamRequest` from the resolved start position.
666///
667/// No heap memory is leaked: filenames borrow from `resolved` (which the
668/// caller holds for the duration of the call), and GTID SIDs are parsed into
669/// fully-owned data structures — `Sid::from_str` produces `Sid<'static>`
670/// because all internal fields (`Seq` / `Tag`) are stored as `Cow::Owned`.
671fn 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        // resolve_current() converts Current → FilePos before we get here;
680        // this arm is only hit if a caller skips that step (defensive).
681        ResolvedStart::Current { .. } => Ok(BinlogStreamRequest::new(server_id)),
682        ResolvedStart::Earliest => {
683            // No filename/pos — server starts from the oldest available binlog.
684            Ok(BinlogStreamRequest::new(server_id))
685        }
686        ResolvedStart::FilePos { file, pos } => {
687            // Borrow the filename bytes directly from the owned `String` in
688            // `resolved` — no copy, no leak.
689            Ok(BinlogStreamRequest::new(server_id)
690                .with_filename(file.as_bytes())
691                .with_pos(*pos))
692        }
693        ResolvedStart::GtidSet { value } => {
694            // `Sid::from_str` parses into fully-owned data (intervals as
695            // `Cow::Owned`, tag as `Tag<'static>` via `to_owned()`), so the
696            // resulting `Sid<'static>` does not borrow the input string.
697            // No leaking required.
698            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
715/// Whether a non-empty in-progress row buffer must be flushed before handling a
716/// DDL `QueryEvent` (F36).
717///
718/// A DDL statement implicitly auto-commits any open transaction in MySQL, so rows
719/// staged in `buffer` when a DDL arrives belong to a transaction that has already
720/// committed on the server. They must be emitted (and their bookmark advanced)
721/// *before* the DDL is processed — otherwise the DDL event's own bookmark advances
722/// past those rows and they are silently lost on resume. This mirrors the
723/// BEGIN/COMMIT/desync flush contract. Returns `true` iff the buffer is non-empty.
724///
725/// Pure seam so the data-loss-prevention decision is unit-testable without a live
726/// binlog stream.
727pub(crate) fn should_flush_buffer_before_ddl(buffer_is_empty: bool) -> bool {
728    !buffer_is_empty
729}
730
731/// A single page the DDL branch emits, modelled purely for testing (F36).
732///
733/// `bookmark_pos` is the `Some(pos)` the page advances the bookmark to, or `None`
734/// when no bookmark is attached (which never happens in the DDL branch but keeps
735/// the model general).
736#[cfg(test)]
737#[derive(Debug, Clone, PartialEq, Eq)]
738pub(crate) struct PlannedPage {
739    /// Number of records on the page (DDL envelope counts as 1; buffered rows as N).
740    pub record_count: usize,
741    /// Whether this page carries the DDL envelope (vs. flushed buffered rows).
742    pub is_ddl: bool,
743    pub bookmark_pos: Option<u64>,
744}
745
746/// Pure model of the per-transaction (`batch_size != 0`) DDL-branch emit sequence,
747/// faithful to the real branch in `stream_pages_impl` (F36 regression guard).
748///
749/// Given the count of rows currently staged in `buffer`, whether the source is
750/// configured to `emit_schema_changes`, and the DDL event's `log_pos`, it returns the
751/// ordered pages the branch emits. The invariant under test: **any buffered rows are
752/// flushed (as their own page, with the pre-DDL bookmark) before the DDL envelope** —
753/// so the DDL's bookmark can never advance past un-emitted rows.
754#[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    // Flush staged rows first, exactly as the real branch's `commit_buffer!` does.
762    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
779/// Map a `RowsEventData` variant to its CDC operation string.
780pub(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/// Build a CDC change-event envelope.
791///
792/// ```json
793/// { "op": "c", "ts_ms": 1234, "schema": "mydb", "table": "users",
794///   "before": null, "after": {"id": 1, "name": "alice"},
795///   "lsn": {"file": "binlog.000001", "pos": 4567}, "txid": 0 }
796/// ```
797#[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
820/// Build a DDL change-event envelope.
821fn 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
830// ──────────────────────────────────────────────────────────────────────────────
831// TLS + Opts construction
832// ──────────────────────────────────────────────────────────────────────────────
833
834fn 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
862// ──────────────────────────────────────────────────────────────────────────────
863// Preflight helpers
864// ──────────────────────────────────────────────────────────────────────────────
865
866/// Decide whether a `binlog_row_value_options` value is safe for CDC.
867///
868/// Only an empty value (full-value JSON logging) is acceptable. Any non-empty
869/// setting — `PARTIAL_JSON` is the only documented value today — means UPDATEs to
870/// JSON columns may emit partial diffs that cannot be reconstructed without the
871/// prior row, so it is rejected. The check is case-insensitive and ignores
872/// surrounding whitespace. Pure seam, unit-tested without a server.
873fn binlog_row_value_options_ok(value: &str) -> bool {
874    value.trim().is_empty()
875}
876
877/// Run preflight checks and return a human-readable summary on success, or an
878/// error message on the first failing check.
879async fn run_preflight_probes(
880    conn: &mut Conn,
881    config: &MysqlCdcSourceConfig,
882) -> Result<String, String> {
883    // Check binlog_format = ROW
884    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    // Check binlog_row_image = FULL
902    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    // Check binlog_row_metadata = FULL (required for column names)
920    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    // Check binlog_row_value_options is not PARTIAL_JSON. Under PARTIAL_JSON an
938    // UPDATE that touches a JSON column emits a partial diff (BinlogValue::JsonDiff)
939    // rather than the full document; faucet-stream cannot reconstruct it without the
940    // prior row, so we reject it here rather than corrupt the column at runtime.
941    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    // A missing variable (older servers) means full-value logging — acceptable.
946    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    // Check REPLICATION grants
957    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 start_position is GtidSet, gtid_mode must be ON. Checked here (rather
974    // than only in `new()`) so `faucet doctor`'s binlog-config probe catches it
975    // too.
976    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
1002/// Run preflight checks, mapping a failure to a typed `FaucetError::Source`.
1003async 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// ──────────────────────────────────────────────────────────────────────────────
1011// Unit tests
1012// ──────────────────────────────────────────────────────────────────────────────
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::state::Bookmark;
1018    use serde_json::json;
1019
1020    // ── binlog_row_value_options preflight seam (PARTIAL_JSON, F17) ───────────
1021
1022    #[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    // ── binlog position decoding (MySQL 8.4 fallback, #242) ───────────────────
1036
1037    #[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    // ── resolve_start precedence ──────────────────────────────────────────────
1084
1085    #[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    // ── op_from_rows_event ────────────────────────────────────────────────────
1167    //
1168    // `op_from_rows_event` maps a `RowsEventData` variant → "c"/"u"/"d". A
1169    // `RowsEventData` cannot be constructed without raw binlog bytes, so this
1170    // mapping is exercised end-to-end by the Docker integration test
1171    // (`tests/integration.rs`), which asserts an INSERT/UPDATE/DELETE produce
1172    // ops c/u/d. A unit test asserting string literals against themselves would
1173    // give false confidence, so it is intentionally omitted here.
1174
1175    // ── envelope assembly ─────────────────────────────────────────────────────
1176
1177    #[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    // ── build_opts TLS ────────────────────────────────────────────────────────
1256
1257    #[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    // dataset_uri is a pure-config method; the source requires a live DB to
1300    // construct so we verify the logic directly using config deserialization.
1301    #[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        // No include_tables → base URI only.
1311        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    // ── build_request ─────────────────────────────────────────────────────────
1337    //
1338    // `BinlogStreamRequest` exposes no public getters and does not derive
1339    // `Debug`, so the only observable outcome of `build_request` for the
1340    // non-erroring arms is `Ok` vs `Err` (the builder is side-effect-free).
1341    // The GtidSet arm additionally has an observable error path, which is
1342    // asserted exactly below.
1343
1344    #[test]
1345    fn build_request_current_arm_succeeds() {
1346        // Defensive arm: `resolve_current` normally converts Current → FilePos
1347        // before `build_request`, but a caller that skips it must still get a
1348        // plain request rather than an error.
1349        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        // A well-formed `uuid:interval` GTID parses into `Sid`s; multiple
1374        // comma-separated entries are each trimmed and parsed.
1375        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        // A malformed GTID set surfaces as a typed `FaucetError::Source` whose
1386        // message names the offending fragment.
1387        let resolved = ResolvedStart::GtidSet {
1388            value: "totally-not-a-gtid".into(),
1389        };
1390        // `BinlogStreamRequest` is not `Debug`, so match the `Result` directly
1391        // rather than via `expect_err` (which would require `Ok: Debug`).
1392        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    // ── build_ddl_envelope ────────────────────────────────────────────────────
1405
1406    #[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        // DDL envelopes carry no before/after/schema/table keys.
1417        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    // ── F36: DDL implicit-commit must flush buffered rows before advancing ────────
1425
1426    #[test]
1427    fn should_flush_buffer_before_ddl_decision() {
1428        // Non-empty buffer → must flush; empty buffer → nothing to flush.
1429        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        // A DDL arriving with 3 staged rows must emit those rows FIRST (their own page,
1436        // bookmarked at the pre-DDL position) and only then the DDL envelope. This is
1437        // the F36 fix: without the flush, the rows are silently lost on resume.
1438        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        // Page 1: the flushed buffered rows.
1446        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        // Page 2: the DDL envelope.
1458        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        // No staged rows → no flush page; just the DDL envelope.
1466        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        // The critical safety property: even when `emit_schema_changes` is OFF (so the
1475        // DDL envelope itself is dropped), buffered rows MUST still be flushed before
1476        // the DDL implicitly auto-commits — otherwise those rows vanish on resume.
1477        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}