Skip to content

fix(sql): stop MySQL deadlocks when workers acquire jobs - #33

Merged
RomainLanz merged 4 commits into
mainfrom
fix/mysql-claim-deadlock
Oct 12, 2026
Merged

RomainLanz merged 4 commits into
mainfrom
fix/mysql-claim-deadlock

Conversation

@RomainLanz

Copy link
Copy Markdown
Member

Problem

On MySQL, popFrom() of the Knex and Kysely adapters failed with ER_LOCK_DEADLOCK when several workers acquired jobs while other processes dispatched jobs. Dedup is not necessary. The Worker then waited 5 s (DEFAULT_ERROR_RETRY_DELAY) before it polled again. The deadlock showed while #31 was reviewed.

Cause

The deadlock report of InnoDB shows two claim transactions that wait on each other.

MySQL uses REPEATABLE READ by default. At this level, SELECT ... FOR UPDATE SKIP LOCKED skips the locked rows, but it keeps a lock on the gap before each index entry that it reads. The claim UPDATE then changes the status of the row, so InnoDB inserts a new entry in the status indexes. This entry often goes into a gap that a concurrent claim holds.

  • Kysely schema (status is a varchar): the new active entry of status_score_idx goes into the gap before the first pending entry. Each popFrom() locks this gap, so two workers wait on each other.
  • Knex schema (status is an enum, in a different order): two promotions of delayed jobs (delayed to pending) deadlock on the execute_at and finished_at indexes. This occurs less frequently.

Fix

On MySQL only, the claims of the two SQL adapters now run under READ COMMITTED: popFrom(), the promotion of delayed jobs, recoverStalledJobs() and claimDueSchedule(). At this level, InnoDB locks only the claimed rows, as PostgreSQL does by default. A retry on ER_LOCK_DEADLOCK keeps the conflict and only hides it, so this PR does not add a retry. The dedup transactions keep REPEATABLE READ.

The README now tells that binlog_format=STATEMENT rejects writes under READ COMMITTED. ROW and MIXED work.

Also in this PR: Knex retention by count on MySQL

This PR adds a "Adapter | Knex (MySQL)" test group, which did not exist. This group found a different bug: retention by count used NOT IN (SELECT ... LIMIT n), and MySQL and MariaDB reject this query. Thus completeJob() and failJob() failed on MySQL when removeOnComplete or removeOnFail set a count. The adapter now reads the ids to keep first, as the Kysely adapter does. This fix is in a separate commit (eb7520f).

Tests

New test in the shared driver suite: "concurrent popFrom does not fail while jobs are dispatched". It runs 6 dispatchers of 400 jobs each (one job in two is delayed) and 4 workers. The test does not catch errors and does not retry, so a deadlock makes it fail.

Group Without the fix With the fix
Kysely MySQL fails 13 times in 13 (ER_LOCK_DEADLOCK) 0 failures in 20
Knex MySQL fails about 1 time in 10 0 failures in 20

The full suite passes (1575/1575) on MariaDB 10.11 (port 3307), PostgreSQL 15, SQLite and Redis.

Limit: all MySQL tests ran on MariaDB 10.11, not on MySQL 8.4 (the version of compose.yml and of the CI). Docker was not available. The CI run of this PR is the first run on MySQL 8.4.

Related #31

Retention by count deleted the jobs whose id was not in a subquery
ordered by finished_at with a LIMIT. MySQL and MariaDB reject LIMIT in
an IN subquery, so completeJob() and failJob() threw on MySQL as soon as
removeOnComplete or removeOnFail set a count.

The Knex adapter now reads the ids to keep first and deletes the others,
as the Kysely adapter does. The driver test suite now runs on Knex with
MySQL, which had no test group and missed this error.
On MySQL, popFrom() failed with ER_LOCK_DEADLOCK when several workers
acquired jobs while others were dispatched, with or without dedup. The
Worker then waited 5 s before polling again.

Under REPEATABLE READ, the MySQL default, SELECT ... FOR UPDATE SKIP
LOCKED skips locked rows but keeps a lock on the gap before every index
entry it scans. The claim UPDATE moves the row to another status, so
InnoDB inserts its new entry in the status index, often into a gap that
a concurrent claim holds. With the Kysely schema, status is a varchar:
the new 'active' entry lands in the gap before the first 'pending' one,
which every popFrom() locks, and two workers wait on each other. With
the Knex schema, an enum, two promotions of delayed jobs deadlock on the
execute_at and finished_at indexes the same way.

The claims of both SQL adapters (popFrom(), the promotion of delayed
jobs, recoverStalledJobs() and claimDueSchedule()) now run under READ
COMMITTED on MySQL, which locks the claimed rows only, as PostgreSQL
does by default. A retry on ER_LOCK_DEADLOCK would have kept the
conflict and only hidden it. Other transactions keep the default level.

Related #31
The SQL adapters now acquire jobs and schedules in READ COMMITTED
transactions on MySQL. InnoDB rejects writes at that level when
binlog_format is STATEMENT, so document which formats work.
Retention by count read the ids to keep, then deleted the other jobs
in a second statement, outside a transaction. Two workers could prune
between these steps: with { count: 1 }, A finished its job and read
[A], B finished a more recent job and pruned A, then the DELETE of A
removed B and no job remained. Kysely had pruned in two steps before;
the Knex adapter did so since eb7520f, which removed a LIMIT subquery
that MySQL rejects.

Both adapters now delete in one statement and select the ids to keep
through a derived table, which MySQL accepts with a LIMIT. A test
pauses the first worker before its DELETE, on two connections, and
checks that the latest job remains, for completeJob() and failJob() on
SQLite and MySQL.
@RomainLanz
RomainLanz merged commit 11c65af into main Oct 12, 2026
17 checks passed
@RomainLanz
RomainLanz deleted the fix/mysql-claim-deadlock branch October 12, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant