remix-run/remix · error · Error
MySQL migration lock could not be acquired
Error message
MySQL migration lock could not be acquired
What it means
The migration lock is acquired with MySQL's GET_LOCK on a reserved connection; runWithMysqlMigrationLock throws this error when the SELECT ... FOR UPDATE / GET_LOCK probe returns a row that does not confirm acquisition (missing lock_name or acquired not truthy). It means another process holds the lock or the lock function failed unexpectedly.
Source
Thrown at packages/data-table-mysql/src/lib/driver.ts:625
async function runWithMysqlMigrationLock<result>(
connection: MysqlTransactionConnection,
name: string,
driver: MysqlDatabaseDriver,
run: (driver: DatabaseDriver<'mysql'>) => Promise<result>,
): Promise<result> {
// sha2(..., 256) yields 64 hex characters, exactly GET_LOCK's 64-character
// lock name limit, so any additional prefix must go inside the hash input.
let [lockRows] = await connection.query(
"select lock_name, get_lock(lock_name, 60) as `acquired` from (select sha2(concat(coalesce(database(), ''), ':', ?), 256) as lock_name) as migration_lock",
[name],
)
let lockRow = isRowsResult(lockRows) ? lockRows[0] : undefined
let lockName = lockRow?.lock_name
if (typeof lockName !== 'string' || !toBooleanExists(lockRow?.acquired)) {
throw new Error('MySQL migration lock could not be acquired')
}
let outcome: { status: 'success'; value: result } | { status: 'failure'; error: unknown }
try {
outcome = { status: 'success', value: await run(driver) }
} catch (error) {
outcome = { status: 'failure', error }
}
let unlockFailed = false
let unlockError: unknown
try {
let [unlockRows] = await connection.query('select release_lock(?) as `released`', [lockName])
if (!isRowsResult(unlockRows) || !toBooleanExists(unlockRows[0]?.released)) {
throw new Error('MySQL migration lock was not held by the reserved connection')View on GitHub (pinned to 9696913134)
Solutions
- Ensure only one migration process runs at a time (deploy serialization / CI mutex)
- Wait for the other holder to finish or its connection to time out, then retry
- If a stale lock is suspected, manually release it with SELECT RELEASE_LOCK('<lock name>') from a privileged connection
Example fix
// before
// two CI jobs run simultaneously:
await driver.runMigrations() // throws: lock could not be acquired
// after
// serialize migration runs (e.g. CI stage dependency or deploy lock)
await withDeployLock(async () => {
await driver.runMigrations()
}) Defensive patterns
Strategy: retry
Validate before calling
// Pre-check is not possible via GET_LOCK result; serialize instead const lockKey = 'deploy-migrations' await withMutex(lockKey, () => driver.runMigrations())
Try / catch
async function migrateWithRetry(driver, attempts = 5) {
for (let i = 0; i < attempts; i++) {
try { return await driver.runMigrations() }
catch (e) { if (e instanceof Error && e.message.includes('could not be acquired')) { await sleep(5000); continue } throw e }
}
throw new Error('migration lock busy')
} Prevention
- Serialize migration jobs across CI/deploy pipelines
- Avoid pointing multiple services' startup migrations at one DB simultaneously
When it happens
Trigger: Running migrations concurrently from two processes/CI jobs against the same database; a previous migration run crashed without releasing the lock and wait_timeout has not expired; permissions or proxy issues causing GET_LOCK to return 0 or NULL.
Common situations: Parallel CI pipelines migrating the same shared DB; a killed migration process leaving the named lock held until the connection dies; long-running deploy overlapping another release.
Related errors
- MySQL migration lock is already held by this database
- MySQL migration lock was not held by the reserved connection
- Unknown transaction token: + token.id
- MySQL database + method + () requires config-based construc
- MySQL database cannot + method + while transactions are op
AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27).
Data as JSON: /api/errors/6cd7372158767b7a.
Report an issue: GitHub.