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
- Remove the nested withMigrationLock call and let the outer holder own the lock
- Restructure so migrations run sequentially under a single lock acquisition
- 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
- Never wrap the official migration runner in your own lock
- Run migrations from a single orchestration entry point
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
- MySQL migration lock could not be acquired
- 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/464f939e42729c92.
Report an issue: GitHub.