You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
# lock_timeout_seconds: 300 # how long migration:run waits for a lock held by a parallel run before failing
42
43
```
43
44
44
45
The bundle requires `symfony/http-kernel` ^6.4+.
@@ -74,6 +75,7 @@ services:
74
75
$excludedTables: ['my_tmp_table'] # migration table ($migrationTableName) is always added to excluded tables automatically
75
76
$templateFilePath: "%kernel.project_dir%/migrations/my-template.txt"# customizable according to your coding style
76
77
$templateIndent: "\t\t"# defaults to spaces
78
+
$lockTimeoutSeconds: 300# how long migration:run waits for a lock held by a parallel run before failing
77
79
```
78
80
</details>
79
81
@@ -82,6 +84,7 @@ services:
82
84
#### Initialization:
83
85
84
86
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.
85
88
86
89
```bash
87
90
$ bin/console migration:init
@@ -213,6 +216,41 @@ $ bin/console migration:run both
213
216
[info] Migration execution completed (phase both)
214
217
```
215
218
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:
[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
+
216
254
### Advanced usage
217
255
218
256
#### 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
249
287
That may complicate datetime manipulations (like duration calculation).
250
288
You can adjust the structure to your needs (e.g. use `DATETIME(6)` for MySQL) manually in some migration.
251
289
290
+
`finished_at` is nullable: a `NULL` value marks a migration that was started but never finished (see [Concurrency & execution safety](#concurrency--execution-safety)).
$logger->error('Found {migrationIncompleteCount} unfinished migration(s) from a previously interrupted run, manual resolution is required: {migrationIncompleteList}', [
// 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)', [
$logger->error('Migration execution aborted, found {migrationIncompleteCount} unfinished migration(s) from a previously interrupted run, manual resolution is required', [
0 commit comments