perf: SKIP LOCKED when selecting jobs in the poll query - #138
Merged
Conversation
nicolasburtey
marked this pull request as ready for review
July 28, 2026 13:10
nicolasburtey
force-pushed
the
perf/poll-skip-locked
branch
from
July 28, 2026 13:11
7977b28 to
a7eb4a1
Compare
Member
|
I'm not sure this one might break the queue-id serial contract. |
nicolasburtey
marked this pull request as draft
July 28, 2026 15:39
nicolasburtey
marked this pull request as ready for review
July 28, 2026 15:40
The poll query locked its selected rows with plain FOR UPDATE. A concurrent poller running the same query blocks on those row locks until the first poller commits, then finds the rows no longer pending and yields nothing — pure serialized wait whenever more than one poller instance (or overlapping poll loops) is active. SKIP LOCKED lets each poller take the next available rows instead of queueing behind its peers. Single-poller deployments are unaffected; the state='pending' guard in the UPDATE CTE remains as the correctness backstop.
Guard for the SKIP LOCKED change: two independent Jobs services (two pollers, distinct instance ids) share one database and register the same job_type, so both are woken by the same notifications and race to claim the same queue head. Asserts that while the head job of a queue is running, the racing poller never starts the next same-queue job, and that the queue drains in order once the head completes. Single-poller queue tests only exercise the ROW_NUMBER/NOT EXISTS logic; this covers the lock-race path that SKIP LOCKED changes.
nicolasburtey
force-pushed
the
perf/poll-skip-locked
branch
from
July 28, 2026 15:49
6412383 to
c4d1bc8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The poll query selects candidate rows with plain
FOR UPDATE. When more than one poller instance (or overlapping poll loops in the same process) runs the query concurrently, each blocks on the rows the other has already locked, waits for the peer's transaction to commit, and then finds those rows no longerpending— yielding zero jobs for the wait. With multiple lana-bank pods polling the same database this serializes poll loops behind each other.Change
FOR UPDATE OF je→FOR UPDATE OF je SKIP LOCKEDinpoll_jobs.Each poller now takes the next available unlocked rows instead of queueing behind its peers. The existing
UPDATE ... WHERE je.state = 'pending'guard in theupdatedCTE remains as the correctness backstop, so a row taken between selection and update is still silently skipped.Single-poller deployments are unaffected.
Why this cannot break queue (
queue_id) serializationThe 1-at-a-time-per-queue guarantee is enforced by two layers that both run before row locking, and SKIP LOCKED only changes the locking step:
NOT EXISTSanti-join excludes pending jobs whose queue already has arunningrow.ROW_NUMBER() PARTITION BY COALESCE(queue_id, id::text)+WHERE rn = 1means one poll can select at most one job per queue.Race walk-through — poller A is mid-transaction taking queue head J1 (locked, uncommitted); poller B polls with a snapshot from before A commits:
pending, so B's anti-join passes — but B's window still ranks J1 as rn=1 and the queue's J2 as rn=2. B can only ever attempt the head J1; it can never select J2. SKIP LOCKED doesn't re-run the window — it just drops locked rows from the selection.running), and theupdatedCTE'sWHERE je.state = 'pending'guard rejects it → B starts nothing for that queue.In both variants the final
UPDATE ... WHERE je.state = 'pending're-evaluates on the newest committed row version, so the pending→running transition stays single-winner even if two pollers race the exact same row.The one real behavioral difference — latency, not correctness: if the lock holder rolls back (crash between selection and commit) rather than commits, a blocked waiter (old behavior) would take the job immediately, while a SKIP LOCKED poller has already moved on. That queue's head then waits for the next wake-up: any notify from other job activity, the
min_waittimer, the immediate re-poll after a non-empty batch, or worst caseMAX_WAIT(60s,src/poller.rs). Worst case is a bounded start delay for that queue — never two same-queue jobs running concurrently.Test coverage
Added
test_queue_id_serializes_execution_across_two_pollers: two independentJobsservices (two pollers, distinct instance ids) share one database and register the samejob_type, so both are woken by the same notifications and race to claim the same queue head. Asserts that while the head job is running (blocked), the racing poller never starts the next same-queue job, and that the queue drains in order after the head completes. Passes 5/5 standalone runs; full suite green except the known pre-existing flaketest_await_completions_resolves_when_notifications_dropped(also fails on unmodified main locally).Notes
.sqlxregenerated (cargo sqlx prepare --workspace).FOR UPDATEline.