Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MySQL Dialect with Deadlock Retries for Kysely

A custom MySQL dialect for Kysely that automatically retries queries when they encounter deadlock errors.

Features

  • 🔄 Automatic retry of standalone queries that fail due to deadlocks
  • 🛡️ Fails fast inside transactions, so atomicity is never broken
  • ♻️ executeTransactionWithRetry to safely replay a whole transaction
  • ⚙️ Configurable retry attempts, exponential backoff and delay
  • 📊 Optional retry tracking and logging
  • 🔌 Drop-in replacement for Kysely's standard MySQL dialect

Installation

npm install mysql-dialect-with-deadlock
# or
pnpm add mysql-dialect-with-deadlock

Usage

import { Kysely } from 'kysely'
import { createPool } from 'mysql2'
import { MySQLDialectWithDeadlockRetries } from 'mysql-dialect-with-deadlock'

const db = new Kysely({
  dialect: new MySQLDialectWithDeadlockRetries({
    pool: createPool({
      host: 'localhost',
      user: 'root',
      database: 'test'
    }),
    deadlock: {
      maxAttempts: 3,
      onRetry: (error, attempt) => {
        console.log(`Retry attempt ${attempt} after deadlock: ${error.message}`)
      }
    }
  })
})

Retries and transactions

Queries running inside a transaction are never retried.

When MySQL raises a deadlock (ER_LOCK_DEADLOCK / errno 1213) it rolls back the entire transaction, not just the statement that failed. Retrying that statement on the same connection would run it — and every statement after it — as a standalone, implicitly committed statement. The transaction looks like it succeeded while its earlier writes are gone: silent data corruption.

So the dialect only retries statements that run in autocommit mode. Inside a transaction the error is re-thrown immediately, letting Kysely roll back cleanly. Transaction boundary statements (BEGIN, START TRANSACTION, COMMIT, ROLLBACK) are never retried either. Raw savepoint SQL is understood too: ROLLBACK TO SAVEPOINT x only undoes part of the work, so the connection is still treated as being inside a transaction afterwards.

ER_LOCK_WAIT_TIMEOUT is treated the same way. By default MySQL only rolls back the failing statement for that error, but innodb_rollback_on_timeout=ON changes that and the dialect cannot detect the setting, so it applies the conservative rule. executeTransactionWithRetry does retry it, since replaying a whole transaction is safe either way — this also covers servers running innodb_deadlock_detect=OFF, where contention surfaces as a lock wait timeout instead of a deadlock.

To retry work that spans a transaction, replay the whole transaction:

import { executeTransactionWithRetry } from 'mysql-dialect-with-deadlock'

await executeTransactionWithRetry(
  db,
  async (trx) => {
    await trx.updateTable('accounts').set({ balance: 0 }).where('id', '=', 1).execute()
    await trx.updateTable('accounts').set({ balance: 100 }).where('id', '=', 2).execute()
  },
  { maxAttempts: 5, initialDelayMs: 200 }
)

Each attempt opens a brand new transaction, so atomicity is preserved. The callback may run more than once — it must not depend on state mutated by a previous attempt.

Configuration

Dialect options

MySQLDialectWithDeadlockRetries accepts everything Kysely's own MysqlDialect does, plus deadlock.

Option Type Description
pool MysqlPool | () => Promise<MysqlPool> A mysql2 pool, or a function returning one
onCreateConnection function Called once per new connection
onReserveConnection function Called every time a connection is checked out
deadlock RetryOptions Retry behaviour — see below. Omit to disable retries entirely

deadlock

Applied to standalone queries only. Retries are opt-in: without a deadlock object the dialect behaves exactly like Kysely's stock MysqlDialect. An empty object (deadlock: {}) is enough to enable them with the defaults below.

Option Type Default Description
maxAttempts number 3 Maximum number of attempts, including the first
delay number 50 Delay in ms between retries
backoff boolean false Use exponential backoff for delays
timeout number undefined Overall timeout in ms across all attempts
retryOn function undefined Extra predicate; only consulted for lock errors
onRetry function undefined Callback function called on each retry

retryOn can only narrow the retry set. It is consulted after the error has already been identified as a transient lock conflict, so it cannot be used to retry unrelated errors.

executeTransactionWithRetry options

Option Type Default Description
maxAttempts number 3 Maximum number of attempts, including the first
initialDelayMs number 200 Delay before the first retry
backoffFactor number 2 Delay multiplier after each attempt (1 = constant)
retryOn function lock conflicts Decides whether an error should be retried
onRetry function undefined Callback function called before each retry
isolationLevel IsolationLevel server default Forwarded to setIsolationLevel, re-applied on every attempt
accessMode AccessMode server default Forwarded to setAccessMode, re-applied on every attempt

The action callback may run more than once, so it must be idempotent — no dependence on state a previous attempt mutated, and no un-repeatable side effects (emails, queue publishes) inside the block. The helper works with any Kysely instance; it does not require this dialect.

Transaction settings are re-issued for each attempt. On MySQL SET TRANSACTION applies to the next transaction only, so a retry that inherited nothing would silently run at the server's default isolation level:

await executeTransactionWithRetry(db, action, {
  isolationLevel: 'serializable',
  maxAttempts: 5,
})

Exports

Export Kind Description
MySQLDialectWithDeadlockRetries class The dialect
MysqlWithDeadlockRetriesConfig type Its constructor options
executeTransactionWithRetry function Replays a whole transaction on lock conflicts
TransactionRetryOptions type Its options

Streaming (.stream()) is supported and passes through unchanged — streamed queries are not retried.

License

MIT

About

A modified version of the MySQL Dialect for Kysely with deadlock retries.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages