Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions app/models/solid_queue/claimed_execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ def success?
end
end

# Raised when a job has already run (or failed) but we couldn't update its
# claim/finished state because of a transient error. The claim is still held
# by a living worker, so it won't be recovered as orphaned unless the worker
# is stopped and replaced.
class FinalizationError < RuntimeError
def initialize(claimed_execution, cause:)
super("Failed to finalize claimed execution #{claimed_execution.id} (job #{claimed_execution.job_id}): #{cause.class}: #{cause.message}")
set_backtrace(cause.backtrace) if cause.backtrace
end
end

class << self
def claiming(job_ids, process_id, &block)
job_data = Array(job_ids).collect { |job_id| { job_id: job_id, process_id: process_id } }
Expand Down Expand Up @@ -62,14 +73,7 @@ def discard_all_from_jobs(*)
end

def perform
result = execute

if result.success?
finished
else
failed_with(result.error)
raise result.error
end
finalize_result(execute)
end

def release
Expand All @@ -90,6 +94,21 @@ def failed_with(error)
end

private
def finalize_result(result)
if result.success?
finished
else
failed_with(result.error)
raise result.error
end
rescue FinalizationError
raise
rescue => error
raise FinalizationError.new(self, cause: error) if still_claimed?

raise
end

def execute
ActiveJob::Base.execute(job.arguments.merge("provider_job_id" => job.id))
Result.new(true, nil)
Expand Down Expand Up @@ -122,4 +141,12 @@ def unless_already_finalized
yield
end
end

def still_claimed?
self.class.exists?(id)
rescue
# If we can't check because the DB is unavailable, assume the claim is
# still held so the worker can be stopped and replaced.
true
end
end
3 changes: 2 additions & 1 deletion lib/solid_queue/fiber_pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module SolidQueue
class FiberPool < Pool
def initialize(size, on_idle: nil)
def initialize(size, on_idle: nil, on_unrecoverable_error: nil)
super

@state_mutex = Mutex.new
Expand Down Expand Up @@ -108,6 +108,7 @@ def perform_execution(execution)
handle_thread_error(error)
register_fatal_error(error)
rescue Exception => error
handle_unrecoverable_error(error)
handle_thread_error(error)
ensure
restore_capacity
Expand Down
22 changes: 18 additions & 4 deletions lib/solid_queue/pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ module SolidQueue
class Pool
include AppExecutor

def self.build(type:, size:, on_idle: nil)
SolidQueue.const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle)
def self.build(type:, size:, on_idle: nil, on_unrecoverable_error: nil)
SolidQueue.const_get("#{type.to_s.camelize}Pool").new(
size,
on_idle: on_idle,
on_unrecoverable_error: on_unrecoverable_error
)
end

attr_reader :size

def initialize(size, on_idle: nil)
def initialize(size, on_idle: nil, on_unrecoverable_error: nil)
@size = size
@on_idle = on_idle
@on_unrecoverable_error = on_unrecoverable_error
@available_capacity = size
@mutex = Mutex.new
end
Expand Down Expand Up @@ -41,7 +46,7 @@ def idle?
end

private
attr_reader :mutex, :on_idle
attr_reader :mutex, :on_idle, :on_unrecoverable_error

def schedule(execution)
raise NotImplementedError
Expand All @@ -50,11 +55,20 @@ def schedule(execution)
def perform_execution(execution)
wrap_in_app_executor { execution.perform }
rescue Exception => error
handle_unrecoverable_error(error)
handle_thread_error(error)
ensure
restore_capacity
end

def handle_unrecoverable_error(error)
return unless error.is_a?(ClaimedExecution::FinalizationError)

# Only signal shutdown — do not join the worker from this pool thread,
# or wait_for_termination during worker shutdown would deadlock.
on_unrecoverable_error&.call(error)
end

def reserve_capacity!
mutex.synchronize do
raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0
Expand Down
1 change: 1 addition & 0 deletions lib/solid_queue/thread_pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def schedule(execution)
end.on_rejection! do |error|
# Backstop for errors raised outside perform_execution's own rescue,
# such as when restoring capacity or waking up the worker
handle_unrecoverable_error(error)
handle_thread_error(error)
end
end
Expand Down
11 changes: 10 additions & 1 deletion lib/solid_queue/worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def initialize(**options)
@pool = Pool.build \
type: execution_pool_type,
size: execution_pool_size,
on_idle: -> { wake_up }
on_idle: -> { wake_up },
on_unrecoverable_error: ->(*) { request_termination }

super(**options)
end
Expand All @@ -48,6 +49,14 @@ def claim_executions
end
end

def request_termination
# Signal the poller to shut down without joining from the pool thread.
# Runnable#stop joins when unsupervised, which would deadlock once
# shutdown waits for this pool thread to finish.
@stopped = true
wake_up
end

