|
| 1 | +# Failed Migration Recovery Runbook |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This runbook provides procedures for detecting, diagnosing, and recovering from failed database migrations in the Mux Backend API. |
| 6 | + |
| 7 | +## Quick Reference |
| 8 | + |
| 9 | +| Scenario | Steps | Recovery Time | |
| 10 | +|----------|-------|---------------| |
| 11 | +| Migration hangs | Check logs → Kill process → Rollback | 5-10 min | |
| 12 | +| Syntax error | Fix schema → Rollback → Retry | 10-15 min | |
| 13 | +| Constraint violation | Backfill data → Rollback → Retry | 15-30 min | |
| 14 | +| Lock timeout | Kill blocking query → Retry | 5 min | |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Detection |
| 19 | + |
| 20 | +### Signs of Migration Failure |
| 21 | + |
| 22 | +1. **Application startup fails** with migration error |
| 23 | +2. **Database logs** show: |
| 24 | + - `ERROR: relation "table_name" already exists` |
| 25 | + - `ERROR: column "column_name" does not exist` |
| 26 | + - `deadlock detected` |
| 27 | + - `statement timeout` |
| 28 | +3. **Metrics** show stuck migration: |
| 29 | + - Long-running transaction in `pg_stat_activity` |
| 30 | + - No progress on migration commit |
| 31 | + |
| 32 | +### Check Migration Status |
| 33 | + |
| 34 | +```bash |
| 35 | +# List applied migrations |
| 36 | +psql -U $DB_USER -d $DB_NAME -c "SELECT * FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 10;" |
| 37 | + |
| 38 | +# Find stuck migrations |
| 39 | +psql -U $DB_USER -d $DB_NAME -c "SELECT * FROM _prisma_migrations WHERE finished_at IS NULL;" |
| 40 | + |
| 41 | +# Check long-running transactions |
| 42 | +psql -U $DB_USER -d $DB_NAME -c "SELECT * FROM pg_stat_activity WHERE state = 'active' AND xact_start < NOW() - INTERVAL '5 minutes';" |
| 43 | +``` |
| 44 | + |
| 45 | +--- |
| 46 | + |
| 47 | +## Recovery Procedures |
| 48 | + |
| 49 | +### Scenario 1: Syntax Error in Migration |
| 50 | + |
| 51 | +**Symptoms:** |
| 52 | +- `ERROR: syntax error at or near...` |
| 53 | +- Migration marked as started but not finished |
| 54 | + |
| 55 | +**Steps:** |
| 56 | + |
| 57 | +1. **Stop the application** |
| 58 | + ```bash |
| 59 | + kubectl scale deployment mux-api --replicas=0 |
| 60 | + ``` |
| 61 | + |
| 62 | +2. **Identify the failed migration** |
| 63 | + ```bash |
| 64 | + psql -U $DB_USER -d $DB_NAME -c "SELECT name FROM _prisma_migrations WHERE finished_at IS NULL;" |
| 65 | + ``` |
| 66 | + |
| 67 | +3. **Rollback (Prisma handles this)** |
| 68 | + ```bash |
| 69 | + # Prisma automatically rolls back failed migrations |
| 70 | + npm run prisma:migrate:resolve -- --rolled-back <migration-name> |
| 71 | + ``` |
| 72 | + |
| 73 | +4. **Fix the migration file** |
| 74 | + - Edit the migration SQL in `prisma/migrations/<timestamp>_<name>/migration.sql` |
| 75 | + - Correct syntax errors |
| 76 | + |
| 77 | +5. **Retry migration** |
| 78 | + ```bash |
| 79 | + npm run prisma:migrate:deploy |
| 80 | + ``` |
| 81 | + |
| 82 | +6. **Restart application** |
| 83 | + ```bash |
| 84 | + kubectl scale deployment mux-api --replicas=3 |
| 85 | + ``` |
| 86 | + |
| 87 | +### Scenario 2: Constraint Violation |
| 88 | + |
| 89 | +**Symptoms:** |
| 90 | +- `ERROR: duplicate key value violates unique constraint` |
| 91 | +- `ERROR: insert or update on table violates foreign key constraint` |
| 92 | + |
| 93 | +**Steps:** |
| 94 | + |
| 95 | +1. **Analyze constraint violation** |
| 96 | + ```bash |
| 97 | + psql -U $DB_USER -d $DB_NAME -c "SELECT * FROM table_name WHERE condition;" |
| 98 | + ``` |
| 99 | + |
| 100 | +2. **Fix conflicting data** (backfill or cleanup) |
| 101 | + ```sql |
| 102 | + -- Example: Remove duplicates before adding UNIQUE constraint |
| 103 | + DELETE FROM table_name WHERE id NOT IN ( |
| 104 | + SELECT MIN(id) FROM table_name GROUP BY unique_col |
| 105 | + ); |
| 106 | + ``` |
| 107 | + |
| 108 | +3. **Rollback migration** |
| 109 | + ```bash |
| 110 | + npm run prisma:migrate:resolve -- --rolled-back <migration-name> |
| 111 | + ``` |
| 112 | + |
| 113 | +4. **Retry after data fix** |
| 114 | + ```bash |
| 115 | + npm run prisma:migrate:deploy |
| 116 | + ``` |
| 117 | + |
| 118 | +### Scenario 3: Lock Timeout |
| 119 | + |
| 120 | +**Symptoms:** |
| 121 | +- `ERROR: canceling statement due to lock timeout` |
| 122 | +- `statement timeout` in logs |
| 123 | + |
| 124 | +**Steps:** |
| 125 | + |
| 126 | +1. **Identify blocking queries** |
| 127 | + ```bash |
| 128 | + psql -U $DB_USER -d $DB_NAME -c "SELECT blocked_locks.pid, blocked_locks.relation::regclass, blocking_locks.pid, blocking_locks.relation::regclass FROM pg_locks blocked_locks JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid AND blocking_locks.granted AND NOT blocked_locks.granted WHERE NOT blocked_locks.granted;" |
| 129 | + ``` |
| 130 | + |
| 131 | +2. **Terminate blocking transaction** |
| 132 | + ```bash |
| 133 | + psql -U $DB_USER -d $DB_NAME -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid != pg_backend_pid() AND query LIKE '%your-table-name%' AND state = 'active';" |
| 134 | + ``` |
| 135 | + |
| 136 | +3. **Increase lock_timeout** (temporary) |
| 137 | + ```sql |
| 138 | + SET lock_timeout = '30 seconds'; |
| 139 | + ``` |
| 140 | + |
| 141 | +4. **Retry migration** |
| 142 | + ```bash |
| 143 | + npm run prisma:migrate:deploy |
| 144 | + ``` |
| 145 | + |
| 146 | +### Scenario 4: Hung Migration |
| 147 | + |
| 148 | +**Symptoms:** |
| 149 | +- Migration started hours ago |
| 150 | +- No errors in logs |
| 151 | +- Application waiting on migration |
| 152 | + |
| 153 | +**Steps:** |
| 154 | + |
| 155 | +1. **Check migration status** |
| 156 | + ```bash |
| 157 | + psql -U $DB_USER -d $DB_NAME -c "SELECT * FROM _prisma_migrations WHERE finished_at IS NULL AND started_at < NOW() - INTERVAL '1 hour';" |
| 158 | + ``` |
| 159 | + |
| 160 | +2. **Identify long-running transaction** |
| 161 | + ```bash |
| 162 | + psql -U $DB_USER -d $DB_NAME -c "SELECT pid, usename, xact_start, state_change, query FROM pg_stat_activity WHERE xact_start < NOW() - INTERVAL '1 hour';" |
| 163 | + ``` |
| 164 | + |
| 165 | +3. **Terminate stuck transaction** |
| 166 | + ```bash |
| 167 | + psql -U $DB_USER -d $DB_NAME -c "SELECT pg_terminate_backend(<pid>);" |
| 168 | + ``` |
| 169 | + |
| 170 | +4. **Mark migration as rolled back** |
| 171 | + ```bash |
| 172 | + npm run prisma:migrate:resolve -- --rolled-back <migration-name> |
| 173 | + ``` |
| 174 | + |
| 175 | +5. **Investigate root cause** before retry |
| 176 | + - Check for missing indexes |
| 177 | + - Verify disk space |
| 178 | + - Review lock contention |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## Verification |
| 183 | + |
| 184 | +### After Any Recovery Attempt |
| 185 | + |
| 186 | +1. **Verify database consistency** |
| 187 | + ```bash |
| 188 | + npm run prisma:generate |
| 189 | + npm run prisma:migrate:status |
| 190 | + ``` |
| 191 | + |
| 192 | +2. **Run integrity checks** |
| 193 | + ```bash |
| 194 | + npm run db:integrity-check |
| 195 | + ``` |
| 196 | + |
| 197 | +3. **Test critical flows** |
| 198 | + ```bash |
| 199 | + npm run test:integration -- --suite=payments |
| 200 | + npm run test:integration -- --suite=wallets |
| 201 | + npm run test:integration -- --suite=recovery |
| 202 | + ``` |
| 203 | + |
| 204 | +4. **Monitor application health** |
| 205 | + ```bash |
| 206 | + kubectl logs -f deployment/mux-api -c mux-api | grep -E "ERROR|WARN|migration" |
| 207 | + ``` |
| 208 | + |
| 209 | +--- |
| 210 | + |
| 211 | +## Prevention |
| 212 | + |
| 213 | +### Best Practices |
| 214 | + |
| 215 | +1. **Test migrations locally first** |
| 216 | + ```bash |
| 217 | + docker-compose up -d postgres |
| 218 | + npm run prisma:migrate:dev |
| 219 | + ``` |
| 220 | + |
| 221 | +2. **Write idempotent migrations** |
| 222 | + - Use `IF NOT EXISTS` / `IF EXISTS` |
| 223 | + - Handle both old and new schema during transition |
| 224 | + |
| 225 | +3. **Add data backfill migrations separately** |
| 226 | + - Split schema changes and data changes |
| 227 | + - Allows rollback at schema layer |
| 228 | + |
| 229 | +4. **Monitor lock timeouts** |
| 230 | + - Set `statement_timeout = 30s` for large ALTER TABLE |
| 231 | + - Use `ALTER TABLE ... CONCURRENTLY` for indexes on large tables |
| 232 | + |
| 233 | +5. **Use feature flags for compatibility** |
| 234 | + - Support both old and new column names during migration |
| 235 | + - Clean up old code after deployment |
| 236 | + |
| 237 | +### Example: Safe Schema Evolution |
| 238 | + |
| 239 | +```sql |
| 240 | +-- Migration 1: Add new column |
| 241 | +ALTER TABLE payments ADD COLUMN assetCode TEXT; |
| 242 | + |
| 243 | +-- Migration 2: Populate data (separate, can be retried safely) |
| 244 | +UPDATE payments SET assetCode = currency WHERE assetCode IS NULL; |
| 245 | + |
| 246 | +-- Migration 3: Add constraints |
| 247 | +ALTER TABLE payments ALTER COLUMN assetCode SET NOT NULL; |
| 248 | + |
| 249 | +-- Migration 4: Deprecate old column (after code updated) |
| 250 | +-- ALTER TABLE payments DROP COLUMN currency_old; |
| 251 | +``` |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +## Troubleshooting |
| 256 | + |
| 257 | +| Error | Cause | Fix | |
| 258 | +|-------|-------|-----| |
| 259 | +| `relation already exists` | Migration already applied | Check `_prisma_migrations` table, mark as rolled-back | |
| 260 | +| `column does not exist` | Schema mismatch | Regenerate Prisma client: `npm run prisma:generate` | |
| 261 | +| `deadlock detected` | Concurrent migrations | Ensure migrations run serially, check app replicas | |
| 262 | +| `statement timeout` | Large table operation | Increase timeout or break into smaller batches | |
| 263 | +| `disk space low` | Insufficient storage | Add disk space or clean old transaction logs | |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## Escalation |
| 268 | + |
| 269 | +**Immediate:** |
| 270 | +- Migration stuck > 30 minutes |
| 271 | +- Multiple rollback failures |
| 272 | +- Production data corruption suspected |
| 273 | + |
| 274 | +**Contact:** |
| 275 | +- On-call DBA: `@dba-oncall` (Slack) |
| 276 | +- Database team: `database-team@mux-labs.com` |
| 277 | +- CTO: For critical data loss scenarios |
| 278 | + |
| 279 | +--- |
| 280 | + |
| 281 | +## Audit & Compliance |
| 282 | + |
| 283 | +All failed migrations are tracked via `MigrationRecoveryService`: |
| 284 | +- Logged to application logs |
| 285 | +- Recovery actions recorded in service state |
| 286 | +- Use for post-incident analysis |
| 287 | + |
| 288 | +**Retention:** 30 days in recovery service memory (logs permanent in ELK) |
| 289 | + |
| 290 | +--- |
| 291 | + |
| 292 | +## Related Documentation |
| 293 | + |
| 294 | +- [Prisma Migrations Guide](https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate) |
| 295 | +- [PostgreSQL Transaction Handling](https://www.postgresql.org/docs/current/runtime-config-client.html) |
| 296 | +- [Mux Backend Architecture](../docs/architecture.md) |
0 commit comments