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" | "TINYINT(1)" | "BOOL" | "BOOLEAN" => ("Bool", false, ""),
213 "SMALLINT" => ("Int16", false, ""),
214 "INT" | "INTEGER" | "MEDIUMINT" => ("Int32", false, ""),
215 "BIGINT" => ("Int64", false, ""),
216 "BIGINT UNSIGNED" => ("UInt64", false, ""),
217 "FLOAT" => ("Float32", false, ""),
218 "DOUBLE" | "DOUBLE PRECISION" | "REAL" => ("Float64", false, ""),
219 t if t.starts_with("DECIMAL") || t.starts_with("NUMERIC") => {
220 ("Decimal", true, "precision/scale preserved when declared")
221 }
222 t if t.starts_with("VARCHAR")
223 || t.starts_with("CHAR")
224 || t == "TEXT"
225 || t == "TINYTEXT"
226 || t == "MEDIUMTEXT"
227 || t == "LONGTEXT" =>
228 {
229 ("Utf8", false, "")
230 }
231 t if t.starts_with("VARBINARY")
232 || t.starts_with("BINARY")
233 || t == "BLOB"
234 || t == "TINYBLOB"
235 || t == "MEDIUMBLOB"
236 || t == "LONGBLOB" =>
237 {
238 ("Bytes", false, "")
239 }
240 "JSON" => ("Json", false, ""),
241 "DATE" => ("Date", false, ""),
242 "DATETIME" | "TIMESTAMP" => ("Timestamp", true, "timezone semantics differ"),
243 "TIME" => ("Time", false, ""),
244 other => ("Unsupported", true, other),
245 };
246 TypeMapping {
247 mysql_type: mysql_type.into(),
248 mongrel_type: mongrel.into(),
249 lossy,
250 notes: notes.into(),
251 }
252}
253
254pub fn plan_mysql_migration(
258 source_display: impl Into<String>,
259 target: impl Into<String>,
260 schema_only: bool,
261 tables: &BTreeMap<String, Vec<(String, String)>>,
262) -> MysqlMigratePlan {
263 let mut table_plans = Vec::new();
264 for (name, cols) in tables {
265 let columns: Vec<TypeMapping> = cols.iter().map(|(_, ty)| map_mysql_type(ty)).collect();
266 let incompatibilities: Vec<String> = columns
267 .iter()
268 .filter(|c| c.mongrel_type == "Unsupported")
269 .map(|c| format!("column type {} unsupported", c.mysql_type))
270 .collect();
271 let col_ddl: Vec<String> = cols
272 .iter()
273 .zip(columns.iter())
274 .map(|((cname, _), mapping)| format!(" {} {}", cname, mapping.mongrel_type))
275 .collect();
276 let ddl = format!("CREATE TABLE {} (\n{}\n);", name, col_ddl.join(",\n"));
277 let recommended_partition_keys = cols
279 .iter()
280 .find(|(_, ty)| {
281 let u = ty.to_ascii_uppercase();
282 u.contains("INT") && !u.contains("POINT")
283 })
284 .map(|(c, _)| vec![c.clone()])
285 .unwrap_or_default();
286 table_plans.push(MigrateTablePlan {
287 source_table: name.clone(),
288 target_table: name.clone(),
289 columns,
290 recommended_partition_keys,
291 ddl,
292 incompatibilities,
293 });
294 }
295 let stages = if schema_only {
296 MigrateStage::PIPELINE
297 .into_iter()
298 .filter(|s| {
299 !matches!(
300 s,
301 MigrateStage::BoundedCopy
302 | MigrateStage::Validate
303 | MigrateStage::CdcCatchUp
304 | MigrateStage::Cutover
305 | MigrateStage::RollbackWindow
306 )
307 })
308 .collect()
309 } else {
310 MigrateStage::PIPELINE.to_vec()
311 };
312 MysqlMigratePlan {
313 source_display: source_display.into(),
314 target: target.into(),
315 schema_only,
316 tables: table_plans,
317 stages,
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub enum MysqlWireRequest {
326 Query {
328 sql: String,
330 },
331 Prepare {
333 sql: String,
335 },
336 ExecutePrepared {
338 statement_id: u32,
340 params: Vec<Vec<u8>>,
342 },
343 Transaction {
345 verb: String,
347 },
348 Kill {
350 target_id: u64,
352 },
353 Authenticate {
355 user: String,
357 },
358}
359
360pub const DUAL_WRITE_WARNING: &str = "Do not recommend application-level dual writes without an \
362outbox/CDC design. Use MySQL binlog CDC for migration catch-up.";
363
364pub type SourceRow = BTreeMap<String, String>;
366
367pub trait MigrateIo {
370 fn apply_ddl(&mut self, ddl: &str) -> Result<(), String>;
372 fn copy_batch(
374 &mut self,
375 table: &str,
376 offset: u64,
377 batch: u64,
378 ) -> Result<Vec<SourceRow>, String>;
379 fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String>;
381 fn count_rows(&self, table: &str) -> Result<(u64, u64), String>;
383 fn checksum_rows(&self, table: &str) -> Result<(String, String), String>;
385 fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String>;
387 fn cdc_lag(&self) -> u64;
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393pub enum CdcOp {
394 Insert,
396 Update,
398 Delete,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct MigrateRunReport {
405 pub completed: Vec<MigrateStage>,
407 pub rows_copied: BTreeMap<String, u64>,
409 pub validated: bool,
411 pub cdc_lag_at_cutover: u64,
413 pub cut_over: bool,
415 pub rollback_window_open: bool,
417}
418
419pub const DEFAULT_COPY_BATCH: u64 = 1_000;
421
422pub fn run_migrate_pipeline(
428 plan: &MysqlMigratePlan,
429 io: &mut dyn MigrateIo,
430 copy_batch: u64,
431 cdc_events: &[(String, CdcOp, SourceRow)],
432) -> Result<MigrateRunReport, String> {
433 let batch = copy_batch.max(1);
434 let mut completed = Vec::new();
435 let mut rows_copied: BTreeMap<String, u64> = BTreeMap::new();
436
437 completed.push(MigrateStage::Introspect);
439 completed.push(MigrateStage::MapTypes);
440 completed.push(MigrateStage::RecommendPartitioning);
441
442 for table in &plan.tables {
443 if !table.incompatibilities.is_empty() {
444 return Err(format!(
445 "table {} has incompatibilities: {:?}",
446 table.source_table, table.incompatibilities
447 ));
448 }
449 io.apply_ddl(&table.ddl)?;
450 }
451 completed.push(MigrateStage::GenerateDdl);
452
453 if plan.schema_only {
454 return Ok(MigrateRunReport {
455 completed,
456 rows_copied,
457 validated: true,
458 cdc_lag_at_cutover: 0,
459 cut_over: false,
460 rollback_window_open: false,
461 });
462 }
463
464 for table in &plan.tables {
465 let mut offset = 0u64;
466 let mut total = 0u64;
467 loop {
468 let rows = io.copy_batch(&table.source_table, offset, batch)?;
469 if rows.is_empty() {
470 break;
471 }
472 for row in &rows {
473 io.insert_row(&table.target_table, row)?;
474 }
475 let n = rows.len() as u64;
476 total += n;
477 offset += n;
478 if n < batch {
479 break;
480 }
481 }
482 rows_copied.insert(table.source_table.clone(), total);
483 }
484 completed.push(MigrateStage::BoundedCopy);
485
486 for table in &plan.tables {
487 let (src, dst) = io.count_rows(&table.source_table)?;
488 if src != dst {
489 return Err(format!(
490 "row count mismatch on {}: source={src} target={dst}",
491 table.source_table
492 ));
493 }
494 let (cs, cd) = io.checksum_rows(&table.source_table)?;
495 if cs != cd {
496 return Err(format!(
497 "checksum mismatch on {}: source={cs} target={cd}",
498 table.source_table
499 ));
500 }
501 }
502 completed.push(MigrateStage::Validate);
503
504 for (table, op, row) in cdc_events {
505 io.apply_cdc(table, *op, row)?;
506 }
507 let mut spins = 0u32;
509 while io.cdc_lag() > 0 {
510 spins += 1;
511 if spins > 10_000 {
512 return Err(format!("CDC lag did not reach zero (lag={})", io.cdc_lag()));
513 }
514 }
515 completed.push(MigrateStage::CdcCatchUp);
516
517 let lag = io.cdc_lag();
518 if lag != 0 {
519 return Err(format!("refusing cutover with cdc lag {lag}"));
520 }
521 completed.push(MigrateStage::Cutover);
522 completed.push(MigrateStage::RollbackWindow);
523
524 Ok(MigrateRunReport {
525 completed,
526 rows_copied,
527 validated: true,
528 cdc_lag_at_cutover: lag,
529 cut_over: true,
530 rollback_window_open: true,
531 })
532}
533
534#[derive(Debug, Default)]
536pub struct MemoryMigrateIo {
537 pub source: BTreeMap<String, Vec<SourceRow>>,
539 pub target: BTreeMap<String, Vec<SourceRow>>,
541 pub ddl: Vec<String>,
543 pub lag: u64,
545}
546
547impl MigrateIo for MemoryMigrateIo {
548 fn apply_ddl(&mut self, ddl: &str) -> Result<(), String> {
549 self.ddl.push(ddl.to_owned());
550 Ok(())
551 }
552
553 fn copy_batch(
554 &mut self,
555 table: &str,
556 offset: u64,
557 batch: u64,
558 ) -> Result<Vec<SourceRow>, String> {
559 let rows = self.source.get(table).cloned().unwrap_or_default();
560 let start = offset as usize;
561 if start >= rows.len() {
562 return Ok(Vec::new());
563 }
564 let end = (start + batch as usize).min(rows.len());
565 Ok(rows[start..end].to_vec())
566 }
567
568 fn insert_row(&mut self, table: &str, row: &SourceRow) -> Result<(), String> {
569 self.target
570 .entry(table.to_owned())
571 .or_default()
572 .push(row.clone());
573 Ok(())
574 }
575
576 fn count_rows(&self, table: &str) -> Result<(u64, u64), String> {
577 let s = self.source.get(table).map(|r| r.len() as u64).unwrap_or(0);
578 let t = self.target.get(table).map(|r| r.len() as u64).unwrap_or(0);
579 Ok((s, t))
580 }
581
582 fn checksum_rows(&self, table: &str) -> Result<(String, String), String> {
583 fn sum(rows: &[SourceRow]) -> String {
584 use sha2::{Digest, Sha256};
585 let mut h = Sha256::new();
586 for row in rows {
587 for (k, v) in row {
588 h.update(k.as_bytes());
589 h.update([0]);
590 h.update(v.as_bytes());
591 h.update([0]);
592 }
593 h.update([1]);
594 }
595 format!("{:x}", h.finalize())
596 }
597 let s = self.source.get(table).map(|r| sum(r)).unwrap_or_default();
598 let t = self.target.get(table).map(|r| sum(r)).unwrap_or_default();
599 Ok((s, t))
600 }
601
602 fn apply_cdc(&mut self, table: &str, op: CdcOp, row: &SourceRow) -> Result<(), String> {
603 match op {
604 CdcOp::Insert | CdcOp::Update => {
605 let rows = self.target.entry(table.to_owned()).or_default();
607 if let Some(key) = row.keys().next().cloned() {
608 if let Some(val) = row.get(&key) {
609 if let Some(existing) = rows.iter_mut().find(|r| r.get(&key) == Some(val)) {
610 *existing = row.clone();
611 } else {
612 rows.push(row.clone());
613 }
614 } else {
615 rows.push(row.clone());
616 }
617 } else {
618 rows.push(row.clone());
619 }
620 }
621 CdcOp::Delete => {
622 if let Some((key, val)) = row.iter().next() {
623 if let Some(rows) = self.target.get_mut(table) {
624 rows.retain(|r| r.get(key) != Some(val));
625 }
626 }
627 }
628 }
629 self.lag = self.lag.saturating_sub(1);
630 Ok(())
631 }
632
633 fn cdc_lag(&self) -> u64 {
634 self.lag
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn maps_common_types() {
644 assert_eq!(map_mysql_type("BIGINT").mongrel_type, "Int64");
645 assert_eq!(map_mysql_type("VARCHAR(255)").mongrel_type, "Utf8");
646 assert_eq!(map_mysql_type("JSON").mongrel_type, "Json");
647 assert!(map_mysql_type("GEOMETRY").mongrel_type == "Unsupported");
648 }
649
650 #[test]
651 fn plan_pipeline_includes_cdc_not_dual_write() {
652 let mut tables = BTreeMap::new();
653 tables.insert(
654 "orders".into(),
655 vec![
656 ("id".into(), "BIGINT".into()),
657 ("note".into(), "TEXT".into()),
658 ],
659 );
660 let plan = plan_mysql_migration("mysql://***@host/db", "mongrel://local", false, &tables);
661 assert!(plan.stages.contains(&MigrateStage::CdcCatchUp));
662 assert!(plan.stages.contains(&MigrateStage::BoundedCopy));
663 assert_eq!(plan.tables.len(), 1);
664 assert!(plan.tables[0].ddl.contains("CREATE TABLE orders"));
665 assert_eq!(
666 plan.tables[0].recommended_partition_keys,
667 vec!["id".to_string()]
668 );
669 assert!(DUAL_WRITE_WARNING.contains("binlog CDC"));
670 }
671
672 #[test]
673 fn run_pipeline_copy_validate_cdc_cutover() {
674 let mut tables = BTreeMap::new();
675 tables.insert(
676 "orders".into(),
677 vec![
678 ("id".into(), "BIGINT".into()),
679 ("note".into(), "TEXT".into()),
680 ],
681 );
682 let plan = plan_mysql_migration("src", "dst", false, &tables);
683 let mut io = MemoryMigrateIo::default();
684 for i in 0..5u64 {
685 let mut row = SourceRow::new();
686 row.insert("id".into(), i.to_string());
687 row.insert("note".into(), format!("n{i}"));
688 io.source.entry("orders".into()).or_default().push(row);
689 }
690 let mut cdc_row = SourceRow::new();
692 cdc_row.insert("id".into(), "99".into());
693 cdc_row.insert("note".into(), "late".into());
694 io.lag = 1;
695 let report = run_migrate_pipeline(
696 &plan,
697 &mut io,
698 2, &[("orders".into(), CdcOp::Insert, cdc_row)],
700 )
701 .unwrap();
702 assert!(report.cut_over);
703 assert!(report.validated);
704 assert!(report.rollback_window_open);
705 assert_eq!(report.rows_copied.get("orders"), Some(&5));
706 assert!(report.completed.contains(&MigrateStage::BoundedCopy));
707 assert!(report.completed.contains(&MigrateStage::Validate));
708 assert!(report.completed.contains(&MigrateStage::CdcCatchUp));
709 assert!(report.completed.contains(&MigrateStage::Cutover));
710 assert_eq!(io.target.get("orders").map(|r| r.len()), Some(6));
712 assert!(!io.ddl.is_empty());
713 }
714
715 #[test]
716 fn dialect_matrix_covers_spec_list() {
717 let m = dialect_matrix();
718 for key in [
719 "AUTO_INCREMENT",
720 "ON DUPLICATE KEY UPDATE",
721 "JSON",
722 "isolation levels",
723 "information_schema",
724 ] {
725 assert!(m.iter().any(|f| f.feature == key), "missing {key}");
726 }
727 }
728
729 #[test]
730 fn schema_only_skips_data_stages() {
731 let plan = plan_mysql_migration("src", "dst", true, &BTreeMap::new());
732 assert!(!plan.stages.iter().any(|s| matches!(
733 s,
734 MigrateStage::BoundedCopy | MigrateStage::CdcCatchUp | MigrateStage::Cutover
735 )));
736 }
737}