Skip to content

Commit d903a36

Browse files
Stop workers when job finalization fails
Prevent claimed executions from remaining stuck after transient database errors by terminating the owning worker so shutdown releases the claim.
1 parent 74b6b07 commit d903a36

5 files changed

Lines changed: 117 additions & 4 deletions

File tree

app/models/solid_queue/claimed_execution.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ def success?
1111
end
1212
end
1313

14+
# Raised when a job has already run (or failed) but we couldn't update its
15+
# claim/finished state because of a transient error. The claim is still held
16+
# by a living worker, so it won't be recovered as orphaned unless the worker
17+
# is stopped and replaced.
18+
class FinalizationError < RuntimeError
19+
def initialize(claimed_execution, cause:)
20+
super("Failed to finalize claimed execution #{claimed_execution.id} (job #{claimed_execution.job_id}): #{cause.class}: #{cause.message}")
21+
set_backtrace(cause.backtrace) if cause.backtrace
22+
end
23+
end
24+
1425
class << self
1526
def claiming(job_ids, process_id, &block)
1627
job_data = Array(job_ids).collect { |job_id| { job_id: job_id, process_id: process_id } }
@@ -70,6 +81,12 @@ def perform
7081
failed_with(result.error)
7182
raise result.error
7283
end
84+
rescue FinalizationError
85+
raise
86+
rescue => error
87+
raise FinalizationError.new(self, cause: error) if still_claimed?
88+
89+
raise
7390
end
7491

7592
def release
@@ -122,4 +139,12 @@ def unless_already_finalized
122139
yield
123140
end
124141
end
142+
143+
def still_claimed?
144+
self.class.exists?(id)
145+
rescue
146+
# If we can't check because the DB is unavailable, assume the claim is
147+
# still held so the worker can be stopped and replaced.
148+
true
149+
end
125150
end

lib/solid_queue/pool.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ class Pool
88

99
delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
1010

11-
def initialize(size, on_idle: nil)
11+
def initialize(size, on_idle: nil, on_unrecoverable_error: nil)
1212
@size = size
1313
@on_idle = on_idle
14+
@on_unrecoverable_error = on_unrecoverable_error
1415
@available_threads = Concurrent::AtomicFixnum.new(size)
1516
@mutex = Mutex.new
1617
end
@@ -26,6 +27,7 @@ def post(execution)
2627
mutex.synchronize { on_idle.try(:call) if idle? }
2728
end
2829
end.on_rejection! do |e|
30+
handle_unrecoverable_error(e)
2931
handle_thread_error(e)
3032
end
3133
end
@@ -39,14 +41,22 @@ def idle?
3941
end
4042

4143
private
42-
attr_reader :available_threads, :on_idle, :mutex
44+
attr_reader :available_threads, :on_idle, :on_unrecoverable_error, :mutex
4345

4446
DEFAULT_OPTIONS = {
4547
min_threads: 0,
4648
idletime: 60,
4749
fallback_policy: :abort
4850
}
4951

52+
def handle_unrecoverable_error(error)
53+
return unless error.is_a?(ClaimedExecution::FinalizationError)
54+
55+
# Only signal shutdown — do not join the worker from this pool thread,
56+
# or wait_for_termination during worker shutdown would deadlock.
57+
on_unrecoverable_error&.call(error)
58+
end
59+
5060
def executor
5161
@executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
5262
end

lib/solid_queue/worker.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ def initialize(**options)
1616
# Ensure that the queues array is deep frozen to prevent accidental modification
1717
@queues = Array(options[:queues]).map(&:freeze).freeze
1818

19-
@pool = Pool.new(options[:threads], on_idle: -> { wake_up })
19+
@pool = Pool.new(
20+
options[:threads],
21+
on_idle: -> { wake_up },
22+
on_unrecoverable_error: ->(*) { request_termination }
23+
)
2024

2125
super(**options)
2226
end
@@ -42,6 +46,14 @@ def claim_executions
4246
end
4347
end
4448

49+
def request_termination
50+
# Signal the poller to shut down without joining from the pool thread.
51+
# Runnable#stop joins when unsupervised, which would deadlock once
52+
# shutdown waits for this pool thread to finish.
53+
@stopped = true
54+
wake_up
55+
end
56+
4557
def shutdown
4658
pool.shutdown
4759
pool.wait_for_termination(SolidQueue.shutdown_timeout)

