Skip to content

fix: enforce concurrency_limit across concurrent workers - #762

Closed
janbjorge wants to merge 2 commits into
mainfrom
fix/global-concurrency-limit-race
Closed

fix: enforce concurrency_limit across concurrent workers#762
janbjorge wants to merge 2 commits into
mainfrom
fix/global-concurrency-limit-race

Conversation

@janbjorge

@janbjorge janbjorge commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Global concurrency_limit can be exceeded when workers dequeue concurrently: the capacity count in the dequeue CTE reads its own statement snapshot (a concurrent uncommitted claim is invisible), and LIMIT ... FOR UPDATE SKIP LOCKED slides past rows locked by another worker onto the remaining queued rows.

  • Deterministic regression test: worker A's claim held open in a transaction, worker B must claim nothing (test_concurrency_limit_holds_across_concurrent_dequeues)
  • Fix candidate-window so SKIP LOCKED cannot slide past in-flight claims
  • Make the limit airtight across snapshots

Closes #761

Two workers on separate connections both observe zero picked jobs
and claim past the entrypoint limit: the capacity count reads the
statement snapshot while SKIP LOCKED slides past rows locked by the
concurrent claim. The interleaving is forced deterministically by
holding worker A's claim transaction open and polling
pg_stat_activity, so the test needs no timing sleeps.

Fails until the dequeue capacity check is fixed.

Refs #761
Two mechanisms let concurrent dequeues exceed a global entrypoint
limit: the capacity count reads its own statement snapshot, so an
uncommitted claim by another worker is invisible, and LIMIT with
FOR UPDATE SKIP LOCKED applies the limit after skipping, sliding
past locked rows onto the remaining queued ones.

Two layered fixes:

- Limited entrypoints fix a candidate window (top rows up to the
  remaining capacity) before any locking, then lock each window row
  by primary key in its own LATERAL. SKIP LOCKED and EvalPlanQual
  can only shrink the pick, never reach outside the window.
- Every fresh claim on a limited entrypoint takes a capacity slot in
  0..limit-1, guarded by a partial unique index on (entrypoint, slot)
  WHERE status = 'picked'. The btree uniqueness check sees uncommitted
  tuples, so the limit holds across snapshots even when a higher
  priority enqueue reshapes a racing worker's window. Losing the slot
  race reports an empty batch; the winner's commit fires the
  table-changed notification that re-wakes the loser.

Unlimited entrypoints keep the previous direct locked scan. Rows
picked before the migration hold no slot and are covered by the
count gate only until they drain.

Refs #761
@janbjorge
janbjorge force-pushed the fix/global-concurrency-limit-race branch from 7751654 to c763071 Compare August 29, 2026 19:48
@janbjorge
janbjorge marked this pull request as ready for review August 30, 2026 11:44
@janbjorge

Copy link
Copy Markdown
Owner Author

Superseded. Windowing landed in #774, claim-set naming in #775, slots continue in #777. The composer redesign here was not carried over; branch kept if we want it as its own PR.

@janbjorge janbjorge closed this Aug 30, 2026
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