From fb24d2c061dd3b39b864d959ccc30cc2a298d5c2 Mon Sep 17 00:00:00 2001 From: wintan1418 Date: Tue, 18 Aug 2026 19:31:12 +0100 Subject: [PATCH] Release blocked executions whose job class no longer resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #784 --- app/models/solid_queue/blocked_execution.rb | 5 +++ .../solid_queue/blocked_execution_test.rb | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+) 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..251e4924 100644 --- a/app/models/solid_queue/blocked_execution.rb +++ b/app/models/solid_queue/blocked_execution.rb @@ -62,6 +62,11 @@ def set_expires_at end def acquire_concurrency_lock + # A job whose class no longer resolves can't check its concurrency limits, but it + # can't hold a semaphore either. Release it without one so it fails on execution + # instead of crashing the dispatcher's concurrency maintenance forever. + return true unless job.concurrency_limited? + Semaphore.wait(job) 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..82ef5b07 --- /dev/null +++ b/test/models/solid_queue/blocked_execution_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +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 + + test "release a blocked execution whose job class no longer resolves" do + NonOverlappingJob.perform_later(@result) + NonOverlappingJob.perform_later(@result) + + blocked_job = SolidQueue::Job.last + assert blocked_job.blocked? + + # Simulate the job class being renamed or deleted in a later deploy + SolidQueue::Job.where(id: blocked_job.id).update_all(class_name: "NoLongerExistingJob") + semaphore_value = SolidQueue::Semaphore.find_by!(key: blocked_job.concurrency_key).value + + assert SolidQueue::BlockedExecution.release_one(blocked_job.concurrency_key) + + # The execution is promoted to ready, where it will fail on execution and + # be recorded as failed, instead of being retried by the dispatcher forever + assert_not SolidQueue::BlockedExecution.exists?(job_id: blocked_job.id) + assert SolidQueue::ReadyExecution.exists?(job_id: blocked_job.id) + + # Without concurrency limits to check, no semaphore slot is taken + assert_equal semaphore_value, SolidQueue::Semaphore.find_by!(key: blocked_job.concurrency_key).value + end +end