From fba251b85dc5764ac1516b0c9dbcf63597f86e01 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:28:32 -0400 Subject: [PATCH] Replace illusory transaction wrapping in migrate.php with statement-level resume (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP --- migrations/migrate.php | 79 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/migrations/migrate.php b/migrations/migrate.php index a725242..b34a8b4 100644 --- a/migrations/migrate.php +++ b/migrations/migrate.php @@ -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); } - throw new Exception($error); } + + // 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 + // 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++; }