Skip to content

Release blocked executions whose job class no longer resolves - #787

Open
wintan1418 wants to merge 1 commit into
rails:mainfrom
wintan1418:guard-blocked-release-against-missing-job-class
Open

Release blocked executions whose job class no longer resolves#787
wintan1418 wants to merge 1 commit into
rails:mainfrom
wintan1418:guard-blocked-release-against-missing-job-class

Conversation

@wintan1418

Copy link
Copy Markdown
Contributor

Fixes #784

Problem

When a SolidQueue::BlockedExecution row exists for a job whose class_name no longer safe_constantizes (class renamed, removed, or re-namespaced between deploys), the dispatcher's concurrency maintenance crashes on every tick, forever:

ActiveSupport::DelegationError: concurrency_limit delegated to job_class, but job_class is nil

BlockedExecution#release calls Semaphore.wait(job) unconditionally, and Semaphore::Proxy#limit/#expires_at dereference job.concurrency_limit/job.concurrency_duration, which are delegated to job_classnil in this scenario. The release transaction rolls back, so unblock_blocked_executions picks up the same row again on the next tick and the orphan is never released or cleaned up.

Fix

Job#concurrency_limited? already treats jobs without a resolvable class as not concurrency-limited, and Job#acquire_concurrency_lock consults it before touching the semaphore — the blocked execution release path was the only one that didn't. This applies the same guard there: the execution is promoted to ready without taking a semaphore slot, so the job fails on execution with a proper NameError and shows up as a failed execution (visible, retriable, discardable), instead of wedging the dispatcher indefinitely.

Test

The regression test reproduces the exact DelegationError from the issue on main and passes with the fix. It also asserts the semaphore value is untouched by the release, since there are no concurrency limits left to enforce for a class that is gone.

BlockedExecution#release called Semaphore.wait(job) unconditionally, and
the semaphore proxy dereferences job.concurrency_limit and
job.concurrency_duration, which are delegated to job_class — nil when the
job's class was renamed or removed between deploys. The release
transaction raised DelegationError and rolled back, so the dispatcher's
concurrency maintenance picked up the same blocked execution and crashed
again on every tick, forever, and the row was never cleaned up.

Job#concurrency_limited? already treats jobs without a resolvable class
as not limited, and Job#acquire_concurrency_lock consults it before
touching the semaphore; the blocked execution release path was the only
one that didn't. Apply the same guard there: promote the execution to
ready without taking a semaphore slot, so the job fails on execution with
a proper NameError and becomes visible as a failed execution instead of
wedging the dispatcher.

Fixes rails#784
@wintan1418
wintan1418 force-pushed the guard-blocked-release-against-missing-job-class branch from 3deff3d to fb24d2c Compare August 18, 2026 18:55
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.

DelegationError in BlockedExecution release path when a job's class_name no longer resolves

1 participant