Repository navigation
fix(sql): keep a dedup id on a single job - #31
Conversation
A dispatch with .dedup() could enqueue a second job, or return the id of a job that was never stored, when it raced with a worker acquiring the job that owned the dedup id. The unique index only covered pending and delayed jobs, so a job left it once acquired. A concurrent dispatch that had read no owner then either inserted a duplicate, or lost the insert and looked for the winner among pending and delayed jobs only. Finding none, Knex returned "added" with the id of the rolled back job and Kysely threw "Unable to resolve concurrent dedup dispatch". MySQL has no partial index, so it had no constraint at all, and its SELECT ... FOR UPDATE deadlocked with the completion of the owner. A plain unique index on (queue, dedup_id) now lets a single job own a dedup id, whatever its status, on PostgreSQL, MySQL and SQLite: jobs without dedup hold NULL, which is never a duplicate. Removing the job releases the id. The dispatch no longer opens a transaction. It reads the owner and inserts when there is none; on a unique violation it reads the owner again. The payload swap, the TTL refresh and the release of an expired id are single UPDATE statements that carry the state they rely on in their WHERE clause, so a job acquired in the meantime is not replaced and an id extended in the meantime is not released. An expired id is now released whatever the status of its job, retained ones included. On MySQL, a dispatch picked as the deadlock victim among concurrent inserts of the same id is retried. addDedupColumns() creates the unique index on existing tables. Where several jobs share a dedup id, the latest keeps it and the others lose only their dedup id. The previous indexes are dropped. Without the migration, sequential dispatches are still deduplicated, but concurrent ones are not.
The dispatch read the owner of a dedup id without a lock, and guarded each update with the state it had read. A change between the read and the update could still be lost: a replace rewrites the whole data column, so a job retried in that window got its attempts counter set back. The dispatch runs in a transaction again, and locks the owner with SELECT ... FOR UPDATE before it skips, extends, replaces or releases it. The updates are plain again. The owner is found through the dedup index without a lock, then locked through its primary key, as a worker locks a job. Locking it through the dedup index takes the index entry then the row, while the removal of a completed job takes the row then the index entry, and the two deadlocked on MySQL. FOR UPDATE cannot lock an owner that does not exist yet, so concurrent first dispatches still race on the unique index. The one that loses the insert runs its transaction again and locks the winner. The same applies to a dispatch MySQL picks as a deadlock victim among concurrent inserts of the same id.
Why the owner is locked through its primary key (MySQL)
In InnoDB, every index entry has its own lock, and a statement takes its locks in the order it walks the indexes:
Those are opposite orders. When a dispatch and the completion of the same job overlap, each holds one lock and waits for the other. InnoDB resolves the deadlock by aborting one of the two. In my runs it was So the dispatch does this instead:
The dispatch and the worker now both start with the row, so one waits for the other. Step 1 holds no lock, so the row can change before step 2. That is why the code checks Measured with 6 dispatchers and 4 workers completing jobs on 3 dedup ids, Knex adapter on MySQL:
|
Why the dispatch runs in a loop
The rejected dispatch cannot carry on in the same transaction:
So it rolls back and runs the transaction again. The second pass finds the owner, locks it, and takes the regular "owner exists" path. The loop is only that: run the transaction again after losing the insert.
The loop runs once, or twice when the insert is lost. A third pass needs another concurrent change at the same moment, such as the winner being completed and removed before it is read again. Two more cases go through it:
|
Case-insensitive SQL lookups can match a dedup key whose spelling differs from the input. Comparing that stored key with the input rejected the owner and retried a conflicting insert forever. Compare the locked key with the value read by the initial lookup instead, keeping the primary-key locking query unchanged. Cover both adapters with case-insensitive SQLite dispatch tests.
A dispatch with dedup ran its transaction again after any unique violation or deadlock, with no limit. Its only exit was finding the owner of the dedup id, so a violation that a retry never resolves, such as the collation mismatch fixed in the previous commit or a future unique index on the jobs table, made the dispatch loop forever. The transaction now runs at most DEDUP_DISPATCH_ATTEMPTS times, then the dispatch fails with the last error as its cause. Losing the insert to a concurrent dispatch takes one extra pass, so five leaves room for an owner that changes in between. Also cover the guard that rethrows when the job id itself is the duplicate: the test expects the primary key violation, not the error of the bounded retries.
A dispatch that loses the insert of a dedup id runs again and treats the winner as an existing owner, so replace and extend apply to it. The README said it always returned skipped.
On MySQL, the workers of these tests can be picked as deadlock victims by concurrent acquisitions, a popFrom issue unrelated to deduplication, which failed the Kysely MySQL group about one run in ten. A deadlocked poll now counts as an empty one, so the tests only check dedup.
Bounding the retries to five attempts made dispatches fail under contention on MySQL: with workers completing the owners of a few dedup ids while several dispatchers insert them, a dispatch can lose the insert more than five times in a row, each time to a dispatch that succeeded. The CI run of the concurrent dedup test failed that way. Each extra pass follows a change made by another transaction, so the loop no longer has a bound. The test of a duplicate job id still expects the primary key violation.
This reverts commit 7e4a40c. A popFrom deadlock on MySQL is a defect: the worker reports an error and waits before polling again. Treating it as an empty poll hid it from the only tests that run into it. It is fixed separately.
* fix(knex): prune completed jobs by count on MySQL 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. * fix(sql): claim jobs under READ COMMITTED on MySQL 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 * docs: note that MySQL claims need a row or mixed binlog 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. * fix(sql): prune jobs by count in a single statement 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.
…slot # Conflicts: # README.md # tests/kysely_adapter.spec.ts
|
Thanks! 🔥 |
Problem
With the Knex and Kysely adapters, a dispatch using
.dedup()could:addedwith the id of a job that was never stored (Knex), or throwUnable to resolve concurrent dedup dispatch(Kysely).Both happen when the dispatch races with a worker acquiring the job that owns the dedup id.
Cause
The unique index only covered
pendinganddelayedjobs, so a job left the index as soon as it was acquired. A concurrent dispatch that had read no owner then:active, orpendinganddelayedjobs only, and found none.MySQL has no partial index, so it had no constraint at all (the caveat documented in the README). Its
SELECT ... FOR UPDATEthrough the dedup index also deadlocked with the completion of the owner.Fix
Schema. A plain unique index on
(queue, dedup_id)replaces the partial one. A single job owns a dedup id, whatever its status. It works the same on PostgreSQL, MySQL and SQLite, because jobs without dedup holdNULL, which is never a duplicate. Removing the job releases the id, socompleteJob,failJoband pruning are unchanged.Dispatch. Still one transaction, with the same two cases:
SELECT ... FOR UPDATE, then skipped, extended, replaced, or released when expired, with plain updates under that lock.What changed compared to
main:pendinganddelayedjobs, are gone. Running the transaction again replaces both.completedandfailedjobs used to keep theirs, which the unique index does not allow.Upgrading
Existing tables need the new index.
addDedupColumns()creates it, and stays idempotent:.dedup()should be stopped while it runs.Without the migration, the new code still deduplicates sequential dispatches, but concurrent ones can race as before.
Tests
dedup: concurrent dispatches stay deduplicated while jobs are acquired: 4 dispatchers against a worker acquiring jobs. Exactly one job per dedup id, and every returned id is a stored job.dedup: concurrent dispatches do not fail while jobs are completed: dispatchers against workers completing jobs. Everyaddedjob is completed.addDedupColumns should leave a dedup slot to its latest job, for Knex (SQLite, PostgreSQL) and Kysely (MySQL).The two concurrency tests fail on
main: Knex on PostgreSQL reports moreaddeddispatches than stored jobs, and Kysely throws on PostgreSQL and MySQL.The adapter specs (
adapter.spec.ts,kysely_adapter.spec.ts,fake_adapter.spec.ts) pass on PostgreSQL, MySQL, SQLite and Redis.The Knex adapter has no MySQL test group, so I checked both adapters on MySQL by hand, with a script running 6 dispatchers and 4 workers on 3 dedup ids. Dispatches and completions ended without an error, where
mainhad 265 deadlocks.Not in this PR
The same script shows
popFromhittingER_LOCK_DEADLOCKon MySQL a few times per run with the Kysely adapter. It happens with plain jobs too, without any dedup, so it is a separate issue.Commits
The first commit made the dispatch lock-free, with conditional updates. The second one brings the transaction and the row lock back, which is the design described above. They can be squashed.
🤖 Generated with Claude Code