test/models/solid_queue/claimed_execution_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,35 @@ class SolidQueue::ClaimedExecutionTest < ActiveSupport::TestCase
1717
assert job.reload.finished?
1818
end
1919

20+
test "raises FinalizationError when finishing fails while the claim remains" do
21+
claimed_execution = prepare_and_claim_job AddToBufferJob.perform_later(42)
22+
23+
SolidQueue::ClaimedExecution.any_instance.stubs(:finished).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))
24+
25+
error = assert_raises SolidQueue::ClaimedExecution::FinalizationError do
26+
claimed_execution.perform
27+
end
28+
29+
assert_match(/transient DB glitch/, error.message)
30+
assert_equal ActiveRecord::StatementInvalid, error.cause.class
31+
assert SolidQueue::ClaimedExecution.exists?(claimed_execution.id)
32+
assert_not claimed_execution.job.reload.finished?
33+
end
34+
35+
test "raises FinalizationError when failing the job fails while the claim remains" do
36+
claimed_execution = prepare_and_claim_job RaisingJob.perform_later(RuntimeError, "A")
37+
38+
SolidQueue::ClaimedExecution.any_instance.stubs(:failed_with).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))
39+
40+
error = assert_raises SolidQueue::ClaimedExecution::FinalizationError do
41+
claimed_execution.perform
42+
end
43+
44+
assert_match(/transient DB glitch/, error.message)
45+
assert SolidQueue::ClaimedExecution.exists?(claimed_execution.id)
46+
assert_not claimed_execution.job.reload.failed?
47+
end
48+
2049
test "stale performer cannot release a concurrency lock after its claim is pruned" do
2150
job_result = JobResult.create!(queue_name: "default", status: "")
2251
first_active_job = NonOverlappingUpdateResultJob.perform_later(job_result, name: "A")

test/unit/worker_test.rb

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ class WorkerTest < ActiveSupport::TestCase
6262
@worker.wake_up
6363

6464
assert_equal 1, subscriber.errors.count
65-
assert_equal "everything is broken", subscriber.messages.first
65+
error = subscriber.errors.first.first
66+
assert_instance_of SolidQueue::ClaimedExecution::FinalizationError, error
67+
assert_equal "everything is broken", error.cause.message
6668
ensure
6769
Rails.error.unsubscribe(subscriber) if Rails.error.respond_to?(:unsubscribe)
6870
SolidQueue.on_thread_error = original_on_thread_error
@@ -85,6 +87,41 @@ class WorkerTest < ActiveSupport::TestCase
8587
Rails.error.unsubscribe(subscriber) if Rails.error.respond_to?(:unsubscribe)
8688
end
8789

90+
test "worker stops and releases the claim when finishing a job fails" do
91+
previous_on_thread_error, SolidQueue.on_thread_error = SolidQueue.on_thread_error, ->(*) { }
92+
93+
SolidQueue::ClaimedExecution.any_instance.stubs(:finished).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))
94+
95+
AddToBufferJob.perform_later "hey!"
96+
97+
@worker.start
98+
99+
wait_while_with_timeout(2.seconds) { !@worker.pool.shutdown? }
100+
assert @worker.pool.shutdown?
101+
102+
wait_for_registered_processes(0, timeout: 1.second)
103+
assert_no_registered_processes
104+
105+
assert_equal 0, SolidQueue::ClaimedExecution.count
106+
assert SolidQueue::Job.last.reload.ready?
107+
ensure
108+
SolidQueue.on_thread_error = previous_on_thread_error
109+
end
110+
111+
test "worker keeps running after a regular job failure" do
112+
RaisingJob.perform_later(ExpectedTestError, "B")
113+
AddToBufferJob.perform_later "ok"
114+
115+
@worker.start
116+
117+
wait_for_jobs_to_finish_for(2.seconds)
118+
@worker.wake_up
119+
120+
assert_not @worker.pool.shutdown?
121+
assert_equal "ok", JobBuffer.last_value
122+
assert_equal 0, SolidQueue::ClaimedExecution.count
123+
end
124+
88125
test "claim and process more enqueued jobs than the pool size allows to process at once" do
89126
5.times do |i|
90127
StoreResultJob.perform_later(:paused, pause: 0.1.second)

0 commit comments

Comments
 (0)