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

  1. Ensure only one migration process runs at a time (deploy serialization / CI mutex)
  2. Wait for the other holder to finish or its connection to time out, then retry
  3. 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

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


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/6cd7372158767b7a. Report an issue: GitHub.