Skip to content

Destroy orphan BlockedExecution rows when the job class no longer resolves - #783

Open
julik wants to merge 1 commit into
rails:mainfrom
julik:fix-blocked-execution-orphan-class
Open

Destroy orphan BlockedExecution rows when the job class no longer resolves#783
julik wants to merge 1 commit into
rails:mainfrom
julik:fix-blocked-execution-orphan-class

Conversation

@julik

@julik julik commented Aug 1, 2026

Copy link
Copy Markdown

Fixes #784.

Summary

Fixes a DelegationError raised on every SolidQueue::Dispatcher::ConcurrencyMaintenance tick when a BlockedExecution row references a Job whose class_name no longer safe_constantizes — e.g. because the concurrency-limited ActiveJob class was renamed or removed between deploys.

Job#acquire_concurrency_lock already gates on concurrency_limited? (which checks job_class.present?), but BlockedExecution#acquire_concurrency_lock — invoked from the dispatcher-driven release path — went straight to Semaphore.wait(job), which calls job.concurrency_limit, which is delegated to a nil job_class:

ActiveSupport::DelegationError: concurrency_limit delegated to job_class, but job_class is nil
  app/models/solid_queue/job/concurrency_controls.rb:11:in 'concurrency_limit'
  app/models/solid_queue/semaphore.rb:86:in 'limit'
  app/models/solid_queue/semaphore.rb:58:in 'attempt_creation'
  app/models/solid_queue/semaphore.rb:46:in 'wait'
  app/models/solid_queue/blocked_execution.rb:65:in 'acquire_concurrency_lock'
  app/models/solid_queue/blocked_execution.rb:49:in 'block (2 levels) in release'
  ...
  lib/solid_queue/dispatcher/concurrency_maintenance.rb:40

Because the exception is raised inside BlockedExecution#release's transaction, the row is never destroyed or promoted, and the dispatcher re-picks the same orphans forever — flooding error reporters and starving any legitimately blocked jobs that share the concurrency key.

Changes

  • BlockedExecution#release: if job.job_class is nil, destroy the orphan row and emit orphaned: true in the release_blocked instrumentation payload. Otherwise fall through to the existing acquire/promote/destroy path.
  • BlockedExecution#set_expires_at: fall back to SolidQueue.default_concurrency_control_period when the class does not resolve (guards the same latent bug at create time).
  • Job#job_class promoted from private to public so BlockedExecution can consult it.
  • New unit test covering the orphan release path.

Test plan

  • bin/rails test test/models/solid_queue/blocked_execution_test.rb — new test passes.
  • bin/rails test test/models/ — full model suite green (97 runs, 585 assertions, 0 failures).
  • CI across mysql/postgres/sqlite.

…olves

If an ActiveJob class with limits_concurrency is renamed or removed
between deploys, any BlockedExecution rows referencing the old class
name would cause the dispatcher's concurrency-maintenance tick to raise
DelegationError forever: release -> acquire_concurrency_lock ->
Semaphore.wait -> job.concurrency_limit, which delegates to a nil
job_class.

Guard the release path (symmetric with Job#acquire_concurrency_lock)
and short-circuit set_expires_at with the default concurrency period
when the class is unresolvable.
@julik
julik marked this pull request as ready for review August 1, 2026 20:07
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