def shutdown
pool.shutdown
pool.wait_for_termination(SolidQueue.shutdown_timeout)
Expand Down
29 changes: 29 additions & 0 deletions test/models/solid_queue/claimed_execution_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,35 @@ class SolidQueue::ClaimedExecutionTest < ActiveSupport::TestCase
assert job.reload.finished?
end

test "raises FinalizationError when finishing fails while the claim remains" do
claimed_execution = prepare_and_claim_job AddToBufferJob.perform_later(42)

SolidQueue::ClaimedExecution.any_instance.stubs(:finished).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))

error = assert_raises SolidQueue::ClaimedExecution::FinalizationError do
claimed_execution.perform
end

assert_match(/transient DB glitch/, error.message)
assert_equal ActiveRecord::StatementInvalid, error.cause.class
assert SolidQueue::ClaimedExecution.exists?(claimed_execution.id)
assert_not claimed_execution.job.reload.finished?
end

test "raises FinalizationError when failing the job fails while the claim remains" do
claimed_execution = prepare_and_claim_job RaisingJob.perform_later(RuntimeError, "A")

SolidQueue::ClaimedExecution.any_instance.stubs(:failed_with).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))

error = assert_raises SolidQueue::ClaimedExecution::FinalizationError do
claimed_execution.perform
end

assert_match(/transient DB glitch/, error.message)
assert SolidQueue::ClaimedExecution.exists?(claimed_execution.id)
assert_not claimed_execution.job.reload.failed?
end

test "stale performer cannot release a concurrency lock after its claim is pruned" do
job_result = JobResult.create!(queue_name: "default", status: "")
first_active_job = NonOverlappingUpdateResultJob.perform_later(job_result, name: "A")
Expand Down
2 changes: 1 addition & 1 deletion test/unit/fiber_pool_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def perform
def test_builds_a_fiber_pool
pool = mock

SolidQueue::FiberPool.expects(:new).with(5, on_idle: nil).returns(pool)
SolidQueue::FiberPool.expects(:new).with(5, on_idle: nil, on_unrecoverable_error: nil).returns(pool)

assert_equal pool, SolidQueue::Pool.build(type: :fiber, size: 5)
end
Expand Down
42 changes: 39 additions & 3 deletions test/unit/worker_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class WorkerTest < ActiveSupport::TestCase
subscriber = ErrorBuffer.new
Rails.error.subscribe(subscriber)

SolidQueue::ClaimedExecution::Result.expects(:new).raises(ExpectedTestError.new("everything is broken")).at_least_once
SolidQueue::ClaimedExecution.any_instance.stubs(:finished).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))

AddToBufferJob.perform_later "hey!"

Expand All @@ -120,8 +120,9 @@ class WorkerTest < ActiveSupport::TestCase
wait_for_jobs_to_finish_for(1.second)
@worker.wake_up

assert_equal 1, subscriber.errors.count
assert_equal "everything is broken", subscriber.messages.first
finalization_errors = subscriber.errors.map(&:first).grep(SolidQueue::ClaimedExecution::FinalizationError)
assert_equal 1, finalization_errors.count
assert_match(/transient DB glitch/, finalization_errors.first.message)
ensure
Rails.error.unsubscribe(subscriber) if Rails.error.respond_to?(:unsubscribe)
SolidQueue.on_thread_error = original_on_thread_error
Expand All @@ -144,6 +145,41 @@ class WorkerTest < ActiveSupport::TestCase
Rails.error.unsubscribe(subscriber) if Rails.error.respond_to?(:unsubscribe)
end

test "worker stops and releases the claim when finishing a job fails" do
previous_on_thread_error, SolidQueue.on_thread_error = SolidQueue.on_thread_error, ->(*) { }

SolidQueue::ClaimedExecution.any_instance.stubs(:finished).raises(ActiveRecord::StatementInvalid.new("transient DB glitch"))

AddToBufferJob.perform_later "hey!"

@worker.start

wait_while_with_timeout(2.seconds) { !@worker.pool.shutdown? }
assert @worker.pool.shutdown?

wait_for_registered_processes(0, timeout: 1.second)
assert_no_registered_processes

assert_equal 0, SolidQueue::ClaimedExecution.count
assert SolidQueue::Job.last.reload.ready?
ensure
SolidQueue.on_thread_error = previous_on_thread_error
end

test "worker keeps running after a regular job failure" do
RaisingJob.perform_later(ExpectedTestError, "B")
AddToBufferJob.perform_later "ok"

@worker.start

wait_for_jobs_to_finish_for(2.seconds)
@worker.wake_up

assert_not @worker.pool.shutdown?
assert_equal "ok", JobBuffer.last_value
assert_equal 0, SolidQueue::ClaimedExecution.count
end

test "polling queries are logged" do
log = StringIO.new
with_active_record_logger(ActiveSupport::Logger.new(log)) do
Expand Down
Loading