A custom MySQL dialect for Kysely that automatically retries queries when they encounter deadlock errors.
- 🔄 Automatic retry of standalone queries that fail due to deadlocks
- 🛡️ Fails fast inside transactions, so atomicity is never broken
- ♻️
executeTransactionWithRetryto 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
npm install mysql-dialect-with-deadlock
# or
pnpm add mysql-dialect-with-deadlockimport { 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}`)
}
}
})
})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.
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 |
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.
| 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,
})| 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.
MIT