From 9a792233ed7d197bf57b5cd2976d6893300037c1 Mon Sep 17 00:00:00 2001 From: Julik Tarkhanov Date: Sat, 1 Aug 2026 21:57:13 +0200 Subject: [PATCH] Destroy orphan BlockedExecution rows when the job class no longer resolves 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. --- app/models/solid_queue/blocked_execution.rb | 10 ++++- .../solid_queue/job/concurrency_controls.rb | 8 ++-- .../solid_queue/blocked_execution_test.rb | 44 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 test/models/solid_queue/blocked_execution_test.rb diff --git a/app/models/solid_queue/blocked_execution.rb b/app/models/solid_queue/blocked_execution.rb index 68551a5f..05e10d46 100644 --- a/app/models/solid_queue/blocked_execution.rb +++ b/app/models/solid_queue/blocked_execution.rb @@ -46,7 +46,12 @@ def releasable(concurrency_keys) def release SolidQueue.instrument(:release_blocked, job_id: job.id, concurrency_key: concurrency_key, released: false) do |payload| transaction do - if acquire_concurrency_lock + if job.job_class.nil? + # The job's class no longer resolves (renamed/removed between deploys). + # Destroy the orphan row so the dispatcher stops retrying it forever. + destroy! + payload[:orphaned] = true + elsif acquire_concurrency_lock promote_to_ready destroy! @@ -58,7 +63,8 @@ def release private def set_expires_at - self.expires_at = job.concurrency_duration.from_now + duration = job.job_class ? job.concurrency_duration : SolidQueue.default_concurrency_control_period + self.expires_at = duration.from_now end def acquire_concurrency_lock diff --git a/app/models/solid_queue/job/concurrency_controls.rb b/app/models/solid_queue/job/concurrency_controls.rb index 30d4399e..b59c464a 100644 --- a/app/models/solid_queue/job/concurrency_controls.rb +++ b/app/models/solid_queue/job/concurrency_controls.rb @@ -33,6 +33,10 @@ def blocked? blocked_execution.present? end + def job_class + @job_class ||= class_name.safe_constantize + end + private def concurrency_on_conflict job_class.concurrency_on_conflict.to_s.inquiry @@ -66,10 +70,6 @@ def release_next_blocked_job BlockedExecution.release_one(concurrency_key) end - def job_class - @job_class ||= class_name.safe_constantize - end - def execution super || blocked_execution end diff --git a/test/models/solid_queue/blocked_execution_test.rb b/test/models/solid_queue/blocked_execution_test.rb new file mode 100644 index 00000000..1ec42514 --- /dev/null +++ b/test/models/solid_queue/blocked_execution_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class SolidQueue::BlockedExecutionTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + class NonOverlappingJob < ApplicationJob + limits_concurrency key: ->(job_result, **) { job_result } + + def perform(job_result) + end + end + + setup do + @result = JobResult.create!(queue_name: "default") + end + + teardown do + SolidQueue::Job.destroy_all + SolidQueue::Semaphore.delete_all + JobResult.delete_all + end + + test "release destroys the blocked row when the job class no longer resolves" do + # Enqueue and consume the semaphore so the next job blocks. + NonOverlappingJob.perform_later(@result) + blocking_job = SolidQueue::Job.last + NonOverlappingJob.perform_later(@result) + blocked_job = SolidQueue::Job.last + blocked = blocked_job.blocked_execution + assert blocked, "expected the second job to be blocked" + + # Simulate the class being renamed/removed between deploys + blocked_job.update_columns(class_name: "GoneJob") + + assert_difference -> { SolidQueue::BlockedExecution.count }, -1 do + assert_nothing_raised do + blocked.reload.release + end + end + + # No ready execution was promoted — the orphan row was just cleaned up. + assert_nil SolidQueue::ReadyExecution.find_by(job_id: blocked_job.id) + end +end