Skip to content

Give concurrency slots back only to the semaphore they came from - #831

Merged
rosa merged 5 commits into
mainfrom
release-concurrency-locks-by-semaphore
Nov 3, 2026
Merged

rosa merged 5 commits into
mainfrom
release-concurrency-locks-by-semaphore

Conversation

@rosa

@rosa rosa commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Fixes #827, and replaces #825.

A concurrency semaphore counts free slots, but it doesn't know which jobs hold them, so any job that gave a slot back by key was treated as a holder. That went wrong in three ways:

  • A job that ran past its duration (A job that outlived its concurrency lock releases the lock of the job that replaced it #827). The dispatcher deletes the expired semaphore and the next job creates a new one. When the first job finishes, it gives its slot back to the new semaphore, so another job starts while the new holder is still running. With a backlog, the key stays one over its limit until the backlog drains.
  • A job whose class was missing when it was dispatched. It skips its limits and takes no slot, but if it later runs where its class loads, it gives one back anyway.
  • A job whose class is missing when it runs (Release the concurrency lock of a job whose class is missing #825). It took a slot when it was dispatched but can't give it back, because releasing needed the class, so the jobs behind it wait until the semaphore expires.

Now, when a job gets a slot, it records the semaphore's id and its concurrency limit in its serialized Active Job data, under solid_queue_concurrency_lock, which Active Job ignores when running the job. It gives the slot back only to that semaphore. A job that skips its limits records that it holds no slot. A blocked job records its semaphore when it's released.

Jobs that got their slot before upgrading have nothing recorded, so they keep giving it back by key, as they do today. Old and new versions can run side by side during a deploy. 2.0 will drop that fallback, so ready and running jobs from before the upgrade should finish first: a slot that isn't given back only returns when its semaphore expires, and on a busy key with a limit above 1 the other jobs keep pushing that expiry out.

One case this doesn't cover, and that we accept: during a deploy, if a failed job is retried by a process still on the old version and then run by one on the new version, the old process doesn't update the recorded semaphore, so the new one gives the slot back to the wrong place. With a limit of 1, the slot comes back when the semaphore expires; with a higher limit on a busy key, the key can run one slot short for as long as it stays busy. It needs all of that to happen inside the deploy window, and it never lets jobs run over the limit.

The change is meant to ship on its own in 1.7.2, with a note in UPGRADING.

The first two tests come from the reproduction in #827 by @npadgett.

🤖 Generated with Claude Code

rosa and others added 4 commits November 3, 2026 19:27
A semaphore counts free slots but doesn't know which jobs hold them, so
any job that gave a slot back by key counted as a holder:

- A job that ran past its duration gave its slot back to the semaphore
  created after its own expired, letting another job start while the
  new holder was still running (#827).
- A job dispatched while its class was missing skipped its limits, but
  gave a slot back if it later ran where its class loads.
- A job whose class was missing when it ran couldn't give back the slot
  it got when it was dispatched (#825).

Now a job records the id of the semaphore it got a slot on, and its
limit, in its serialized Active Job data, and gives the slot back only
to that semaphore. A job that skips its limits records that it holds
no slot. Jobs that got their slot before this change have nothing
recorded and keep giving it back by key, until 2.0.

The first two tests come from the reproduction in #827.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On MySQL with REPEATABLE READ, a plain read after taking a slot uses the
transaction's snapshot, which can still include a semaphore that expired
and was deleted after the snapshot was taken, so the job could record
that one instead of the semaphore it just created. A locking read sees
the latest committed rows.

A job that keeps its concurrency key after its class drops
limits_concurrency takes its slot with a limit of 1, so record that
rather than nil, which failed to give the slot back if the class was
also missing when the job ran.

Also cover jobs sharing a semaphore, discarding ready jobs one by one
and in bulk, and retried jobs, and explain the change in UPGRADING.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the upgrade note

A plain read inside a REPEATABLE READ transaction can still see a
semaphore deleted after the transaction's snapshot, so without the
locking read the job would record that one. The test fails without it.

A slot that isn't given back comes back only when its semaphore expires,
and every job taking or giving back a slot for the same key pushes that
out, so on a busy key with a limit above 1 it may not come back for a
long time. Say so in UPGRADING instead of promising it comes back after
duration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rosa
rosa force-pushed the release-concurrency-locks-by-semaphore branch 2 times, most recently from 5f852d5 to 9dcd3f4 Compare November 3, 2026 19:07
concurrency_limited? now just means the job has a concurrency key, as on
the Active Job side, and the code checks for a missing class where that
matters. The job exposes the semaphore it got its slot from and the
limit it got it with, so the semaphore asks for those instead of
reading the recorded data itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rosa
rosa force-pushed the release-concurrency-locks-by-semaphore branch from 9dcd3f4 to 0c06081 Compare November 3, 2026 19:26
@rosa
rosa merged commit 8b2a87b into main Nov 3, 2026
229 of 232 checks passed
@rosa
rosa deleted the release-concurrency-locks-by-semaphore branch November 3, 2026 19:58
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.

A job that outlived its concurrency lock releases the lock of the job that replaced it

1 participant