Skip to main content

mongreldb_core/
migrate_mysql.rs

1//! MySQL migration path (spec section 14.1, Stage 5A).
2//!
3//! Maps MySQL wire/dialect concepts into the canonical MongrelDB request
4//! model. Does **not** duplicate the transaction engine: the adapter only
5//! translates auth, COM_QUERY, prepared statements, transactions, metadata,
6//! and kill into protocol requests.
7//!
8//! The migrate tool pipeline:
9//! introspect → map types → recommend partition keys → generate DDL →
10//! bounded copy → validate → binlog CDC catch-up → cutover → rollback window.
11//! Application-level dual-write without outbox/CDC is explicitly not
12//! recommended.
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18/// MySQL→Mongrel type mapping entry.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TypeMapping {
21    /// MySQL type name (e.g. `BIGINT UNSIGNED`).
22    pub mysql_type: String,
23    /// Mongrel type name (e.g. `UInt64`) or error marker.
24    pub mongrel_type: String,
25    /// Whether the mapping is lossy.
26    pub lossy: bool,
27    /// Notes for the operator.
28    pub notes: String,
29}
30
31/// Dialect compatibility matrix row.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DialectFeature {
34    /// Feature key (e.g. `AUTO_INCREMENT`).
35    pub feature: String,
36    /// Support level.
37    pub support: DialectSupport,
38    /// Operator-facing detail.
39    pub detail: String,
40}
41
42/// How fully a MySQL dialect feature is supported.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum DialectSupport {
45    /// Fully supported with equivalent semantics.
46    Full,
47    /// Supported with documented differences.
48    Partial,
49    /// Unsupported; clear error returned.
50    Unsupported,
51}
52
53/// One table plan produced by introspection + mapping.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct MigrateTablePlan {
56    /// Source table name.
57    pub source_table: String,
58    /// Target table name.
59    pub target_table: String,
60    /// Column mappings (source → target type).
61    pub columns: Vec<TypeMapping>,
62    /// Recommended partition key columns (empty = single tablet).
63    pub recommended_partition_keys: Vec<String>,
64    /// Generated Mongrel DDL.
65    pub ddl: String,
66    /// Incompatible features flagged.
67    pub incompatibilities: Vec<String>,
68}
69
70/// Full migration plan (schema + copy + CDC + cutover).
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct MysqlMigratePlan {
73    /// Source DSN (redacted credentials for display).
74    pub source_display: String,
75    /// Target database id / path.
76    pub target: String,
77    /// Schema-only mode (no data copy).
78    pub schema_only: bool,
79    /// Per-table plans.
80    pub tables: Vec<MigrateTablePlan>,
81    /// Pipeline stages remaining.
82    pub stages: Vec<MigrateStage>,
83}
84
85/// Pipeline stage of the migrate tool.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub enum MigrateStage {
88    /// Introspect MySQL schema.
89    Introspect,
90    /// Map types and constraints.
91    MapTypes,
92    /// Recommend partition/colocation keys.
93    RecommendPartitioning,
94    /// Generate MongrelDB DDL.
95    GenerateDdl,
96    /// Copy data in bounded batches.
97    BoundedCopy,
98    /// Validate row counts and checksums.
99    Validate,
100    /// Binlog CDC catch-up.
101    CdcCatchUp,
102    /// Cut over after lag reaches zero.
103    Cutover,
104    /// Rollback window open.
105    RollbackWindow,
106}
107
108impl MigrateStage {
109    /// Full pipeline order.
110    pub const PIPELINE: [MigrateStage; 9] = [
111        MigrateStage::Introspect,
112        MigrateStage::MapTypes,
113        MigrateStage::RecommendPartitioning,
114        MigrateStage::GenerateDdl,
115        MigrateStage::BoundedCopy,
116        MigrateStage::Validate,
117        MigrateStage::CdcCatchUp,
118        MigrateStage::Cutover,
119        MigrateStage::RollbackWindow,
120    ];
121
122    /// Stable name.
123    pub fn name(self) -> &'static str {
124        match self {
125            Self::Introspect => "introspect",
126            Self::MapTypes => "map_types",
127            Self::RecommendPartitioning => "recommend_partitioning",
128            Self::GenerateDdl => "generate_ddl",
129            Self::BoundedCopy => "bounded_copy",
130            Self::Validate => "validate",
131            Self::CdcCatchUp => "cdc_catch_up",
132            Self::Cutover => "cutover",
133            Self::RollbackWindow => "rollback_window",
134        }
135    }
136}
137
138/// Built-in dialect compatibility matrix (spec §14.1 list).
139pub fn dialect_matrix() -> Vec<DialectFeature> {
140    vec![
141        feat(
142            "data_types",
143            DialectSupport::Partial,
144            "common numeric/string/json types map; spatial unsupported",
145        ),
146        feat(
147            "AUTO_INCREMENT",
148            DialectSupport::Full,
149            "maps to Mongrel sequences / auto-inc",
150        ),
151        feat(
152            "LAST_INSERT_ID",
153            DialectSupport::Partial,
154            "session-scoped; multi-row insert returns first",
155        ),
156        feat(
157            "ON DUPLICATE KEY UPDATE",
158            DialectSupport::Partial,
159            "maps to upsert when unique key present",
160        ),
161        feat(
162            "LIMIT syntax",
163            DialectSupport::Full,
164            "LIMIT/OFFSET supported",
165        ),
166        feat(
167            "boolean behavior",
168            DialectSupport::Partial,
169            "MySQL TINYINT(1) → Bool when declared",
170        ),
171        feat(
172            "date/time behavior",
173            DialectSupport::Partial,
174            "TIMESTAMP TZ handling differs; documented",
175        ),
176        feat("JSON", DialectSupport::Full, "JSON type + path extracts"),
177        feat(
178            "collations",
179            DialectSupport::Partial,
180            "utf8mb4_bin equivalent; locale collations limited",
181        ),
182        feat(
183            "isolation levels",
184            DialectSupport::Full,
185            "RC/RR/Serializable via SET TRANSACTION",
186        ),
187        feat(
188            "locks",
189            DialectSupport::Partial,
190            "FOR UPDATE planned; GET_LOCK unsupported",
191        ),
192        feat(
193            "information_schema",
194            DialectSupport::Partial,
195            "core tables exposed; full MySQL catalog not mirrored",
196        ),
197    ]
198}
199
200fn feat(feature: &str, support: DialectSupport, detail: &str) -> DialectFeature {
201    DialectFeature {
202        feature: feature.into(),
203        support,
204        detail: detail.into(),
205    }
206}
207
208/// Map a MySQL column type name to a Mongrel type (best-effort matrix).
209pub fn map_mysql_type(mysql_type: &str) -> TypeMapping {
210    let upper = mysql_type.trim().to_ascii_uppercase();
211    let (mongrel, lossy, notes) = match upper.as_str() {
212        "TINYINT(1)" | "BOOL" | "BOOLEAN" => ("Bool", false, ""),
213        "TINYINT" => ("Int8", false, ""),
214        "TINYINT UNSIGNED" => ("UInt8", false, ""),
215        "SMALLINT" => ("Int16", false, ""),
216        "SMALLINT UNSIGNED" => ("UInt16", false, ""),
217        "INT" | "INTEGER" | "MEDIUMINT" => ("Int32", false, ""),
218        "INT UNSIGNED" | "INTEGER UNSIGNED" | "MEDIUMINT UNSIGNED" => ("UInt32", false, ""),
219        "BIGINT" => ("Int64", false, ""),
220        "BIGINT UNSIGNED" => ("UInt64", false, ""),
221        "FLOAT" => ("Float32", false, ""),
222        "DOUBLE" | "DOUBLE PRECISION" | "REAL" => ("Float64", false, ""),
223        t if t.starts_with("DECIMAL") || t.starts_with("NUMERIC") => {
224            ("Decimal", true, "precision/scale preserved when declared")
225        }
226        t if t.starts_with("VARCHAR")
227            || t.starts_with("CHAR")
228            || t == "TEXT"
229            || t == "TINYTEXT"
230            || t == "MEDIUMTEXT"
231            || t == "LONGTEXT" =>
232        {
233            ("Utf8", false, "")
234        }
235        t if t.starts_with("VARBINARY")
236            || t.starts_with("BINARY")
237            || t == "BLOB"
238            || t == "TINYBLOB"
239            || t == "MEDIUMBLOB"
240            || t == "LONGBLOB" =>
241        {
242            ("Bytes", false, "")
243        }
244        "JSON" => ("Json", false, ""),
245        "DATE" => ("Date", false, ""),
246        "DATETIME" | "TIMESTAMP" => ("Timestamp", true, "timezone semantics differ"),
247        "TIME" => ("Time", false, ""),
248        other => ("Unsupported", true, other),
249    };
250    TypeMapping {
251        mysql_type: mysql_type.into(),
252        mongrel_type: mongrel.into(),
253        lossy,
254        notes: notes.into(),
255    }
256}
257
258/// Build a migration plan from an introspected schema description.
259///
260/// `tables` maps table name → list of `(column_name, mysql_type)`.
261pub fn plan_mysql_migration(
262    source_display: impl Into<String>,
263    target: impl Into<String>,
264    schema_only: bool,
265    tables: &BTreeMap<String, Vec<(String, String)>>,
266) -> MysqlMigratePlan {
267    let mut table_plans = Vec::new();
268    for (name, cols) in tables {
269        let columns: Vec<TypeMapping> = cols.iter().map(|(_, ty)| map_mysql_type(ty)).collect();
270        let incompatibilities: Vec<String> = columns
271            .iter()
272            .filter(|c| c.mongrel_type == "Unsupported")
273            .map(|c| format!("column type {} unsupported", c.mysql_type))
274            .collect();
275        let col_ddl: Vec<String> = cols
276            .iter()
277            .zip(columns.iter())
278            .map(|((cname, _), mapping)| format!("  {} {}", cname, mapping.mongrel_type))
279            .collect();
280        let ddl = format!("CREATE TABLE {} (\n{}\n);", name, col_ddl.join(",\n"));
281        // Recommend first integer PK-like column as partition key when present.
282        let recommended_partition_keys = cols
283            .iter()
284            .find(|(_, ty)| {
285                let u = ty.to_ascii_uppercase();
286                u.contains("INT") && !u.contains("POINT")
287            })
288            .map(|(c, _)| vec![c.clone()])
289            .unwrap_or_default();
290        table_plans.push(MigrateTablePlan {
291            source_table: name.clone(),
292            target_table: name.clone(),
293            columns,
294            recommended_partition_keys,
295            ddl,
296            incompatibilities,
297        });
298    }
299    let stages = if schema_only {
300        MigrateStage::PIPELINE
301            .into_iter()
302            .filter(|s| {
303                !matches!(
304                    s,
305                    MigrateStage::BoundedCopy
306                        | MigrateStage::Validate
307                        | MigrateStage::CdcCatchUp
308                        | MigrateStage::Cutover
309                        | MigrateStage::RollbackWindow
310                )
311            })
312            .collect()
313    } else {
314        MigrateStage::PIPELINE.to_vec()
315    };
316    MysqlMigratePlan {
317        source_display: source_display.into(),
318        target: target.into(),
319        schema_only,
320        tables: table_plans,
321        stages,
322    }
323}
324
325/// Wire-adapter request kinds mapped into the canonical protocol model.
326///
327/// The adapter never implements its own transaction state machine.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub enum MysqlWireRequest {
330    /// COM_QUERY → ExecuteRequest SQL.
331    Query {
332        /// SQL text.
333        sql: String,
334    },
335    /// COM_STMT_PREPARE.
336    Prepare {
337        /// SQL text.
338        sql: String,
339    },
340    /// COM_STMT_EXECUTE.
341    ExecutePrepared {
342        /// Statement id.
343        statement_id: u32,
344        /// Bound parameter payloads (opaque).
345        params: Vec<Vec<u8>>,
346    },
347    /// BEGIN/COMMIT/ROLLBACK → session txn commands.
348    Transaction {
349        /// begin | commit | rollback.
350        verb: String,
351    },
352    /// COM_PROCESS_KILL / KILL QUERY.
353    Kill {
354        /// Target connection/query id.
355        target_id: u64,
356    },
357    /// Auth handshake result (SCRAM or mysql_native_password mapped).
358    Authenticate {
359        /// Username.
360        user: String,
361    },
362}
363
364/// Dual-write warning constant (spec §14.1).
365pub const DUAL_WRITE_WARNING: &str = "Do not recommend application-level dual writes without an \
366outbox/CDC design. Use MySQL binlog CDC for migration catch-up.";
367
368/// One source row as column name → string value (testable without a live MySQL).
369pub type SourceRow = BTreeMap<String, String>;
370
371/// Trait the migrate tool uses for source/target I/O. Production binds a
372/// MySQL client + Mongrel session; tests supply in-memory stores.
373pub trait MigrateIo {
374    /// Apply generated DDL on the target.
375    fn apply_ddl(&mut self, ddl: &str) -> Result<(), String>;
376    /// Copy up to `batch` rows starting at `offset` from `table`.
377    fn copy_batch(
378        &mut self,
379        table: &str,
380        offset: u64,
381        batch: u64,
382    ) -> Result<Vec<SourceRow>, String>;
383    /// Insert one row into the target table.
384    fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String>;
385    /// Count rows on source and target for validation.
386    fn count_rows(&self, table: &str) -> Result<(u64, u64), String>;
387    /// Row checksum (deterministic) for source and target.
388    fn checksum_rows(&self, table: &str) -> Result<(String, String), String>;
389    /// Apply one CDC event (insert/update/delete as row map + op).
390    fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String>;
391    /// Poll the source for more binlog events when lag remains after the
392    /// initial catch-up batch. Implementations must block/back off rather than
393    /// busy-spin and must honor `control`.
394    fn poll_cdc(
395        &mut self,
396        _control: &crate::ExecutionControl,
397    ) -> Result<Vec<(String, CdcOp, SourceRow)>, String> {
398        Err("CDC source cannot poll remaining binlog events".into())
399    }
400    /// Current CDC lag in events remaining (0 = caught up).
401    fn cdc_lag(&self) -> u64;
402}
403
404/// CDC operation kind from binlog.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
406pub enum CdcOp {
407    /// Insert.
408    Insert,
409    /// Update (row is post-image).
410    Update,
411    /// Delete.
412    Delete,
413}
414
415/// Result of running the migrate pipeline against a [`MigrateIo`].
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct MigrateRunReport {
418    /// Stages completed in order.
419    pub completed: Vec<MigrateStage>,
420    /// Rows copied per table.
421    pub rows_copied: BTreeMap<String, u64>,
422    /// Validation ok.
423    pub validated: bool,
424    /// CDC lag at cutover.
425    pub cdc_lag_at_cutover: u64,
426    /// Whether cutover was performed.
427    pub cut_over: bool,
428    /// Rollback window still open.
429    pub rollback_window_open: bool,
430}
431
432/// Default bounded copy batch size.
433pub const DEFAULT_COPY_BATCH: u64 = 1_000;
434
435/// Run the full migrate pipeline (or schema-only subset) against `io`.
436///
437/// Stages: map types already in `plan` → apply DDL → bounded copy → validate
438/// counts/checksums → CDC catch-up until lag 0 → cutover → open rollback window.
439/// Does **not** dual-write; CDC is the catch-up path (see [`DUAL_WRITE_WARNING`]).
440pub fn run_migrate_pipeline(
441    plan: &MysqlMigratePlan,
442    io: &mut dyn MigrateIo,
443    copy_batch: u64,
444    cdc_events: &[(String, CdcOp, SourceRow)],
445) -> Result<MigrateRunReport, String> {
446    run_migrate_pipeline_controlled(
447        plan,
448        io,
449        copy_batch,
450        cdc_events,
451        &crate::ExecutionControl::new(None),
452    )
453}
454
455pub fn run_migrate_pipeline_controlled(
456    plan: &MysqlMigratePlan,
457    io: &mut dyn MigrateIo,
458    copy_batch: u64,
459    cdc_events: &[(String, CdcOp, SourceRow)],
460    control: &crate::ExecutionControl,
461) -> Result<MigrateRunReport, String> {
462    let batch = copy_batch.max(1);
463    let mut completed = Vec::new();
464    let mut rows_copied: BTreeMap<String, u64> = BTreeMap::new();
465
466    // Introspect/Map/Recommend already done when building the plan.
467    completed.push(MigrateStage::Introspect);
468    completed.push(MigrateStage::MapTypes);
469    completed.push(MigrateStage::RecommendPartitioning);
470
471    for table in &plan.tables {
472        if !table.incompatibilities.is_empty() {
473            return Err(format!(
474                "table {} has incompatibilities: {:?}",
475                table.source_table, table.incompatibilities
476            ));
477        }
478        io.apply_ddl(&table.ddl)?;
479    }
480    completed.push(MigrateStage::GenerateDdl);
481
482    if plan.schema_only {
483        return Ok(MigrateRunReport {
484            completed,
485            rows_copied,
486            validated: true,
487            cdc_lag_at_cutover: 0,
488            cut_over: false,
489            rollback_window_open: false,
490        });
491    }
492
493    for table in &plan.tables {
494        let mut offset = 0u64;
495        let mut total = 0u64;
496        loop {
497            let rows = io.copy_batch(&table.source_table, offset, batch)?;
498            if rows.is_empty() {
499                break;
500            }
501            for row in &rows {
502                io.insert_row(&table.target_table, row)?;
503            }
504            let n = rows.len() as u64;
505            total += n;
506            offset += n;
507            if n < batch {
508                break;
509            }
510        }
511        rows_copied.insert(table.source_table.clone(), total);
512    }
513    completed.push(MigrateStage::BoundedCopy);
514
515    for table in &plan.tables {
516        let (src, dst) = io.count_rows(&table.source_table)?;
517        if src != dst {
518            return Err(format!(
519                "row count mismatch on {}: source={src} target={dst}",
520                table.source_table
521            ));
522        }
523        let (cs, cd) = io.checksum_rows(&table.source_table)?;
524        if cs != cd {
525            return Err(format!(
526                "checksum mismatch on {}: source={cs} target={cd}",
527                table.source_table
528            ));
529        }
530    }
531    completed.push(MigrateStage::Validate);
532
533    for (table, op, row) in cdc_events {
534        control.checkpoint().map_err(|error| error.to_string())?;
535        io.apply_cdc(table, *op, row)?;
536    }
537    // Drain remaining lag by polling real source progress. Positive lag with
538    // no events or no decreasing watermark is a hard error, never a CPU spin.
539    while io.cdc_lag() > 0 {
540        control.checkpoint().map_err(|error| error.to_string())?;
541        let before = io.cdc_lag();
542        let events = io.poll_cdc(control)?;
543        if events.is_empty() {
544            return Err(format!(
545                "CDC source made no progress while lag remained {before}"
546            ));
547        }
548        for (table, op, row) in events {
549            control.checkpoint().map_err(|error| error.to_string())?;
550            io.apply_cdc(&table, op, &row)?;
551        }
552        let after = io.cdc_lag();
553        if after >= before {
554            return Err(format!(
555                "CDC source watermark did not advance: before={before} after={after}"
556            ));
557        }
558    }
559    completed.push(MigrateStage::CdcCatchUp);
560
561    let lag = io.cdc_lag();
562    if lag != 0 {
563        return Err(format!("refusing cutover with cdc lag {lag}"));
564    }
565    completed.push(MigrateStage::Cutover);
566    completed.push(MigrateStage::RollbackWindow);
567
568    Ok(MigrateRunReport {
569        completed,
570        rows_copied,
571        validated: true,
572        cdc_lag_at_cutover: lag,
573        cut_over: true,
574        rollback_window_open: true,
575    })
576}
577
578/// In-memory migrate I/O for tests (source rows + target store).
579#[derive(Debug, Default)]
580pub struct MemoryMigrateIo {
581    /// Source table → rows.
582    pub source: BTreeMap<String, Vec<SourceRow>>,
583    /// Target table → rows.
584    pub target: BTreeMap<String, Vec<SourceRow>>,
585    /// Applied DDL statements.
586    pub ddl: Vec<String>,
587    /// Pending CDC lag counter (decremented by apply_cdc or set by tests).
588    pub lag: u64,
589    /// Events returned by subsequent source polls.
590    pub cdc_queue: Vec<(String, CdcOp, SourceRow)>,
591}
592
593impl MigrateIo for MemoryMigrateIo {
594    fn apply_ddl(&mut self, ddl: &str) -> Result<(), String> {
595        self.ddl.push(ddl.to_owned());
596        Ok(())
597    }
598
599    fn copy_batch(
600        &mut self,
601        table: &str,
602        offset: u64,
603        batch: u64,
604    ) -> Result<Vec<SourceRow>, String> {
605        let rows = self.source.get(table).cloned().unwrap_or_default();
606        let start = offset as usize;
607        if start >= rows.len() {
608            return Ok(Vec::new());
609        }
610        let end = (start + batch as usize).min(rows.len());
611        Ok(rows[start..end].to_vec())
612    }
613
614    fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String> {
615        self.target
616            .entry(table.to_owned())
617            .or_default()
618            .push(row.clone());
619        Ok(())
620    }
621
622    fn count_rows(&self, table: &str) -> Result<(u64, u64), String> {
623        let s = self.source.get(table).map(|r| r.len() as u64).unwrap_or(0);
624        let t = self.target.get(table).map(|r| r.len() as u64).unwrap_or(0);
625        Ok((s, t))
626    }
627
628    fn checksum_rows(&self, table: &str) -> Result<(String, String), String> {
629        fn sum(rows: &[SourceRow]) -> String {
630            use sha2::{Digest, Sha256};
631            let mut h = Sha256::new();
632            for row in rows {
633                for (k, v) in row {
634                    h.update(k.as_bytes());
635                    h.update([0]);
636                    h.update(v.as_bytes());
637                    h.update([0]);
638                }
639                h.update([1]);
640            }
641            format!("{:x}", h.finalize())
642        }
643        let s = self.source.get(table).map(|r| sum(r)).unwrap_or_default();
644        let t = self.target.get(table).map(|r| sum(r)).unwrap_or_default();
645        Ok((s, t))
646    }
647
648    fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String> {
649        match op {
650            CdcOp::Insert | CdcOp::Update => {
651                // Upsert by first column if present.
652                let rows = self.target.entry(table.to_owned()).or_default();
653                if let Some(key) = row.keys().next().cloned() {
654                    if let Some(val) = row.get(&key) {
655                        if let Some(existing) = rows.iter_mut().find(|r| r.get(&key) == Some(val)) {
656                            *existing = row.clone();
657                        } else {
658                            rows.push(row.clone());
659                        }
660                    } else {
661                        rows.push(row.clone());
662                    }
663                } else {
664                    rows.push(row.clone());
665                }
666            }
667            CdcOp::Delete => {
668                if let Some((key, val)) = row.iter().next() {
669                    if let Some(rows) = self.target.get_mut(table) {
670                        rows.retain(|r| r.get(key) != Some(val));
671                    }
672                }
673            }
674        }
675        self.lag = self.lag.saturating_sub(1);
676        Ok(())
677    }
678
679    fn cdc_lag(&self) -> u64 {
680        self.lag
681    }
682
683    fn poll_cdc(
684        &mut self,
685        control: &crate::ExecutionControl,
686    ) -> Result<Vec<(String, CdcOp, SourceRow)>, String> {
687        control.checkpoint().map_err(|error| error.to_string())?;
688        Ok(std::mem::take(&mut self.cdc_queue))
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn maps_common_types() {
698        assert_eq!(map_mysql_type("BIGINT").mongrel_type, "Int64");
699        assert_eq!(map_mysql_type("TINYINT").mongrel_type, "Int8");
700        assert_eq!(map_mysql_type("INT UNSIGNED").mongrel_type, "UInt32");
701        assert_eq!(map_mysql_type("VARCHAR(255)").mongrel_type, "Utf8");
702        assert_eq!(map_mysql_type("JSON").mongrel_type, "Json");
703        assert!(map_mysql_type("GEOMETRY").mongrel_type == "Unsupported");
704    }
705
706    #[test]
707    fn plan_pipeline_includes_cdc_not_dual_write() {
708        let mut tables = BTreeMap::new();
709        tables.insert(
710            "orders".into(),
711            vec![
712                ("id".into(), "BIGINT".into()),
713                ("note".into(), "TEXT".into()),
714            ],
715        );
716        let plan = plan_mysql_migration("mysql://***@host/db", "mongrel://local", false, &tables);
717        assert!(plan.stages.contains(&MigrateStage::CdcCatchUp));
718        assert!(plan.stages.contains(&MigrateStage::BoundedCopy));
719        assert_eq!(plan.tables.len(), 1);
720        assert!(plan.tables[0].ddl.contains("CREATE TABLE orders"));
721        assert_eq!(
722            plan.tables[0].recommended_partition_keys,
723            vec!["id".to_string()]
724        );
725        assert!(DUAL_WRITE_WARNING.contains("binlog CDC"));
726    }
727
728    #[test]
729    fn run_pipeline_copy_validate_cdc_cutover() {
730        let mut tables = BTreeMap::new();
731        tables.insert(
732            "orders".into(),
733            vec![
734                ("id".into(), "BIGINT".into()),
735                ("note".into(), "TEXT".into()),
736            ],
737        );
738        let plan = plan_mysql_migration("src", "dst", false, &tables);
739        let mut io = MemoryMigrateIo::default();
740        for i in 0..5u64 {
741            let mut row = SourceRow::new();
742            row.insert("id".into(), i.to_string());
743            row.insert("note".into(), format!("n{i}"));
744            io.source.entry("orders".into()).or_default().push(row);
745        }
746        // One insert that arrived after snapshot start.
747        let mut cdc_row = SourceRow::new();
748        cdc_row.insert("id".into(), "99".into());
749        cdc_row.insert("note".into(), "late".into());
750        io.lag = 1;
751        let report = run_migrate_pipeline(
752            &plan,
753            &mut io,
754            2, // small batches force multi-batch copy
755            &[("orders".into(), CdcOp::Insert, cdc_row)],
756        )
757        .unwrap();
758        assert!(report.cut_over);
759        assert!(report.validated);
760        assert!(report.rollback_window_open);
761        assert_eq!(report.rows_copied.get("orders"), Some(&5));
762        assert!(report.completed.contains(&MigrateStage::BoundedCopy));
763        assert!(report.completed.contains(&MigrateStage::Validate));
764        assert!(report.completed.contains(&MigrateStage::CdcCatchUp));
765        assert!(report.completed.contains(&MigrateStage::Cutover));
766        // Target has 5 copied + 1 CDC.
767        assert_eq!(io.target.get("orders").map(|r| r.len()), Some(6));
768        assert!(!io.ddl.is_empty());
769    }
770
771    #[test]
772    fn dialect_matrix_covers_spec_list() {
773        let m = dialect_matrix();
774        for key in [
775            "AUTO_INCREMENT",
776            "ON DUPLICATE KEY UPDATE",
777            "JSON",
778            "isolation levels",
779            "information_schema",
780        ] {
781            assert!(m.iter().any(|f| f.feature == key), "missing {key}");
782        }
783    }
784
785    #[test]
786    fn schema_only_skips_data_stages() {
787        let plan = plan_mysql_migration("src", "dst", true, &BTreeMap::new());
788        assert!(!plan.stages.iter().any(|s| matches!(
789            s,
790            MigrateStage::BoundedCopy | MigrateStage::CdcCatchUp | MigrateStage::Cutover
791        )));
792    }
793
794    #[test]
795    fn positive_cdc_lag_polls_events_instead_of_spinning() {
796        let plan = MysqlMigratePlan {
797            source_display: "src".into(),
798            target: "dst".into(),
799            schema_only: false,
800            tables: Vec::new(),
801            stages: MigrateStage::PIPELINE.to_vec(),
802        };
803        let mut row = SourceRow::new();
804        row.insert("id".into(), "1".into());
805        let mut io = MemoryMigrateIo {
806            lag: 1,
807            cdc_queue: vec![("orders".into(), CdcOp::Insert, row)],
808            ..MemoryMigrateIo::default()
809        };
810        let report = run_migrate_pipeline(&plan, &mut io, 10, &[]).unwrap();
811        assert!(report.cut_over);
812        assert_eq!(io.cdc_lag(), 0);
813    }
814
815    #[test]
816    fn positive_cdc_lag_without_events_fails_closed() {
817        let plan = MysqlMigratePlan {
818            source_display: "src".into(),
819            target: "dst".into(),
820            schema_only: false,
821            tables: Vec::new(),
822            stages: MigrateStage::PIPELINE.to_vec(),
823        };
824        let mut io = MemoryMigrateIo {
825            lag: 1,
826            ..MemoryMigrateIo::default()
827        };
828        assert_eq!(
829            run_migrate_pipeline(&plan, &mut io, 10, &[]).unwrap_err(),
830            "CDC source made no progress while lag remained 1"
831        );
832    }
833}