1use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TypeMapping {
21 pub mysql_type: String,
23 pub mongrel_type: String,
25 pub lossy: bool,
27 pub notes: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DialectFeature {
34 pub feature: String,
36 pub support: DialectSupport,
38 pub detail: String,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum DialectSupport {
45 Full,
47 Partial,
49 Unsupported,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct MigrateTablePlan {
56 pub source_table: String,
58 pub target_table: String,
60 pub columns: Vec<TypeMapping>,
62 pub recommended_partition_keys: Vec<String>,
64 pub ddl: String,
66 pub incompatibilities: Vec<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct MysqlMigratePlan {
73 pub source_display: String,
75 pub target: String,
77 pub schema_only: bool,
79 pub tables: Vec<MigrateTablePlan>,
81 pub stages: Vec<MigrateStage>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87pub enum MigrateStage {
88 Introspect,
90 MapTypes,
92 RecommendPartitioning,
94 GenerateDdl,
96 BoundedCopy,
98 Validate,
100 CdcCatchUp,
102 Cutover,
104 RollbackWindow,
106}
107
108impl MigrateStage {
109 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 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
138pub 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
208pub 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
258pub 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub enum MysqlWireRequest {
330 Query {
332 sql: String,
334 },
335 Prepare {
337 sql: String,
339 },
340 ExecutePrepared {
342 statement_id: u32,
344 params: Vec<Vec<u8>>,
346 },
347 Transaction {
349 verb: String,
351 },
352 Kill {
354 target_id: u64,
356 },
357 Authenticate {
359 user: String,
361 },
362}
363
364pub 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
368pub type SourceRow = BTreeMap<String, String>;
370
371pub trait MigrateIo {
374 fn apply_ddl(&mut self, ddl: &str) -> Result<(), String>;
376 fn copy_batch(
378 &mut self,
379 table: &str,
380 offset: u64,
381 batch: u64,
382 ) -> Result<Vec<SourceRow>, String>;
383 fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String>;
385 fn count_rows(&self, table: &str) -> Result<(u64, u64), String>;
387 fn checksum_rows(&self, table: &str) -> Result<(String, String), String>;
389 fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String>;
391 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 fn cdc_lag(&self) -> u64;
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
406pub enum CdcOp {
407 Insert,
409 Update,
411 Delete,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct MigrateRunReport {
418 pub completed: Vec<MigrateStage>,
420 pub rows_copied: BTreeMap<String, u64>,
422 pub validated: bool,
424 pub cdc_lag_at_cutover: u64,
426 pub cut_over: bool,
428 pub rollback_window_open: bool,
430}
431
432pub const DEFAULT_COPY_BATCH: u64 = 1_000;
434
435pub 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 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 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#[derive(Debug, Default)]
580pub struct MemoryMigrateIo {
581 pub source: BTreeMap<String, Vec<SourceRow>>,
583 pub target: BTreeMap<String, Vec<SourceRow>>,
585 pub ddl: Vec<String>,
587 pub lag: u64,
589 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 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 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, &[("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 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}