remix-run/remix · error · Error

MySQL migration lock is already held by this database

Error message

MySQL migration lock is already held by this database

What it means

The MySQL driver serializes migration lock holders via an AsyncLocalStorage store plus a promise queue; withMigrationLock throws if the current async context already holds the lock. This guards against re-entrant/nested migration lock acquisition on the same database, which would otherwise deadlock on MySQL's named locks.

Source

Thrown at packages/data-table-mysql/src/lib/driver.ts:407

  }

  /**
   * Runs migration work on the mysql connection that owns the named lock.
   *
   * Lock acquisition waits up to 60 seconds and throws when the lock cannot
   * be acquired. Re-entering this method from inside `run` throws instead of
   * deadlocking, and a failed run destroys the reserved connection instead of
   * returning it to the pool.
   * @param name Logical migration lock name.
   * @param run Migration work to run with a connection-bound driver.
   * @returns The callback result.
   */
  async withMigrationLock<result>(
    name: string,
    run: (driver: DatabaseDriver<'mysql'>) => Promise<result>,
  ): Promise<result> {
    if (this.#migrationLockStore.getStore()) {
      throw new Error('MySQL migration lock is already held by this database')
    }

    let waitForPreviousLock = this.#migrationLockQueue
    let releaseQueue: () => void = () => undefined
    this.#migrationLockQueue = new Promise((resolve) => {
      releaseQueue = resolve
    })

    await waitForPreviousLock

    try {
      let releaseOnClose = false
      let connection: MysqlTransactionConnection

      if (isMysqlPool(this.#client)) {
        connection = await this.#client.getConnection()
        releaseOnClose = true
      } else {

View on GitHub (pinned to 9696913134)

Solutions

  1. Remove the nested withMigrationLock call and let the outer holder own the lock
  2. Restructure so migrations run sequentially under a single lock acquisition
  3. If composing runners, check whether the lock is already held before acquiring (e.g. expose/acquire a store check) instead of unconditionally re-locking

Example fix

// before
await driver.withMigrationLock('migrate', async () => {
  await runMigrations(driver) // internally calls withMigrationLock again
})
// after
await runMigrations(driver) // single lock acquisition inside the runner
Defensive patterns

Strategy: validation

Validate before calling

// Serialize migration calls yourself so nesting can't happen
let migrating = false
async function migrateOnce(driver) {
  if (migrating) throw new Error('migration already running')
  migrating = true
  try { await runMigrations(driver) } finally { migrating = false }
}

Try / catch

try { await driver.withMigrationLock(name, run) } catch (e) { if (e instanceof Error && e.message.includes('already held')) { return run(driver) /* trust outer holder */ } throw e }

Prevention

When it happens

Trigger: Calling withMigrationLock (directly or via a migration runner) from within a callback already running under withMigrationLock on the same driver instance — e.g. nested migration invocations or a migration that itself triggers the lock-protected runner.

Common situations: Custom migration orchestration that wraps the official runner in another withMigrationLock call; test fixtures that run migrations inside a locked setup block; recursive migration scripts.

Related errors


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