diff --git a/app/models/solid_queue/blocked_execution.rb b/app/models/solid_queue/blocked_execution.rb index 68551a5f5..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 @@ -46,7 +48,15 @@ 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). + # 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[:failed] = true + elsif acquire_concurrency_lock promote_to_ready destroy! @@ -58,7 +68,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..01a096df5 --- /dev/null +++ b/test/models/solid_queue/blocked_execution_test.rb @@ -0,0 +1,50 @@ +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 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 + 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, + -> { SolidQueue::FailedExecution.count } => 1 do + assert_nothing_raised do + blocked.reload.release + end + end + + # 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