Database Migration: Diff's MySQL → New Managed MySQL 8.0¶
How we move OMS's data from Diff's MySQL 8.0 onto the new managed MySQL 8.0 (Azure Database for MySQL Flexible Server, or AWS RDS depending on cloud target).
Production facts (verified)¶
These were captured directly against the running production DB on 2026-05-18.
| Engine | MySQL 8.0.44 |
| Database name | popsocket |
| Total size | 279 GB (185 GB after LHM ghost cleanup) |
| Tables | 137 active + 5 ghost = 142 |
| Charset / collation | utf8mb4 / utf8mb4_0900_ai_ci |
| sql_mode | NO_AUTO_VALUE_ON_ZERO, STRICT_ALL_TABLES, NO_ENGINE_SUBSTITUTION |
| Time zone | UTC |
| binlog_format | MIXED (consider switching to ROW pre-migration) |
| binlog retention | 30 days (excellent for replication setup) |
| max_connections | 2619 |
| innodb_buffer_pool_size | ~23 GB |
| Stored procedures | 0 |
| Triggers | 0 |
| Views | 0 |
Top 10 largest tables (production):
| Table | Size |
|---|---|
demandware_orders |
53.56 GB |
lhma_2025_12_02_..._demandware_orders (ghost) |
47.70 GB |
lhma_2025_12_02_..._shopify_app_admin_shopify_orders (ghost) |
46.04 GB |
shopify_app_admin_shopify_orders |
44.71 GB |
shopify_app_admin_shopify_line_items |
13.08 GB |
order_audits |
10.15 GB |
shipstation_orders |
9.52 GB |
images |
5.25 GB |
line_item_assets |
4.42 GB |
line_items |
3.89 GB |
What is and isn't a concern¶
This is a same-version migration (8.0 → 8.0). The "5.7 → 8.0 upgrade" risk we originally planned for does not exist. Specifically:
- No reserved-word collisions (same version on both sides)
- No sql_mode reconciliation (we'll match exactly)
- No charset migration (already on
utf8mb4) - No auth-plugin compatibility issues (same
caching_sha2_password) - No test-suite-against-8.0 pass needed for cutover
What remains:
- Data volume — 279 GB. Replication is the only realistic path.
- LHM ghost tables — 95 GB of dead data. See "Pre-migration cleanup" below.
- Two ~50 GB tables —
demandware_ordersandshopify_app_admin_shopify_ordersdominate transfer time. Plan around them.
Pre-migration cleanup (ask Diff first)¶
Diff did an LHM batch on 2025-12-02 and didn't drop the shadow tables.
lhma_2025_12_02_13_02_35_612_demandware_orders 47.70 GB
lhma_2025_12_02_13_35_52_814_shopify_app_admin_shopify_orders 46.04 GB
lhma_2025_12_02_14_19_07_713_marketplace_orders 1.37 GB
lhma_2025_12_02_15_25_47_807_amazon_orders 0.02 GB
lhma_2025_12_02_15_25_53_141_influencer_orders 0.00 GB
───────
95 GB
Action item: confirm with Diff that these are safe to drop. If yes, have them drop the tables before we start replication. The migration window then transfers 185 GB instead of 279 GB — a third less data.
Strategy: native MySQL binlog replication¶
This is the recommended approach for a 279 GB database. mysqldump and restore
alone would take many hours of downtime — unacceptable for OMS.
Azure has shifted away from Azure DMS for MySQL in favor of native replication; on AWS, AWS DMS is still recommended for MySQL → MySQL. Either way the shape is the same: seed with a consistent dump, then catch up via binlog replication, then flip traffic during a short cutover window.
┌─────────────────┐ ┌─────────────────────┐
│ Diff prod MySQL │ ───── binlog ──────► │ New managed MySQL │
│ (8.0.44) │ replication │ 8.0 (replica mode) │
└─────────────────┘ └─────────────────────┘
│
│ catches up over hours/days
▼
"lag = 0" (caught up)
│
cutover window ▼
1. Pause Sidekiq, put Rails in maintenance mode
2. Wait for replica to catch up final transactions (seconds)
3. Promote new DB to primary (stop replication)
4. Update DATABASE_URL Secret, roll Deployments
5. Resume traffic
│
▼
Total downtime: ~15 min
Sequence of work¶
Order matters. Don't skip steps.
Phase A — Pre-work (1 week before cutover)¶
- Get LHM ghost tables dropped (Diff action). Shrinks dataset to ~185 GB.
- Provision new managed MySQL 8.0 (Senith). Sizing:
- Engine: MySQL 8.0 (match prod's 8.0.44 or nearest available patch level)
- Instance class: comparable to current AWS instance. The current prod has 23 GB InnoDB buffer pool, so target ~32 GB RAM with
innodb_buffer_pool_size~24 GB. - Storage: 500 GB (185 GB current + headroom). Provisioned IOPS sized for write throughput.
- Multi-AZ for high availability.
character_set_server = utf8mb4,collation_server = utf8mb4_0900_ai_ci.sql_mode = NO_AUTO_VALUE_ON_ZERO,STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION(match prod).time_zone = +00:00(UTC).- TLS required on connections.
- Set up replication user on Diff prod (Diff action). Grant
REPLICATION SLAVE,REPLICATION CLIENT. - Confirm network path (Senith + Diff). Either: AWS public endpoint with TLS + IP allowlist, or VPC ↔ VNet peering. Public endpoint is sufficient for a 1–2 week migration window.
- Switch Diff's
binlog_formattoROW(Diff action, if currentlyMIXED). Required for reliable replication across platforms. - Confirm
binlog_expire_logs_seconds ≥ 604800(7 days, Diff action). Currently 30 days — plenty.
Phase B — Seed the new database¶
- Take a consistent dump from Diff prod (Senith):
Expected time: 2–4 hours for 185 GB. Compress as you go (
mysqldump --single-transaction --master-data=2 --gtid \ --set-gtid-purged=ON \ -h <diff-host> -u <user> -p \ popsocket > popsocket-seed.sql| gzip > popsocket-seed.sql.gz). - Record the binlog position from the dump's header. Looks like
CHANGE MASTER TO MASTER_LOG_FILE='...', MASTER_LOG_POS=...;. - Restore the dump on the new managed DB (Senith). Expected time: 4–8 hours. Use
mysql --init-command="SET FOREIGN_KEY_CHECKS=0;"for speed. - Verify row counts match for the top 10 largest tables (joint).
Phase C — Run replication and soak¶
- Configure new DB as a replica of Diff prod (Senith):
- Monitor
Seconds_Behind_Masterfor ~1 week. Should drop to 0 within 24 hours of starting replication, then stay near 0. - Set up a Datadog dashboard showing replication lag, replica error count, replica state. Watch daily.
Phase D — Pre-cutover validation (1 week before)¶
- Freeze schema migrations. No new files in
db/migrate/until cutover + 1 week after. - Spot-check data consistency between old and new (joint). Pick 5–10 known rows, compare on both sides.
- Run the Rails app in staging pointed at a replica (Rails team). Smoke test the critical paths.
- Communicate maintenance window to internal users + Shopify/SFCC partners (manager).
- Prepare the rollback
DATABASE_URLpointing back at Diff prod, ready to flip if cutover fails.
Phase E — Cutover (15-minute maintenance window)¶
Detailed timing:
| Time | Step | Owner |
|---|---|---|
| T+0 | Ingress returns 503 for all paths except /up |
Senith |
| T+1m | Scale Sidekiq Deployments to 0; wait for in-flight jobs to drain | Senith |
| T+3m | Confirm Redis queues are empty (LLEN queue:* = 0) |
Joint |
| T+5m | Verify replication Seconds_Behind_Master = 0 |
Senith |
| T+6m | STOP SLAVE; RESET SLAVE ALL; on new DB — it's now standalone primary |
Senith |
| T+7m | Spot-check AUTO_INCREMENT counters on hot tables match expectations |
Joint |
| T+8m | Update DATABASE_URL Secret to point at new DB |
Senith |
| T+9m | Rolling restart all Deployments: kubectl rollout restart deployment/{web,sidekiq-*} |
Senith |
| T+11m | Run smoke test: hit /up, exercise critical endpoints, enqueue test Sidekiq job |
Rails team |
| T+13m | Scale Sidekiq Deployments back to normal replica counts | Senith |
| T+14m | Drop maintenance mode — traffic resumes | Senith |
Phase F — Post-cutover (first week)¶
- T+1h: verify error rate is at baseline (Datadog).
- T+24h: keep Diff's prod DB read-only as rollback.
SET GLOBAL read_only = 1;on the old DB. - T+1w: decommission Diff's prod DB. Take a final backup, then shut down.
- T+1w: unfreeze schema migrations.
Rollback plan¶
If we hit a problem during cutover (smoke test fails, app errors, etc.), the escape hatch is to point back at Diff prod within ~1 hour of cutover:
- Maintenance mode on
- Drain Sidekiq
- Update
DATABASE_URLback to Diff prod - Rolling restart Deployments
- Smoke test
- Drop maintenance mode
The rollback window closes after ~1 hour because by then the new DB has accumulated writes that the old DB doesn't have, and reverting would lose data.
Critical gotchas¶
| Gotcha | Mitigation |
|---|---|
| AWS auto-purges binlogs | Step 6 — confirm 30-day retention before starting (already verified) |
| Azure MySQL Flex enforces TLS | Verify sslmode: required in DATABASE_URL, or ?sslmode=verify_ca |
| Connection limit on smaller tiers | New DB needs ≥3000 connection budget to match prod's 2619 max_connections + headroom |
sql_mode defaults differ |
Set explicitly to match prod — don't rely on defaults |
| AUTO_INCREMENT drift | Verify post-cutover before traffic resumes |
| Replication lag spikes during peak write | Acceptable while soaking; must be 0 at cutover |
binlog_format = MIXED on prod |
Switch to ROW before starting replication |
What changes in the app — nothing¶
Rails reads DATABASE_URL from environment. Switching DBs is updating one
Kubernetes Secret + rolling the Deployments. No code change at cutover.
The env-var refactor done earlier on the
docker-setup-chnages branch
is what makes that possible.
Effort estimate¶
| Phase | Calendar time | Active engineering |
|---|---|---|
| A (Pre-work) | 1 week | A few hours total |
| B (Seed dump+restore) | 12–24 hours | Mostly waiting |
| C (Soak) | 1 week | Daily lag checks |
| D (Pre-cutover validation) | 1 week | A day of smoke testing |
| E (Cutover) | 15 minutes | High-attention war room |
| F (Post-cutover) | 1 week | Monitoring, then decommission |
Total elapsed: ~3 weeks from "drop LHM ghosts" to "old DB shut down." Most of that is replication soak time, not active engineering.
Open items¶
These still need confirmation from Diff:
- Confirm the 5 LHM ghost tables are safe to drop (asked)
- Get the production MySQL endpoint and replication user credentials
- Confirm the network path option (public endpoint vs VPC peering)
- Confirm any other services connect directly to the DB (BI, analytics, ETL) — these need re-pointing post-cutover
See decisions-needed.md and the most recent Diff email thread for status.