Skip to content

fix(qb): claim jobs through capacity slots - #777

Merged
janbjorge merged 3 commits into
mainfrom
fix/concurrency-limit-capacity-slots
Aug 30, 2026
Merged

fix(qb): claim jobs through capacity slots#777
janbjorge merged 3 commits into
mainfrom
fix/concurrency-limit-capacity-slots

Conversation

@janbjorge

@janbjorge janbjorge commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Concurrent workers could exceed an entrypoint's concurrency_limit. #774
fixed the case where both workers see the same queue ordering. This covers
the rest: a higher-priority job arriving inside another worker's in-flight
claim changes that ordering, so the second worker windows a row the first
never locked.

Every fresh claim on a limited entrypoint now takes a numbered slot in
0..limit-1, guarded by a partial unique index on
(entrypoint, slot) WHERE status = 'picked'. Counting picked rows reads a
snapshot and cannot see an uncommitted claim. A btree uniqueness check can,
so the loser of a slot race gets 23505 and reports an empty batch, which
the poll loop retries. Unlimited entrypoints carry no slot and stay outside
the index.

Rows picked before the migration hold no slot and are covered by the count
gate until they drain. Mixed limits across workers stay unsupported (#776).

Depends on #751: the slot column and its index need registering in the
schema manifest, which replaces the probes this touches in qm.py.

Closes #761

A picked job of a limited entrypoint will hold one slot in
0..limit-1, and a partial unique index on (entrypoint, slot)
rejects a second claim of the same slot even when the first is
invisible to the claimer's snapshot. Nothing writes the column
yet; the dequeue statement starts using it separately.

Fresh installs get the column inline. Existing installs get it
through the upgrade path, where rows picked before the migration
keep slot NULL, stay covered by the count gate alone, and drain
out as they complete.

Startup verification learns both objects, so a worker running the
slot-aware dequeue against an un-upgraded database fails loudly
rather than silently overshooting the limit.
The persistence layer had to ask whether a driver exception was a
unique violation, and answered it by importing asyncpg and psycopg
and testing isinstance against their error classes.

SqlStateError declares the shape both drivers share: an exception
carrying a PostgreSQL SQLSTATE. A new sqlstate module reads the
code through that protocol and names the two codes the dequeue
cares about. is_unique_violation keeps its name, since it is
re-exported from the public shim, but delegates now instead of
importing drivers.

Dequeue uses it to report an empty batch when it loses a capacity
slot race. Losing that race means the capacity went to another
worker, which is the same outcome as finding nothing claimable;
the winner's commit fires the notification that wakes the poll
loop.
Windowing candidates before the lock (#774) closes the reported
race, because both workers derive the same window and collide on
the same row locks. It stops holding as soon as the ordering
changes under them: a higher-priority job arriving inside another
worker's in-flight claim gives the second worker a window the
first never locked, and the limit is exceeded again.

Every fresh claim on a limited entrypoint now takes a slot in
0..limit-1. Two workers racing for the last slot both pass the
count gate, but the loser's index insertion waits on the winner
and fails with 23505, which dequeue reports as an empty batch for
the poll loop to retry. A stale re-pick keeps the slot it already
holds, so recovery is not gated.

The free-slot search generates held+batch seats rather than one
row per configured seat, so a poll costs the same at any limit.
Unbounded, concurrency_limit=5000 produced 286k plan rows and ran
67x slower than an ungated dequeue; bounded, it matches
concurrency_limit=10.

The ungated shapes render byte-identical SQL, which is what proves
this touches only entrypoints that carry a limit.

Closes #761
@janbjorge janbjorge changed the title ggFix/concurrency limit capacity slots fix(qb): claim jobs through capacity slots Aug 30, 2026
@janbjorge
janbjorge marked this pull request as ready for review August 30, 2026 12:06
@janbjorge
janbjorge merged commit 2f3a8b1 into main Aug 30, 2026
43 of 44 checks passed
@janbjorge
janbjorge deleted the fix/concurrency-limit-capacity-slots branch August 30, 2026 21:03
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.

Global concurrency_limit can be exceeded by concurrent workers

1 participant