Replace illusory transaction wrapping in migrate.php with statement-level resume (#30)
migrate.php wrapped each migration file's statements in begin_transaction()/rollback(), but MySQL DDL statements cause an implicit commit — so a rollback couldn't actually undo earlier DDL already executed within the same file. A migration failing partway left the DB altered but unrecorded, and the next run retried the whole file from statement 1, hitting "already exists" errors not on the safe-to-ignore allowlist and permanently wedging the runner. Removed the transaction wrapper (it only gave false confidence) and added a migration_progress table that records the index of the last successfully-executed statement in each file. A re-run after a partial failure now resumes right after the last success instead of re-executing already-applied DDL. Verified against real MariaDB with a 4-statement migration where statement 3 fails: run 1 correctly applies statements 1-2 and records progress at index 1; after fixing the bad statement, run 2 resumes at statement 3, completes, and clears the progress marker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
This commit is contained in:
+68
-11
@@ -46,6 +46,23 @@ if (!$conn->query($createTable)) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Tracks per-statement progress within a migration file. MySQL DDL statements
|
||||
// (ALTER/CREATE TABLE, etc.) cause an implicit commit, so begin_transaction()/
|
||||
// rollback() around a whole file can't actually undo DDL already executed
|
||||
// earlier in that same file. This table lets a re-run after a partial failure
|
||||
// resume from the statement after the last one that succeeded, instead of
|
||||
// re-executing already-applied DDL and wedging on "already exists" errors.
|
||||
$createProgressTable = "CREATE TABLE IF NOT EXISTS migration_progress (
|
||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
last_statement_index INT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)";
|
||||
|
||||
if (!$conn->query($createProgressTable)) {
|
||||
echo "Error: Could not create migration_progress table: " . $conn->error . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Get list of completed migrations
|
||||
$completed = [];
|
||||
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||
@@ -114,47 +131,87 @@ foreach ($pending as $file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute migration - handle multiple statements
|
||||
$conn->begin_transaction();
|
||||
|
||||
// Execute migration statement-by-statement, tracking progress as we go.
|
||||
// No begin_transaction()/rollback() here: DDL statements auto-commit in
|
||||
// MySQL/MariaDB regardless, so a transaction wrapper around the whole
|
||||
// file would only create the illusion of atomicity while giving no real
|
||||
// protection. Instead, each statement commits immediately (autocommit),
|
||||
// and its index is durably recorded so a later re-run can resume exactly
|
||||
// where a previous run left off rather than re-executing already-applied
|
||||
// DDL.
|
||||
try {
|
||||
// Split by semicolon but respect statements properly
|
||||
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||
$statements = array_filter(
|
||||
$statements = array_values(array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
function($stmt) {
|
||||
// Remove comments and check if there's actual SQL
|
||||
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||
return !empty(trim($cleaned));
|
||||
}
|
||||
);
|
||||
));
|
||||
|
||||
$resumeFrom = 0;
|
||||
$progressStmt = $conn->prepare(
|
||||
"SELECT last_statement_index FROM migration_progress WHERE filename = ?"
|
||||
);
|
||||
$progressStmt->bind_param('s', $filename);
|
||||
$progressStmt->execute();
|
||||
$progressRow = $progressStmt->get_result()->fetch_assoc();
|
||||
$progressStmt->close();
|
||||
if ($progressRow) {
|
||||
$resumeFrom = (int)$progressRow['last_statement_index'] + 1;
|
||||
echo "\n Resuming from statement " . ($resumeFrom + 1) . " of " . count($statements)
|
||||
. " after a previous partial failure... ";
|
||||
}
|
||||
|
||||
foreach ($statements as $index => $statement) {
|
||||
if ($index < $resumeFrom) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if (!$conn->query($statement)) {
|
||||
// Some "errors" are acceptable (like "index already exists")
|
||||
$error = $conn->error;
|
||||
if (strpos($error, 'Duplicate key name') !== false ||
|
||||
strpos($error, 'already exists') !== false) {
|
||||
// Index already exists, that's fine
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
throw new Exception($error);
|
||||
}
|
||||
}
|
||||
|
||||
// Record the migration
|
||||
// Record progress after every statement so a later run can
|
||||
// resume from here even if a subsequent statement fails.
|
||||
$upsert = $conn->prepare(
|
||||
"INSERT INTO migration_progress (filename, last_statement_index) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_statement_index = VALUES(last_statement_index)"
|
||||
);
|
||||
$upsert->bind_param('si', $filename, $index);
|
||||
$upsert->execute();
|
||||
$upsert->close();
|
||||
}
|
||||
|
||||
// Record the migration as fully complete and clear its progress marker
|
||||
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||
$stmt->bind_param('s', $filename);
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception("Could not record migration: " . $conn->error);
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
$clearProgress = $conn->prepare("DELETE FROM migration_progress WHERE filename = ?");
|
||||
$clearProgress->bind_param('s', $filename);
|
||||
$clearProgress->execute();
|
||||
$clearProgress->close();
|
||||
|
||||
echo "OK\n";
|
||||
$success++;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$conn->rollback();
|
||||
// Nothing to roll back: every statement up to the failure already
|
||||
// committed (DDL implicitly, everything else via autocommit). The
|
||||
// progress marker recorded above reflects exactly how far this file
|
||||
// got, so the next run will resume right after the last success.
|
||||
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||
$failed++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user