From 9a792233ed7d197bf57b5cd2976d6893300037c1 Mon Sep 17 00:00:00 2001 From: Julik Tarkhanov Date: Sat, 1 Aug 2026 21:57:13 +0200 Subject: [PATCH 1/2] 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 68551a5f5..05e10d464 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 30d4399ed..b59c464ad 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 000000000..1ec425149 --- /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 From ccfac3bbc3bc70c5220b39a950a8a20b8ddfa59e Mon Sep 17 00:00:00 2001 From: Julik Tarkhanov Date: Sun, 30 Aug 2026 16:26:35 +0200 Subject: [PATCH 2/2] Surface orphan BlockedExecution as FailedExecution instead of dropping it Per review feedback: instead of silently discarding a blocked job whose class no longer resolves, mark it as failed so it shows up in Mission Control where it can be retried (once the class is restored) or discarded. Either way the blocked row is destroyed so the dispatcher stops re-picking it. --- app/models/solid_queue/blocked_execution.rb | 9 +++++++-- test/models/solid_queue/blocked_execution_test.rb | 12 +++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/models/solid_queue/blocked_execution.rb b/app/models/solid_queue/blocked_execution.rb index 05e10d464..2d178def2 100644 --- a/app/models/solid_queue/blocked_execution.rb +++ b/app/models/solid_queue/blocked_execution.rb @@ -2,6 +2,8 @@ module SolidQueue class BlockedExecution < Execution + class JobClassMissingError < RuntimeError; end + assumes_attributes_from_job :concurrency_key before_create :set_expires_at @@ -48,9 +50,12 @@ def release transaction do 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. + # Mark the job as failed so it surfaces in Mission Control (where it can + # be retried once the class is restored, or discarded), and destroy the + # orphan row so the dispatcher stops retrying it forever. + job.failed_with(JobClassMissingError.new("Job class #{job.class_name.inspect} could not be resolved")) destroy! - payload[:orphaned] = true + payload[:failed] = true elsif acquire_concurrency_lock promote_to_ready destroy! diff --git a/test/models/solid_queue/blocked_execution_test.rb b/test/models/solid_queue/blocked_execution_test.rb index 1ec425149..01a096df5 100644 --- a/test/models/solid_queue/blocked_execution_test.rb +++ b/test/models/solid_queue/blocked_execution_test.rb @@ -20,7 +20,7 @@ def perform(job_result) JobResult.delete_all end - test "release destroys the blocked row when the job class no longer resolves" do + test "release marks the job as failed and 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 @@ -32,13 +32,19 @@ def perform(job_result) # Simulate the class being renamed/removed between deploys blocked_job.update_columns(class_name: "GoneJob") - assert_difference -> { SolidQueue::BlockedExecution.count }, -1 do + assert_difference -> { SolidQueue::BlockedExecution.count } => -1, + -> { SolidQueue::FailedExecution.count } => 1 do assert_nothing_raised do blocked.reload.release end end - # No ready execution was promoted — the orphan row was just cleaned up. + # No ready execution was promoted — the orphan is now surfaced as a failed execution. assert_nil SolidQueue::ReadyExecution.find_by(job_id: blocked_job.id) + + failed = SolidQueue::FailedExecution.find_by(job_id: blocked_job.id) + assert failed, "expected a failed execution to be created for the orphan" + assert_equal "SolidQueue::BlockedExecution::JobClassMissingError", failed.exception_class + assert_match "GoneJob", failed.message end end