Skip to content

Commit 82d338a

Browse files
committed
Make migration:run safe to run repeatedly and in parallel
Previously a killed `migration:run` left no trace, so the next run would silently re-execute a possibly partially-applied migration, and two runs in parallel could both execute the same migration. This makes the command safe in both situations (safe = never silently corrupts the database). Two mechanisms, modeled on nextras/migrations: 1. Record the start before executing. `finished_at` is now nullable; the start marker (finished_at = NULL) is committed *before* the migration body and *outside* any transaction (only the body is wrapped in a transaction for TransactionalMigration). A migration interrupted mid-run therefore leaves a durable finished_at = NULL row that survives a body rollback (PostgreSQL) and a DDL implicit commit (MySQL). Every subsequent `migration:run` then fails loudly until the situation is resolved manually, instead of re-running potentially non-idempotent SQL. 2. Serialize runs with a database lock. `migration:run` acquires a lock before reading state and executing, releasing it in a finally block: - MySQL/MariaDB: GET_LOCK / RELEASE_LOCK - PostgreSQL: pg_try_advisory_lock / pg_advisory_unlock - SQLite/other: no-op (single-writer) The lock wait timeout is configurable (`lock_timeout_seconds`, default 300). Also: - `migration:init` idempotently upgrades an existing table by making `finished_at` nullable (narrow single-column ALTER, no full schema diff). - `migration:run` exits 1 (unfinished migration), 2 (lock not acquired) or 3 (table missing/not upgraded); `migration:check` reports unfinished migrations (exit bit 8). - `getExecutedVersions()` now honors a custom migration table name (was hardcoded), and identifiers in raw SELECTs are quoted. BC: `finished_at` becomes nullable and the incomplete-migration check can now block runs. Run `migration:init` once after upgrading to migrate the table. Co-Authored-By: Claude Code
1 parent 2e4a8e4 commit 82d338a

15 files changed

Lines changed: 926 additions & 46 deletions

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ two_phase_migrations:
3939
# excluded_tables: ['my_tmp_table']
4040
# template_file_path: '%kernel.project_dir%/migrations/my-template.txt'
4141
# template_indent: "\t\t"
42+
# lock_timeout_seconds: 300 # how long migration:run waits for a lock held by a parallel run before failing
4243
```
4344

4445
The bundle requires `symfony/http-kernel` ^6.4+.
@@ -74,6 +75,7 @@ services:
7475
$excludedTables: ['my_tmp_table'] # migration table ($migrationTableName) is always added to excluded tables automatically
7576
$templateFilePath: "%kernel.project_dir%/migrations/my-template.txt" # customizable according to your coding style
7677
$templateIndent: "\t\t" # defaults to spaces
78+
$lockTimeoutSeconds: 300 # how long migration:run waits for a lock held by a parallel run before failing
7779
```
7880
</details>
7981
@@ -82,6 +84,7 @@ services:
8284
#### Initialization:
8385
8486
After installation, you need to create `migration` table in your database. It is safe to run it even when the table was already initialized.
87+
When upgrading from `1.x`, running it once also upgrades the existing table to the new schema (it makes `finished_at` nullable, see [Concurrency & execution safety](#concurrency--execution-safety)). Only that single column is altered, so it is safe to run against a populated table.
8588

8689
```bash
8790
$ bin/console migration:init
@@ -213,6 +216,41 @@ $ bin/console migration:run both
213216
[info] Migration execution completed (phase both)
214217
```
215218

219+
### Concurrency & execution safety
220+
221+
`migration:run` is designed to be **safe to run multiple times and in parallel** (e.g. during a rolling deployment where several instances may start the command at once). "Safe" here means it will never silently corrupt your database — not that every invocation finishes the work. Two mechanisms provide this:
222+
223+
#### Parallel runs are serialized by a database lock
224+
225+
Before doing anything, `migration:run` acquires a database-level lock, so only one run executes migrations at a time:
226+
227+
- **MySQL / MariaDB** – a named lock via `GET_LOCK()` / `RELEASE_LOCK()`
228+
- **PostgreSQL** – a session-level advisory lock via `pg_try_advisory_lock()` / `pg_advisory_unlock()`
229+
- **other platforms (incl. SQLite)** – no-op (SQLite already serializes writers at the filesystem level)
230+
231+
A parallel run waits up to `lock_timeout_seconds` (default `300`) for the lock. If the lock holder finishes in time, the waiting run simply proceeds and finds nothing to do (exit code `0`). If the timeout is exceeded, it aborts with exit code `2` instead of running concurrently. The lock is bound to the connection session, so it is released automatically if a runner is killed.
232+
233+
#### Interrupted migrations block all further runs until resolved
234+
235+
Each migration is recorded in two steps: a row with `started_at` (and `finished_at = NULL`) is committed **before** the migration body runs, and `finished_at` is set **after** it succeeds. If a runner is killed mid-migration (on MySQL, DDL such as `ALTER` auto-commits and cannot be rolled back), a `finished_at = NULL` row is left behind.
236+
237+
Because such a migration may be only partially applied, **every subsequent `migration:run` fails loudly** (exit code `1`) instead of re-executing potentially non-idempotent SQL:
238+
239+
```bash
240+
$ bin/console migration:run before
241+
242+
# example output:
243+
[info] Starting migration execution (phase before)
244+
[error] Migration execution aborted, found 1 unfinished migration(s) from a previously interrupted run, manual resolution is required
245+
```
246+
247+
To recover, inspect the database to determine what the interrupted migration actually applied, then resolve the `finished_at = NULL` row in the migration table manually:
248+
249+
- delete the row to re-run the migration from scratch (only safe if nothing was applied or the migration is idempotent), or
250+
- set its `finished_at` to mark it as completed (if you verified everything was applied or finished it by hand).
251+
252+
`migration:run` exit codes: `0` success, `1` unfinished migration found (manual resolution required), `2` lock could not be acquired, `3` migration table missing or not upgraded (run `migration:init`). `migration:check` also reports unfinished migrations (and adds `8` to its exit code bitmask).
253+
216254
### Advanced usage
217255

218256
#### Run custom code for each executed query:
@@ -249,6 +287,8 @@ But those columns are declared as VARCHARs by default, because there is [no micr
249287
That may complicate datetime manipulations (like duration calculation).
250288
You can adjust the structure to your needs (e.g. use `DATETIME(6)` for MySQL) manually in some migration.
251289

290+
`finished_at` is nullable: a `NULL` value marks a migration that was started but never finished (see [Concurrency & execution safety](#concurrency--execution-safety)).
291+
252292
```
253293
+----------------+--------+-----------------------------+---------------------------+
254294
| version | phase | started_at | finished_at |

src/Bridge/Symfony/TwoPhaseMigrationsBundle.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public function configure(DefinitionConfigurator $definition): void
3232
->end()
3333
->scalarNode('template_file_path')->defaultNull()->end()
3434
->scalarNode('template_indent')->defaultNull()->end()
35+
->integerNode('lock_timeout_seconds')->defaultNull()->end()
3536
->end();
3637
}
3738

@@ -44,6 +45,7 @@ public function configure(DefinitionConfigurator $definition): void
4445
* excluded_tables: list<string>,
4546
* template_file_path: ?string,
4647
* template_indent: ?string,
48+
* lock_timeout_seconds: ?int,
4749
* } $config
4850
*/
4951
public function loadExtension( // @phpstan-ignore method.childParameterType, method.childParameterType
@@ -63,6 +65,7 @@ public function loadExtension( // @phpstan-ignore method.childParameterType, met
6365
'$excludedTables' => $config['excluded_tables'],
6466
'$templateFilePath' => $config['template_file_path'],
6567
'$templateIndent' => $config['template_indent'],
68+
'$lockTimeoutSeconds' => $config['lock_timeout_seconds'],
6669
]);
6770

6871
$services->set(MigrationService::class)

src/Command/MigrationCheckCommand.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Symfony\Component\Console\Input\InputInterface;
1111
use Symfony\Component\Console\Output\OutputInterface;
1212
use function array_diff;
13+
use function array_map;
1314
use function array_values;
1415
use function count;
1516
use function implode;
@@ -22,6 +23,7 @@ class MigrationCheckCommand extends Command
2223

2324
public const NAME = 'migration:check';
2425

26+
public const EXIT_INCOMPLETE_MIGRATION = 8;
2527
public const EXIT_ENTITIES_NOT_SYNCED = 4;
2628
public const EXIT_UNKNOWN_MIGRATION = 2;
2729
public const EXIT_AWAITING_MIGRATION = 1;
@@ -45,6 +47,7 @@ public function execute(
4547
$logger->info('Starting migration check');
4648

4749
$exitCode = self::EXIT_OK;
50+
$exitCode |= $this->checkIncompleteMigrations($logger);
4851
$exitCode |= $this->checkMigrationsExecuted($logger);
4952
$exitCode |= $this->checkEntitiesSyncedWithDatabase($logger);
5053

@@ -56,6 +59,26 @@ public function execute(
5659
return $exitCode;
5760
}
5861

62+
private function checkIncompleteMigrations(LoggerInterface $logger): int
63+
{
64+
$incomplete = $this->migrationService->getIncompleteMigrations();
65+
66+
if (count($incomplete) === 0) {
67+
return self::EXIT_OK;
68+
}
69+
70+
$logger->error('Found {migrationIncompleteCount} unfinished migration(s) from a previously interrupted run, manual resolution is required: {migrationIncompleteList}', [
71+
'migrationIncompleteCount' => count($incomplete),
72+
'migrationIncomplete' => $incomplete,
73+
'migrationIncompleteList' => implode(', ', array_map(
74+
static fn (array $migration): string => $migration['version'] . ' (phase ' . $migration['phase'] . ')',
75+
$incomplete,
76+
)),
77+
]);
78+
79+
return self::EXIT_INCOMPLETE_MIGRATION;
80+
}
81+
5982
private function checkEntitiesSyncedWithDatabase(LoggerInterface $logger): int
6083
{
6184
$updates = $this->migrationService->generateDiffSqls();

src/Command/MigrationRunCommand.php

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44

55
use LogicException;
66
use Psr\Log\LoggerInterface;
7+
use ShipMonk\Doctrine\Migration\IncompleteMigrationException;
8+
use ShipMonk\Doctrine\Migration\MigrationLockException;
79
use ShipMonk\Doctrine\Migration\MigrationPhase;
810
use ShipMonk\Doctrine\Migration\MigrationService;
11+
use ShipMonk\Doctrine\Migration\MigrationTableNotInitializedException;
912
use Symfony\Component\Console\Attribute\AsCommand;
1013
use Symfony\Component\Console\Command\Command;
1114
use Symfony\Component\Console\Input\InputArgument;
@@ -28,6 +31,11 @@ class MigrationRunCommand extends Command
2831
public const ARGUMENT_PHASE = 'phase';
2932
public const PHASE_BOTH = 'both';
3033

34+
public const EXIT_OK = 0;
35+
public const EXIT_INCOMPLETE_MIGRATION = 1;
36+
public const EXIT_LOCK_NOT_ACQUIRED = 2;
37+
public const EXIT_TABLE_NOT_INITIALIZED = 3;
38+
3139
public function __construct(
3240
private readonly MigrationService $migrationService,
3341
private readonly ?LoggerInterface $logger = null,
@@ -65,19 +73,60 @@ public function execute(
6573
'migrationPhases' => array_map(static fn (MigrationPhase $phase): string => $phase->value, $phases),
6674
]);
6775

68-
$migratedSomething = $this->executeMigrations($logger, $phases);
76+
// Acquiring the lock first makes the whole run serialized across processes, so that running this command
77+
// multiple times in parallel is safe (only one runner executes migrations, the others find nothing to do).
78+
try {
79+
$this->migrationService->acquireLock();
80+
} catch (MigrationLockException $e) {
81+
$logger->error('Migration execution aborted, could not acquire migration lock within {migrationLockTimeoutSeconds} s (another migration run is probably in progress)', [
82+
'migrationPhaseArgument' => $phaseArgument,
83+
'migrationLockTimeoutSeconds' => $e->timeoutSeconds,
84+
]);
85+
86+
return self::EXIT_LOCK_NOT_ACQUIRED;
87+
}
88+
89+
try {
90+
// Fail with an actionable message (instead of a cryptic constraint violation) when the migration table
91+
// is missing or was not upgraded via migration:init after a library upgrade.
92+
$this->migrationService->assertMigrationTableUpToDate();
93+
94+
// A migration interrupted by a previous run leaves a partially applied state that we must not silently
95+
// continue from, so all subsequent runs fail loudly until the situation is resolved manually.
96+
$this->migrationService->assertNoIncompleteMigrations();
97+
98+
$migratedSomething = $this->executeMigrations($logger, $phases);
99+
100+
if (!$migratedSomething) {
101+
$logger->notice('No migrations to execute (phase {migrationPhaseArgument})', [
102+
'migrationPhaseArgument' => $phaseArgument,
103+
]);
104+
} else {
105+
$logger->info('Migration execution completed (phase {migrationPhaseArgument})', [
106+
'migrationPhaseArgument' => $phaseArgument,
107+
]);
108+
}
69109

70-
if (!$migratedSomething) {
71-
$logger->notice('No migrations to execute (phase {migrationPhaseArgument})', [
110+
return self::EXIT_OK;
111+
} catch (MigrationTableNotInitializedException $e) {
112+
$logger->error('Migration execution aborted, migration table {migrationTableName} is not initialized, run the migration:init command first', [
72113
'migrationPhaseArgument' => $phaseArgument,
114+
'migrationTableName' => $e->tableName,
115+
'migrationError' => $e->getMessage(),
73116
]);
74-
} else {
75-
$logger->info('Migration execution completed (phase {migrationPhaseArgument})', [
117+
118+
return self::EXIT_TABLE_NOT_INITIALIZED;
119+
} catch (IncompleteMigrationException $e) {
120+
$logger->error('Migration execution aborted, found {migrationIncompleteCount} unfinished migration(s) from a previously interrupted run, manual resolution is required', [
76121
'migrationPhaseArgument' => $phaseArgument,
122+
'migrationIncompleteCount' => count($e->incompleteMigrations),
123+
'migrationIncomplete' => $e->incompleteMigrations,
77124
]);
78-
}
79125

80-
return 0;
126+
return self::EXIT_INCOMPLETE_MIGRATION;
127+
} finally {
128+
$this->migrationService->releaseLock();
129+
}
81130
}
82131

83132
/**
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\Doctrine\Migration;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* Thrown when there are migrations that were started but never finished (e.g. the process was killed mid-execution).
9+
* Such migrations may be partially applied, therefore all subsequent runs must fail until the state is resolved manually.
10+
*
11+
* @api
12+
*/
13+
class IncompleteMigrationException extends RuntimeException
14+
{
15+
16+
/**
17+
* @param list<array{version: string, phase: string, startedAt: string}> $incompleteMigrations
18+
*/
19+
public function __construct(
20+
public readonly array $incompleteMigrations,
21+
string $message,
22+
)
23+
{
24+
parent::__construct($message);
25+
}
26+
27+
}

src/MigrationConfig.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
use LogicException;
66
use function is_dir;
77
use function is_file;
8+
use function max;
89

910
class MigrationConfig
1011
{
1112

13+
private const DEFAULT_LOCK_TIMEOUT_SECONDS = 300;
14+
1215
private string $migrationsDir;
1316

1417
private string $migrationsTableName;
@@ -26,6 +29,8 @@ class MigrationConfig
2629

2730
private string $templateIndent;
2831

32+
private int $lockTimeoutSeconds;
33+
2934
/**
3035
* @param string[]|null $excludedTables
3136
*/
@@ -37,6 +42,7 @@ public function __construct(
3742
?array $excludedTables = null,
3843
?string $templateFilePath = null,
3944
?string $templateIndent = null,
45+
?int $lockTimeoutSeconds = null,
4046
)
4147
{
4248
$templateFilePathToUse = $templateFilePath ?? __DIR__ . '/template/migration.txt';
@@ -57,6 +63,7 @@ public function __construct(
5763
$this->excludedTables[] = $this->getMigrationTableName();
5864
$this->templateFilePath = $templateFilePathToUse;
5965
$this->templateIndent = $templateIndent ?? ' ';
66+
$this->lockTimeoutSeconds = max(1, $lockTimeoutSeconds ?? self::DEFAULT_LOCK_TIMEOUT_SECONDS);
6067
}
6168

6269
public function getMigrationsDirectory(): string
@@ -97,4 +104,9 @@ public function getTemplateIndent(): string
97104
return $this->templateIndent;
98105
}
99106

107+
public function getLockTimeoutSeconds(): int
108+
{
109+
return $this->lockTimeoutSeconds;
110+
}
111+
100112
}

0 commit comments

Comments
 (0)