From 34990a66f4494bf841a8aeb9e94187ec7025aa6b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 09:35:08 +0200 Subject: [PATCH 01/11] Remove the SolidQueue::Pool alias Nothing references it anymore, inside or outside the gem: it was internal API, always instantiated by the worker and never something apps would configure or subclass. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/pool.rb | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 lib/solid_queue/pool.rb diff --git a/lib/solid_queue/pool.rb b/lib/solid_queue/pool.rb deleted file mode 100644 index 356bbaf3..00000000 --- a/lib/solid_queue/pool.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module SolidQueue - Pool = ExecutionPools::ThreadPool -end From a16bbb157a6ab854763afaafdfd1d174774d0ff9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 08:57:43 +0200 Subject: [PATCH 02/11] Refresh worker metadata only on heartbeats Keeping the inflight gauge fresh on every claim and job completion required a mutex and dirty-tracking in the worker, an extra metadata update per claiming poll (up to 10 per second per worker at the default polling interval), and had the poller and heartbeat threads racing to update the same row. The gauge doesn't need that freshness: it's a monitoring convenience, the exact count is always available from claimed executions, and the heartbeat already refreshes it with bounded staleness. With the dirty-tracking gone, the pool callback's only job is waking the worker when capacity frees up, so it also gets its old name back: on_idle. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools.rb | 6 ++--- lib/solid_queue/execution_pools/fiber_pool.rb | 10 ++++---- .../execution_pools/thread_pool.rb | 8 +++---- lib/solid_queue/worker.rb | 24 +------------------ test/unit/execution_pools/fiber_pool_test.rb | 4 ++-- test/unit/worker_test.rb | 11 +++++---- 6 files changed, 22 insertions(+), 41 deletions(-) diff --git a/lib/solid_queue/execution_pools.rb b/lib/solid_queue/execution_pools.rb index 766cc189..4facebfa 100644 --- a/lib/solid_queue/execution_pools.rb +++ b/lib/solid_queue/execution_pools.rb @@ -3,12 +3,12 @@ module SolidQueue module ExecutionPools class << self - def build(type:, size:, on_state_change: nil) + def build(type:, size:, on_idle: nil) case type when :thread - ThreadPool.new(size, on_state_change: on_state_change) + ThreadPool.new(size, on_idle: on_idle) when :fiber - FiberPool.new(size, on_state_change: on_state_change) + FiberPool.new(size, on_idle: on_idle) else raise ArgumentError, "Unknown execution pool type #{type.inspect}. Expected one of: :thread, :fiber" end diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index 72023d64..69bf9f65 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -47,9 +47,9 @@ def supported_isolation_level? attr_reader :size - def initialize(size, on_state_change: nil) + def initialize(size, on_idle: nil) @size = size - @on_state_change = on_state_change + @on_idle = on_idle @available_capacity = size @mutex = Mutex.new @state_mutex = Mutex.new @@ -117,7 +117,7 @@ def metadata end private - attr_reader :boot_queue, :mutex, :on_state_change, :pending_executions, :reactor_thread, :state_mutex + attr_reader :boot_queue, :mutex, :on_idle, :pending_executions, :reactor_thread, :state_mutex def name @name ||= "solid_queue-fiber-pool-#{object_id}" @@ -176,7 +176,7 @@ def restore_capacity @available_capacity.positive? end - on_state_change&.call if should_notify + on_idle&.call if should_notify end def register_fatal_error(error) @@ -185,7 +185,7 @@ def register_fatal_error(error) end boot_queue << error if boot_queue.empty? - on_state_change&.call + on_idle&.call end def raise_if_fatal_error! diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb index 5b7d1565..1b7d99d9 100644 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ b/lib/solid_queue/execution_pools/thread_pool.rb @@ -9,9 +9,9 @@ class ThreadPool delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor - def initialize(size, on_state_change: nil) + def initialize(size, on_idle: nil) @size = size - @on_state_change = on_state_change + @on_idle = on_idle @available_capacity = size @mutex = Mutex.new end @@ -49,7 +49,7 @@ def metadata end private - attr_reader :mutex, :on_state_change + attr_reader :mutex, :on_idle DEFAULT_OPTIONS = { min_threads: 0, @@ -75,7 +75,7 @@ def restore_capacity @available_capacity.positive? end - on_state_change&.call if should_notify + on_idle&.call if should_notify end end end diff --git a/lib/solid_queue/worker.rb b/lib/solid_queue/worker.rb index 31945b95..fe334742 100644 --- a/lib/solid_queue/worker.rb +++ b/lib/solid_queue/worker.rb @@ -20,13 +20,11 @@ def initialize(**options) # Ensure that the queues array is deep frozen to prevent accidental modification @queues = Array(options[:queues]).map(&:freeze).freeze - @metadata_state_mutex = Mutex.new - @metadata_dirty = false @pool_options = { type: execution_pool_type, size: execution_pool_size, - on_state_change: -> { mark_metadata_dirty; wake_up } + on_idle: -> { wake_up } } super(**options) @@ -43,8 +41,6 @@ def poll pool.post(execution) end - reload_metadata_if_needed(executions.any?) - pool.idle? ? polling_interval : 10.minutes end end @@ -97,23 +93,5 @@ def worker_defaults_for(options) SolidQueue::Configuration::WORKER_DEFAULTS end end - - def mark_metadata_dirty - metadata_state_mutex.synchronize { @metadata_dirty = true } - end - - def metadata_state_mutex - @metadata_state_mutex - end - - def reload_metadata_if_needed(executions_claimed) - needs_reload = metadata_state_mutex.synchronize do - claimed_or_dirty = executions_claimed || @metadata_dirty - @metadata_dirty = false - claimed_or_dirty - end - - reload_metadata if needs_reload - end end end diff --git a/test/unit/execution_pools/fiber_pool_test.rb b/test/unit/execution_pools/fiber_pool_test.rb index 16e8f75b..8f9aabf8 100644 --- a/test/unit/execution_pools/fiber_pool_test.rb +++ b/test/unit/execution_pools/fiber_pool_test.rb @@ -31,7 +31,7 @@ def test_raises_a_clear_error_when_the_async_gem_is_unavailable def test_builds_a_fiber_pool pool = mock - SolidQueue::ExecutionPools::FiberPool.expects(:new).with(5, on_state_change: nil).returns(pool) + SolidQueue::ExecutionPools::FiberPool.expects(:new).with(5, on_idle: nil).returns(pool) assert_equal pool, SolidQueue::ExecutionPools.build(type: :fiber, size: 5) end @@ -103,7 +103,7 @@ def test_marks_the_pool_as_fatal_when_an_execution_is_cancelled original_on_thread_error = SolidQueue.on_thread_error SolidQueue.on_thread_error = ->(error) { reported_errors << error.class.name } - pool = SolidQueue::ExecutionPools::FiberPool.new(1, on_state_change: -> { notifications << :changed }) + pool = SolidQueue::ExecutionPools::FiberPool.new(1, on_idle: -> { notifications << :changed }) pool.post CancelledExecution.new(started) Timeout.timeout(1.second) { started.pop } diff --git a/test/unit/worker_test.rb b/test/unit/worker_test.rb index a7baecb2..702f272a 100644 --- a/test/unit/worker_test.rb +++ b/test/unit/worker_test.rb @@ -78,8 +78,9 @@ class WorkerTest < ActiveSupport::TestCase end end - test "updates inflight metadata after jobs finish in #{mode[:name]} mode" do - StoreResultJob.perform_later(:slow, pause: 0.1.second) + test "updates inflight metadata on heartbeats in #{mode[:name]} mode" do + old_heartbeat_interval, SolidQueue.process_heartbeat_interval = SolidQueue.process_heartbeat_interval, 0.5.second + StoreResultJob.perform_later(:slow, pause: 1.second) with_worker_execution_support(mode[:options]) do worker = SolidQueue::Worker.new(queues: "background", polling_interval: 0.05, **mode[:options]) @@ -92,7 +93,7 @@ class WorkerTest < ActiveSupport::TestCase wait_for(timeout: 2.seconds) { process.reload.metadata["inflight"] == 1 } assert_equal 1, process.reload.metadata["inflight"] - wait_for(timeout: 2.seconds) do + wait_for(timeout: 3.seconds) do process.reload.metadata["inflight"] == 0 && JobResult.where(queue_name: :background, status: "completed", value: :slow).count == 1 end @@ -102,6 +103,8 @@ class WorkerTest < ActiveSupport::TestCase worker&.stop wait_for_registered_processes(0, timeout: 1.second) end + ensure + SolidQueue.process_heartbeat_interval = old_heartbeat_interval end end @@ -109,7 +112,7 @@ class WorkerTest < ActiveSupport::TestCase pool = SolidQueue::ExecutionPools::ThreadPool.new(3) SolidQueue::ExecutionPools.expects(:build).once.with do |**options| - options[:type] == :thread && options[:size] == 3 && options[:on_state_change].respond_to?(:call) + options[:type] == :thread && options[:size] == 3 && options[:on_idle].respond_to?(:call) end.returns(pool) worker = SolidQueue::Worker.new(queues: "background", threads: 3, polling_interval: 0.2) From b206d6d06dad1b99b1b1ceb2c1ef93a8ac72c13a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 10:01:09 +0200 Subject: [PATCH 03/11] Rely on configuration validation for worker execution options The configuration already validates that workers specify either threads or fibers but not both, and that's where option validation belongs; the worker doesn't validate any of its other options. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/worker.rb | 7 ------- test/unit/worker_test.rb | 8 -------- 2 files changed, 15 deletions(-) diff --git a/lib/solid_queue/worker.rb b/lib/solid_queue/worker.rb index fe334742..659c1adc 100644 --- a/lib/solid_queue/worker.rb +++ b/lib/solid_queue/worker.rb @@ -12,7 +12,6 @@ class Worker < Processes::Poller def initialize(**options) options = options.dup - validate_execution_options!(options) execution_pool_type = options.key?(:fibers) ? :fiber : :thread execution_pool_size = options[:fibers] || options[:threads] || SolidQueue::Configuration::WORKER_DEFAULTS[:threads] @@ -80,12 +79,6 @@ def build_pool @pool ||= ExecutionPools.build(**@pool_options) end - def validate_execution_options!(options) - if options.key?(:threads) && options.key?(:fibers) - raise ArgumentError, "Workers can specify either `threads` or `fibers`, but not both." - end - end - def worker_defaults_for(options) if options.key?(:fibers) SolidQueue::Configuration::WORKER_DEFAULTS.except(:threads) diff --git a/test/unit/worker_test.rb b/test/unit/worker_test.rb index 702f272a..9673a686 100644 --- a/test/unit/worker_test.rb +++ b/test/unit/worker_test.rb @@ -128,14 +128,6 @@ class WorkerTest < ActiveSupport::TestCase wait_for_registered_processes(0, timeout: 1.second) end - test "rejects multiple worker pool size options" do - error = assert_raises ArgumentError do - SolidQueue::Worker.new(queues: "background", threads: 3, fibers: 3, polling_interval: 0.2) - end - - assert_match /either `threads` or `fibers`/, error.message - end - test "defaults thread workers to the configured thread pool size" do worker = SolidQueue::Worker.new(queues: "background", polling_interval: 0.2) From e4a38498afb0f455cea21a94324202e52871ceb1 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 10:38:05 +0200 Subject: [PATCH 04/11] Start the fiber pool's reactor lazily on first use In the default fork supervisor mode, workers are instantiated by the supervisor before forking, so a reactor thread started when the pool is built wouldn't survive the fork. Starting it when the first execution is posted makes the fiber pool safe to build at any point, matching the thread pool, which already builds its executor lazily on first use. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools/fiber_pool.rb | 21 +++++++++++----- test/unit/execution_pools/fiber_pool_test.rb | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index 69bf9f65..d9d8cd04 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -57,14 +57,10 @@ def initialize(size, on_idle: nil) @fatal_error = nil @boot_queue = Thread::Queue.new @pending_executions = Thread::Queue.new + @reactor_thread = nil self.class.ensure_dependency! self.class.ensure_supported_isolation_level! - - @reactor_thread = start_reactor - - boot_result = @boot_queue.pop - raise boot_result if boot_result.is_a?(Exception) end def post(execution) @@ -74,6 +70,8 @@ def post(execution) reserve_capacity! reserved = true + + start_reactor_if_needed pending_executions << execution rescue Exception restore_capacity if reserved @@ -106,7 +104,7 @@ def shutdown? end def wait_for_termination(timeout) - reactor_thread.join(timeout) + reactor_thread&.join(timeout) end def metadata @@ -123,6 +121,17 @@ def name @name ||= "solid_queue-fiber-pool-#{object_id}" end + # The reactor thread is started lazily, when the first execution is posted, + # so that the pool can be safely built before forking: in the default fork + # supervisor mode, workers are instantiated in the supervisor process, and + # a thread started there wouldn't survive the fork. + def start_reactor_if_needed + @reactor_thread ||= start_reactor.tap do + boot_result = boot_queue.pop + raise boot_result if boot_result.is_a?(Exception) + end + end + def start_reactor create_thread do Async do |task| diff --git a/test/unit/execution_pools/fiber_pool_test.rb b/test/unit/execution_pools/fiber_pool_test.rb index 8f9aabf8..a5a90f20 100644 --- a/test/unit/execution_pools/fiber_pool_test.rb +++ b/test/unit/execution_pools/fiber_pool_test.rb @@ -85,6 +85,10 @@ def test_waits_for_in_flight_executions_during_shutdown def test_shutdown_wakes_the_reactor_when_idle with_execution_isolation(:fiber) do pool = SolidQueue::ExecutionPools::FiberPool.new(1) + results = Thread::Queue.new + + pool.post Execution.new(nil, results, nil) + Timeout.timeout(1.second) { results.pop } pool.shutdown @@ -95,6 +99,27 @@ def test_shutdown_wakes_the_reactor_when_idle end end + def test_starts_the_reactor_lazily_so_the_pool_can_be_built_before_forking + with_execution_isolation(:fiber) do + pool = SolidQueue::ExecutionPools::FiberPool.new(1) + + pid = fork do + results = Thread::Queue.new + pool.post Execution.new(nil, results, nil) + Timeout.timeout(1.second) { results.pop } + pool.shutdown + pool.wait_for_termination(1.second) + exit!(0) + end + + _, status = Process.waitpid2(pid) + assert_equal 0, status.exitstatus + ensure + pool&.shutdown + pool&.wait_for_termination(1.second) + end + end + def test_marks_the_pool_as_fatal_when_an_execution_is_cancelled with_execution_isolation(:fiber) do notifications = Thread::Queue.new From 6608b6e05d82319a9a49d8b65826e8e1aac47434 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 10:38:05 +0200 Subject: [PATCH 05/11] Simplify worker initialization Build the execution pool directly when the worker is initialized, which is safe now that both pools start their threads lazily. Merge the worker defaults unconditionally and choose the pool type with a single check on the fibers option, relying on the configuration's validation to prevent threads and fibers from being combined. If a worker is instantiated directly with no pool options, it defaults to a thread pool; if it somehow gets both options, fibers win. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/worker.rb | 27 ++++----------------------- test/unit/worker_test.rb | 20 -------------------- 2 files changed, 4 insertions(+), 43 deletions(-) diff --git a/lib/solid_queue/worker.rb b/lib/solid_queue/worker.rb index 659c1adc..597fbff3 100644 --- a/lib/solid_queue/worker.rb +++ b/lib/solid_queue/worker.rb @@ -11,20 +11,18 @@ class Worker < Processes::Poller attr_reader :queues, :pool def initialize(**options) - options = options.dup - execution_pool_type = options.key?(:fibers) ? :fiber : :thread - execution_pool_size = options[:fibers] || options[:threads] || SolidQueue::Configuration::WORKER_DEFAULTS[:threads] - options = options.with_defaults(worker_defaults_for(options)) + + options = options.dup.with_defaults(SolidQueue::Configuration::WORKER_DEFAULTS) + execution_pool_size = execution_pool_type == :fiber ? options[:fibers] : options[:threads] # Ensure that the queues array is deep frozen to prevent accidental modification @queues = Array(options[:queues]).map(&:freeze).freeze - @pool_options = { + @pool = ExecutionPools.build \ type: execution_pool_type, size: execution_pool_size, on_idle: -> { wake_up } - } super(**options) end @@ -50,11 +48,6 @@ def claim_executions end end - def boot - build_pool - super - end - def shutdown pool.shutdown pool.wait_for_termination(SolidQueue.shutdown_timeout) @@ -74,17 +67,5 @@ def heartbeat def set_procline procline "waiting for jobs in #{queues.join(",")}" end - - def build_pool - @pool ||= ExecutionPools.build(**@pool_options) - end - - def worker_defaults_for(options) - if options.key?(:fibers) - SolidQueue::Configuration::WORKER_DEFAULTS.except(:threads) - else - SolidQueue::Configuration::WORKER_DEFAULTS - end - end end end diff --git a/test/unit/worker_test.rb b/test/unit/worker_test.rb index 9673a686..48063713 100644 --- a/test/unit/worker_test.rb +++ b/test/unit/worker_test.rb @@ -108,26 +108,6 @@ class WorkerTest < ActiveSupport::TestCase end end - test "builds the execution pool on boot instead of initialize" do - pool = SolidQueue::ExecutionPools::ThreadPool.new(3) - - SolidQueue::ExecutionPools.expects(:build).once.with do |**options| - options[:type] == :thread && options[:size] == 3 && options[:on_idle].respond_to?(:call) - end.returns(pool) - - worker = SolidQueue::Worker.new(queues: "background", threads: 3, polling_interval: 0.2) - - assert_nil worker.pool - - worker.start - wait_for_registered_processes(1, timeout: 1.second) - - assert_equal pool, worker.pool - ensure - worker&.stop - wait_for_registered_processes(0, timeout: 1.second) - end - test "defaults thread workers to the configured thread pool size" do worker = SolidQueue::Worker.new(queues: "background", polling_interval: 0.2) From a4e6341b1056ddc1a47fd5e53a9ea86f8fb57db5 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 11:23:58 +0200 Subject: [PATCH 06/11] Store only the pool type and size in worker metadata The in-flight gauge was refreshed on heartbeats only, so it could show counts that were stale by up to a heartbeat interval; better not to show it than to show stale information, especially given the exact count is always available from claimed executions. The pool type and size are constant, and the pool knows both, so the worker can store them directly when registering, as it did before, and the pools don't need to provide changing metadata at all. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools/fiber_pool.rb | 11 ++--- .../execution_pools/thread_pool.rb | 11 ++--- lib/solid_queue/worker.rb | 7 +-- .../fiber_processes_lifecycle_test.rb | 2 +- test/unit/execution_pools/fiber_pool_test.rb | 2 - test/unit/worker_test.rb | 48 ++----------------- 6 files changed, 14 insertions(+), 67 deletions(-) diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index d9d8cd04..b5b0e976 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -47,6 +47,10 @@ def supported_isolation_level? attr_reader :size + def type + :fiber + end + def initialize(size, on_idle: nil) @size = size @on_idle = on_idle @@ -107,13 +111,6 @@ def wait_for_termination(timeout) reactor_thread&.join(timeout) end - def metadata - { - fiber_pool_size: size, - inflight: size - available_capacity - } - end - private attr_reader :boot_queue, :mutex, :on_idle, :pending_executions, :reactor_thread, :state_mutex diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb index 1b7d99d9..f517ae58 100644 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ b/lib/solid_queue/execution_pools/thread_pool.rb @@ -9,6 +9,10 @@ class ThreadPool delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor + def type + :thread + end + def initialize(size, on_idle: nil) @size = size @on_idle = on_idle @@ -41,13 +45,6 @@ def idle? available_capacity.positive? end - def metadata - { - inflight: size - available_capacity, - thread_pool_size: size - } - end - private attr_reader :mutex, :on_idle diff --git a/lib/solid_queue/worker.rb b/lib/solid_queue/worker.rb index 597fbff3..a351fa67 100644 --- a/lib/solid_queue/worker.rb +++ b/lib/solid_queue/worker.rb @@ -28,7 +28,7 @@ def initialize(**options) end def metadata - super.merge(queues: queues.join(",")).merge(pool.metadata) + super.merge(queues: queues.join(","), pool_type: pool.type, pool_size: pool.size) end private @@ -59,11 +59,6 @@ def all_work_completed? SolidQueue::ReadyExecution.aggregated_count_across(queues).zero? end - def heartbeat - super - reload_metadata - end - def set_procline procline "waiting for jobs in #{queues.join(",")}" end diff --git a/test/integration/fiber_processes_lifecycle_test.rb b/test/integration/fiber_processes_lifecycle_test.rb index 3a1eafe3..22e207e7 100644 --- a/test/integration/fiber_processes_lifecycle_test.rb +++ b/test/integration/fiber_processes_lifecycle_test.rb @@ -21,7 +21,7 @@ class FiberProcessesLifecycleTest < ActiveSupport::TestCase test "process jobs in fibers in a forked worker" do worker = find_processes_registered_as("Worker").first - assert_metadata worker, fiber_pool_size: 3 + assert_metadata worker, pool_type: "fiber", pool_size: 3 6.times { |i| enqueue_store_result_job("job_#{i}") } diff --git a/test/unit/execution_pools/fiber_pool_test.rb b/test/unit/execution_pools/fiber_pool_test.rb index a5a90f20..cb013d53 100644 --- a/test/unit/execution_pools/fiber_pool_test.rb +++ b/test/unit/execution_pools/fiber_pool_test.rb @@ -57,7 +57,6 @@ def test_executes_jobs_as_fibers_on_a_single_reactor_thread assert_equal 1, entries.map(&:first).uniq.count assert_equal 2, entries.map(&:last).uniq.count assert_equal 2, pool.available_capacity - assert_equal 0, pool.metadata[:inflight] ensure pool&.shutdown pool&.wait_for_termination(1.second) @@ -136,7 +135,6 @@ def test_marks_the_pool_as_fatal_when_an_execution_is_cancelled error = assert_raises(Async::Stop) { pool.available_capacity } assert_equal [ error.class.name ], reported_errors - assert_raises(Async::Stop) { pool.metadata } ensure SolidQueue.on_thread_error = original_on_thread_error pool&.shutdown diff --git a/test/unit/worker_test.rb b/test/unit/worker_test.rb index 48063713..c21cd574 100644 --- a/test/unit/worker_test.rb +++ b/test/unit/worker_test.rb @@ -8,19 +8,11 @@ class WorkerTest < ActiveSupport::TestCase EXECUTION_MODES = [ { name: "thread", - options: { threads: 3 }, - expected_metadata: { - inflight: 0, - thread_pool_size: 3 - } + options: { threads: 3 } }, { name: "fiber", - options: { fibers: 3 }, - expected_metadata: { - inflight: 0, - fiber_pool_size: 3 - } + options: { fibers: 3 } } ].freeze @@ -43,10 +35,7 @@ class WorkerTest < ActiveSupport::TestCase process = SolidQueue::Process.first assert_equal "Worker", process.kind - assert_metadata process, { - queues: "background", - polling_interval: 0.2 - }.merge(mode[:expected_metadata]) + assert_metadata process, queues: "background", polling_interval: 0.2, pool_type: mode[:name], pool_size: 3 ensure worker&.stop wait_for_registered_processes(0, timeout: 1.second) @@ -77,35 +66,6 @@ class WorkerTest < ActiveSupport::TestCase wait_for_registered_processes(0, timeout: 1.second) end end - - test "updates inflight metadata on heartbeats in #{mode[:name]} mode" do - old_heartbeat_interval, SolidQueue.process_heartbeat_interval = SolidQueue.process_heartbeat_interval, 0.5.second - StoreResultJob.perform_later(:slow, pause: 1.second) - - with_worker_execution_support(mode[:options]) do - worker = SolidQueue::Worker.new(queues: "background", polling_interval: 0.05, **mode[:options]) - - worker.start - wait_for_registered_processes(1, timeout: 1.second) - - process = SolidQueue::Process.first - - wait_for(timeout: 2.seconds) { process.reload.metadata["inflight"] == 1 } - assert_equal 1, process.reload.metadata["inflight"] - - wait_for(timeout: 3.seconds) do - process.reload.metadata["inflight"] == 0 && - JobResult.where(queue_name: :background, status: "completed", value: :slow).count == 1 - end - assert_equal 0, process.reload.metadata["inflight"] - assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :slow).count - ensure - worker&.stop - wait_for_registered_processes(0, timeout: 1.second) - end - ensure - SolidQueue.process_heartbeat_interval = old_heartbeat_interval - end end test "defaults thread workers to the configured thread pool size" do @@ -115,7 +75,7 @@ class WorkerTest < ActiveSupport::TestCase wait_for_registered_processes(1, timeout: 1.second) assert_equal 3, worker.pool.size - assert_metadata SolidQueue::Process.first, thread_pool_size: 3, inflight: 0 + assert_metadata SolidQueue::Process.first, pool_type: "thread", pool_size: 3 ensure worker&.stop wait_for_registered_processes(0, timeout: 1.second) From d66bb3f3cfa624a9503db132281b6cc2ca0af20b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 11:24:10 +0200 Subject: [PATCH 07/11] Report errors escaping the execution future The rescue inside the future covers job execution, but an error raised when restoring capacity or waking up the worker would escape it and reject the future with nobody watching. Attach the on_rejection callback the old pool had as a backstop, so those errors are also reported via handle_thread_error. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools/thread_pool.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb index f517ae58..ff76721b 100644 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ b/lib/solid_queue/execution_pools/thread_pool.rb @@ -31,6 +31,10 @@ def post(execution) handle_thread_error(error) ensure restore_capacity + end.on_rejection! do |error| + # Backstop for errors raised outside the rescue above, such as when + # restoring capacity or waking up the worker + handle_thread_error(error) end rescue Exception restore_capacity if reserved From 2fea2e59950dd36bd8b64d85caedea52ca98bb90 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 11:31:13 +0200 Subject: [PATCH 08/11] Rescue only the code that runs with capacity reserved when posting Instead of tracking whether capacity had been reserved with a local flag, scope the rescue to the code that runs after the reservation, which is the only part that needs to restore it on failure. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools/fiber_pool.rb | 14 +++++----- .../execution_pools/thread_pool.rb | 28 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index b5b0e976..f15dff97 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -68,18 +68,18 @@ def initialize(size, on_idle: nil) end def post(execution) - reserved = false raise_if_fatal_error! raise RuntimeError, "Execution pool is shutting down" if shutdown? reserve_capacity! - reserved = true - start_reactor_if_needed - pending_executions << execution - rescue Exception - restore_capacity if reserved - raise + begin + start_reactor_if_needed + pending_executions << execution + rescue Exception + restore_capacity + raise + end end def available_capacity diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb index ff76721b..2cbd9a3d 100644 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ b/lib/solid_queue/execution_pools/thread_pool.rb @@ -21,24 +21,24 @@ def initialize(size, on_idle: nil) end def post(execution) - reserved = false reserve_capacity! - reserved = true - Concurrent::Promises.future_on(executor, execution) do |thread_execution| - wrap_in_app_executor { thread_execution.perform } - rescue Exception => error - handle_thread_error(error) - ensure + begin + Concurrent::Promises.future_on(executor, execution) do |thread_execution| + wrap_in_app_executor { thread_execution.perform } + rescue Exception => error + handle_thread_error(error) + ensure + restore_capacity + end.on_rejection! do |error| + # Backstop for errors raised outside the rescue above, such as when + # restoring capacity or waking up the worker + handle_thread_error(error) + end + rescue Exception restore_capacity - end.on_rejection! do |error| - # Backstop for errors raised outside the rescue above, such as when - # restoring capacity or waking up the worker - handle_thread_error(error) + raise end - rescue Exception - restore_capacity if reserved - raise end def available_capacity From 2298a9e5864aa6444852efb68e1bb4053ed8c6be Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 11:38:14 +0200 Subject: [PATCH 09/11] Extract a base class for the execution pools Both pools share the capacity ledger: the reserve/restore accounting, the idle notification, and the reserve-then-schedule shape of post. Move all of that to ExecutionPools::Base, leaving each pool with just its scheduling mechanism: the thread pool schedules executions on a Concurrent::ThreadPoolExecutor via schedule, and the fiber pool hands them to its reactor, adding its fatal-error and shutdown guards on top of the base's post. The default way of performing an execution, wrapped in the app executor, reporting errors and restoring capacity, also lives in the base class; the fiber pool overrides it to treat Async::Stop as fatal. The pool type is derived from the class name, and the factory performs the lookup in reverse, trusting the type it receives: the worker only ever passes :thread or :fiber. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools.rb | 13 +--- lib/solid_queue/execution_pools/base.rb | 73 +++++++++++++++++++ lib/solid_queue/execution_pools/fiber_pool.rb | 56 +++----------- .../execution_pools/thread_pool.rb | 73 +++---------------- 4 files changed, 96 insertions(+), 119 deletions(-) create mode 100644 lib/solid_queue/execution_pools/base.rb diff --git a/lib/solid_queue/execution_pools.rb b/lib/solid_queue/execution_pools.rb index 4facebfa..501984ab 100644 --- a/lib/solid_queue/execution_pools.rb +++ b/lib/solid_queue/execution_pools.rb @@ -2,17 +2,8 @@ module SolidQueue module ExecutionPools - class << self - def build(type:, size:, on_idle: nil) - case type - when :thread - ThreadPool.new(size, on_idle: on_idle) - when :fiber - FiberPool.new(size, on_idle: on_idle) - else - raise ArgumentError, "Unknown execution pool type #{type.inspect}. Expected one of: :thread, :fiber" - end - end + def self.build(type:, size:, on_idle: nil) + const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle) end end end diff --git a/lib/solid_queue/execution_pools/base.rb b/lib/solid_queue/execution_pools/base.rb new file mode 100644 index 00000000..338e16b6 --- /dev/null +++ b/lib/solid_queue/execution_pools/base.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SolidQueue + module ExecutionPools + class Base + include AppExecutor + + attr_reader :size + + def initialize(size, on_idle: nil) + @size = size + @on_idle = on_idle + @available_capacity = size + @mutex = Mutex.new + end + + def type + self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym + end + + def post(execution) + reserve_capacity! + + begin + schedule(execution) + rescue Exception + restore_capacity + raise + end + end + + def available_capacity + mutex.synchronize { @available_capacity } + end + + def idle? + available_capacity.positive? + end + + private + attr_reader :mutex, :on_idle + + def schedule(execution) + raise NotImplementedError + end + + def perform_execution(execution) + wrap_in_app_executor { execution.perform } + rescue Exception => error + handle_thread_error(error) + ensure + restore_capacity + end + + def reserve_capacity! + mutex.synchronize do + raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0 + + @available_capacity -= 1 + end + end + + def restore_capacity + should_notify = mutex.synchronize do + @available_capacity += 1 + @available_capacity.positive? + end + + on_idle&.call if should_notify + end + end + end +end diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index f15dff97..5b0be63f 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -2,9 +2,7 @@ module SolidQueue module ExecutionPools - class FiberPool - include AppExecutor - + class FiberPool < Base class MissingDependencyError < LoadError def initialize(error) super( @@ -45,17 +43,9 @@ def supported_isolation_level? end end - attr_reader :size - - def type - :fiber - end - def initialize(size, on_idle: nil) - @size = size - @on_idle = on_idle - @available_capacity = size - @mutex = Mutex.new + super + @state_mutex = Mutex.new @shutdown = false @fatal_error = nil @@ -71,24 +61,12 @@ def post(execution) raise_if_fatal_error! raise RuntimeError, "Execution pool is shutting down" if shutdown? - reserve_capacity! - - begin - start_reactor_if_needed - pending_executions << execution - rescue Exception - restore_capacity - raise - end + super end def available_capacity raise_if_fatal_error! - mutex.synchronize { @available_capacity } - end - - def idle? - available_capacity.positive? + super end def shutdown @@ -112,12 +90,17 @@ def wait_for_termination(timeout) end private - attr_reader :boot_queue, :mutex, :on_idle, :pending_executions, :reactor_thread, :state_mutex + attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex def name @name ||= "solid_queue-fiber-pool-#{object_id}" end + def schedule(execution) + start_reactor_if_needed + pending_executions << execution + end + # The reactor thread is started lazily, when the first execution is posted, # so that the pool can be safely built before forking: in the default fork # supervisor mode, workers are instantiated in the supervisor process, and @@ -168,23 +151,6 @@ def perform_execution(execution) restore_capacity end - def reserve_capacity! - mutex.synchronize do - raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0 - - @available_capacity -= 1 - end - end - - def restore_capacity - should_notify = mutex.synchronize do - @available_capacity += 1 - @available_capacity.positive? - end - - on_idle&.call if should_notify - end - def register_fatal_error(error) state_mutex.synchronize do @fatal_error ||= error diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb index 2cbd9a3d..75b135af 100644 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ b/lib/solid_queue/execution_pools/thread_pool.rb @@ -2,81 +2,28 @@ module SolidQueue module ExecutionPools - class ThreadPool - include AppExecutor - - attr_reader :size - + class ThreadPool < Base delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor - def type - :thread - end - - def initialize(size, on_idle: nil) - @size = size - @on_idle = on_idle - @available_capacity = size - @mutex = Mutex.new - end - - def post(execution) - reserve_capacity! - - begin - Concurrent::Promises.future_on(executor, execution) do |thread_execution| - wrap_in_app_executor { thread_execution.perform } - rescue Exception => error - handle_thread_error(error) - ensure - restore_capacity - end.on_rejection! do |error| - # Backstop for errors raised outside the rescue above, such as when - # restoring capacity or waking up the worker - handle_thread_error(error) - end - rescue Exception - restore_capacity - raise - end - end - - def available_capacity - mutex.synchronize { @available_capacity } - end - - def idle? - available_capacity.positive? - end - private - attr_reader :mutex, :on_idle - DEFAULT_OPTIONS = { min_threads: 0, idletime: 60, fallback_policy: :abort } - def executor - @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size) - end - - def reserve_capacity! - mutex.synchronize do - raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0 - - @available_capacity -= 1 + def schedule(execution) + Concurrent::Promises.future_on(executor, execution) do |thread_execution| + perform_execution(thread_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_thread_error(error) end end - def restore_capacity - should_notify = mutex.synchronize do - @available_capacity += 1 - @available_capacity.positive? - end - - on_idle&.call if should_notify + def executor + @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size) end end end From 235107922ee93ad70267527c7077df3b633f4777 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 11:55:08 +0200 Subject: [PATCH 10/11] Move the fiber worker checks to Configuration The async dependency and isolation level checks were surfaced through Configuration's validations but implemented in the fiber pool, which also ran them when built. Configuration is where these belong: it validates every supervised path before any process is instantiated, and reports through valid? and check. The pool now just requires the async gem lazily when starting its reactor, keeping setups without fiber workers from ever loading it, and otherwise trusts its caller, like the rest of the internal classes. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/configuration.rb | 16 ++++-- lib/solid_queue/execution_pools/fiber_pool.rb | 57 ++++--------------- test/unit/configuration_test.rb | 8 +-- test/unit/execution_pools/fiber_pool_test.rb | 20 ------- 4 files changed, 23 insertions(+), 78 deletions(-) diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 200245df..7684eac7 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -132,17 +132,21 @@ def ensure_valid_worker_execution_options def ensure_fiber_workers_have_required_dependency return unless workers_options.any? { |options| fiber_worker?(options) } - SolidQueue::ExecutionPools::FiberPool.ensure_dependency! - rescue LoadError => error - errors.add(:base, error.message) + require "async" + require "async/semaphore" + rescue LoadError + errors.add(:base, "Fiber workers require the `async` gem. " \ + "Add `gem \"async\"` to your Gemfile to configure workers with `fibers`.") end def ensure_fiber_workers_use_supported_isolation_level return unless workers_options.any? { |options| fiber_worker?(options) } - SolidQueue::ExecutionPools::FiberPool.ensure_supported_isolation_level! - rescue ArgumentError => error - errors.add(:base, error.message) + unless ActiveSupport::IsolatedExecutionState.isolation_level == :fiber + errors.add(:base, "Fiber workers require fiber-scoped isolated execution state. " \ + "Set `config.active_support.isolation_level = :fiber` in your Rails configuration " \ + "(or `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` outside Rails).") + end end def default_options diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb index 5b0be63f..4039bd82 100644 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ b/lib/solid_queue/execution_pools/fiber_pool.rb @@ -3,46 +3,6 @@ module SolidQueue module ExecutionPools class FiberPool < Base - class MissingDependencyError < LoadError - def initialize(error) - super( - "Fiber worker execution requires the `async` gem. " \ - "Add `gem \"async\"` to your Gemfile to configure workers with `fibers`. " \ - "Original error: #{error.message}" - ) - end - end - - class UnsupportedIsolationLevelError < ArgumentError - def initialize(level) - super( - "Fiber worker execution requires fiber-scoped isolated execution state. " \ - "Set `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` " \ - "(or `config.active_support.isolation_level = :fiber` in Rails). " \ - "Current isolation level: #{level.inspect}" - ) - end - end - - class << self - def ensure_dependency! - require "async" - require "async/semaphore" - rescue LoadError => error - raise MissingDependencyError.new(error) - end - - def ensure_supported_isolation_level! - return if supported_isolation_level? - - raise UnsupportedIsolationLevelError.new(ActiveSupport::IsolatedExecutionState.isolation_level) - end - - def supported_isolation_level? - ActiveSupport::IsolatedExecutionState.isolation_level == :fiber - end - end - def initialize(size, on_idle: nil) super @@ -52,9 +12,6 @@ def initialize(size, on_idle: nil) @boot_queue = Thread::Queue.new @pending_executions = Thread::Queue.new @reactor_thread = nil - - self.class.ensure_dependency! - self.class.ensure_supported_isolation_level! end def post(execution) @@ -104,11 +61,17 @@ def schedule(execution) # The reactor thread is started lazily, when the first execution is posted, # so that the pool can be safely built before forking: in the default fork # supervisor mode, workers are instantiated in the supervisor process, and - # a thread started there wouldn't survive the fork. + # a thread started there wouldn't survive the fork. The async gem is also + # required lazily here, so that setups without fiber workers never load it. def start_reactor_if_needed - @reactor_thread ||= start_reactor.tap do - boot_result = boot_queue.pop - raise boot_result if boot_result.is_a?(Exception) + @reactor_thread ||= begin + require "async" + require "async/semaphore" + + start_reactor.tap do + boot_result = boot_queue.pop + raise boot_result if boot_result.is_a?(Exception) + end end end diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index b25bc066..4a0d7c06 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -228,15 +228,13 @@ class ConfigurationTest < ActiveSupport::TestCase # Fiber workers require fiber-scoped isolated execution state configuration = SolidQueue::Configuration.new(skip_recurring: true, dispatchers: [], workers: [ { fibers: 3 } ]) assert_not configuration.valid? - assert_match /requires fiber-scoped isolated execution state/, configuration.errors.full_messages.first + assert_match /require fiber-scoped isolated execution state/, configuration.errors.full_messages.first # Fiber workers require the async gem with_execution_isolation(:fiber) do - load_error = LoadError.new("cannot load such file -- async") - missing_dependency_error = SolidQueue::ExecutionPools::FiberPool::MissingDependencyError.new(load_error) - SolidQueue::ExecutionPools::FiberPool.expects(:ensure_dependency!).raises(missing_dependency_error) - configuration = SolidQueue::Configuration.new(skip_recurring: true, dispatchers: [], workers: [ { fibers: 3 } ]) + configuration.expects(:require).with("async").raises(LoadError.new("cannot load such file -- async")) + assert_not configuration.valid? assert_match /gem "async"/, configuration.errors.full_messages.first end diff --git a/test/unit/execution_pools/fiber_pool_test.rb b/test/unit/execution_pools/fiber_pool_test.rb index cb013d53..14900d26 100644 --- a/test/unit/execution_pools/fiber_pool_test.rb +++ b/test/unit/execution_pools/fiber_pool_test.rb @@ -16,18 +16,6 @@ def perform end end - def test_raises_a_clear_error_when_the_async_gem_is_unavailable - load_error = LoadError.new("cannot load such file -- async") - - SolidQueue::ExecutionPools::FiberPool.expects(:require).with("async").raises(load_error) - - error = assert_raises SolidQueue::ExecutionPools::FiberPool::MissingDependencyError do - SolidQueue::ExecutionPools::FiberPool.new(3) - end - - assert_match /gem "async"/, error.message - end - def test_builds_a_fiber_pool pool = mock @@ -36,14 +24,6 @@ def test_builds_a_fiber_pool assert_equal pool, SolidQueue::ExecutionPools.build(type: :fiber, size: 5) end - def test_raises_a_clear_error_when_isolation_level_is_not_fiber - error = assert_raises SolidQueue::ExecutionPools::FiberPool::UnsupportedIsolationLevelError do - SolidQueue::ExecutionPools::FiberPool.new(3) - end - - assert_match /isolation_level = :fiber/, error.message - end - def test_executes_jobs_as_fibers_on_a_single_reactor_thread with_execution_isolation(:fiber) do pool = SolidQueue::ExecutionPools::FiberPool.new(2) From 90cec67493f3dce809de99ca4bda6d90a5f03ef8 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 23 Jul 2026 12:02:28 +0200 Subject: [PATCH 11/11] Reorganize the execution pools under SolidQueue::Pool Replace the ExecutionPools module with a Pool abstract class that the thread and fiber pools inherit from, and that builds the right pool for a worker, mirroring how Supervisor relates to ForkSupervisor and AsyncSupervisor. Co-Authored-By: Claude Opus 4.8 --- lib/solid_queue/execution_pools.rb | 9 -- lib/solid_queue/execution_pools/base.rb | 73 ---------- lib/solid_queue/execution_pools/fiber_pool.rb | 132 ------------------ .../execution_pools/thread_pool.rb | 30 ---- lib/solid_queue/fiber_pool.rb | 130 +++++++++++++++++ lib/solid_queue/pool.rb | 75 ++++++++++ lib/solid_queue/thread_pool.rb | 28 ++++ lib/solid_queue/worker.rb | 2 +- .../{execution_pools => }/fiber_pool_test.rb | 14 +- 9 files changed, 241 insertions(+), 252 deletions(-) delete mode 100644 lib/solid_queue/execution_pools.rb delete mode 100644 lib/solid_queue/execution_pools/base.rb delete mode 100644 lib/solid_queue/execution_pools/fiber_pool.rb delete mode 100644 lib/solid_queue/execution_pools/thread_pool.rb create mode 100644 lib/solid_queue/fiber_pool.rb create mode 100644 lib/solid_queue/pool.rb create mode 100644 lib/solid_queue/thread_pool.rb rename test/unit/{execution_pools => }/fiber_pool_test.rb (86%) diff --git a/lib/solid_queue/execution_pools.rb b/lib/solid_queue/execution_pools.rb deleted file mode 100644 index 501984ab..00000000 --- a/lib/solid_queue/execution_pools.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module SolidQueue - module ExecutionPools - def self.build(type:, size:, on_idle: nil) - const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle) - end - end -end diff --git a/lib/solid_queue/execution_pools/base.rb b/lib/solid_queue/execution_pools/base.rb deleted file mode 100644 index 338e16b6..00000000 --- a/lib/solid_queue/execution_pools/base.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module SolidQueue - module ExecutionPools - class Base - include AppExecutor - - attr_reader :size - - def initialize(size, on_idle: nil) - @size = size - @on_idle = on_idle - @available_capacity = size - @mutex = Mutex.new - end - - def type - self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym - end - - def post(execution) - reserve_capacity! - - begin - schedule(execution) - rescue Exception - restore_capacity - raise - end - end - - def available_capacity - mutex.synchronize { @available_capacity } - end - - def idle? - available_capacity.positive? - end - - private - attr_reader :mutex, :on_idle - - def schedule(execution) - raise NotImplementedError - end - - def perform_execution(execution) - wrap_in_app_executor { execution.perform } - rescue Exception => error - handle_thread_error(error) - ensure - restore_capacity - end - - def reserve_capacity! - mutex.synchronize do - raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0 - - @available_capacity -= 1 - end - end - - def restore_capacity - should_notify = mutex.synchronize do - @available_capacity += 1 - @available_capacity.positive? - end - - on_idle&.call if should_notify - end - end - end -end diff --git a/lib/solid_queue/execution_pools/fiber_pool.rb b/lib/solid_queue/execution_pools/fiber_pool.rb deleted file mode 100644 index 4039bd82..00000000 --- a/lib/solid_queue/execution_pools/fiber_pool.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -module SolidQueue - module ExecutionPools - class FiberPool < Base - def initialize(size, on_idle: nil) - super - - @state_mutex = Mutex.new - @shutdown = false - @fatal_error = nil - @boot_queue = Thread::Queue.new - @pending_executions = Thread::Queue.new - @reactor_thread = nil - end - - def post(execution) - raise_if_fatal_error! - raise RuntimeError, "Execution pool is shutting down" if shutdown? - - super - end - - def available_capacity - raise_if_fatal_error! - super - end - - def shutdown - state_mutex.synchronize do - next false if @shutdown - - @shutdown = true - end.tap do |shut_down| - # Wake the reactor: already-queued executions are drained before the - # blocked pop in +wait_for_executions+ returns nil - pending_executions.close if shut_down - end - end - - def shutdown? - state_mutex.synchronize { @shutdown } - end - - def wait_for_termination(timeout) - reactor_thread&.join(timeout) - end - - private - attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex - - def name - @name ||= "solid_queue-fiber-pool-#{object_id}" - end - - def schedule(execution) - start_reactor_if_needed - pending_executions << execution - end - - # The reactor thread is started lazily, when the first execution is posted, - # so that the pool can be safely built before forking: in the default fork - # supervisor mode, workers are instantiated in the supervisor process, and - # a thread started there wouldn't survive the fork. The async gem is also - # required lazily here, so that setups without fiber workers never load it. - def start_reactor_if_needed - @reactor_thread ||= begin - require "async" - require "async/semaphore" - - start_reactor.tap do - boot_result = boot_queue.pop - raise boot_result if boot_result.is_a?(Exception) - end - end - end - - def start_reactor - create_thread do - Async do |task| - semaphore = Async::Semaphore.new(size, parent: task) - boot_queue << :ready - - # The reactor exits when all in-flight execution fibers, children - # of this task, have finished - wait_for_executions(semaphore) - end - rescue Exception => error - register_fatal_error(error) - raise - end - end - - def wait_for_executions(semaphore) - # Thread::Queue#pop is fiber-scheduler-aware: it suspends this fiber, letting - # execution fibers run, and wakes the reactor when the poller thread pushes new - # work or closes the queue on shutdown, after which it drains any remaining - # executions and returns nil - while execution = pending_executions.pop - semaphore.async(execution) do |_execution_task, scheduled_execution| - perform_execution(scheduled_execution) - end - end - end - - def perform_execution(execution) - wrap_in_app_executor { execution.perform } - rescue Async::Stop => error - handle_thread_error(error) - register_fatal_error(error) - rescue Exception => error - handle_thread_error(error) - ensure - restore_capacity - end - - def register_fatal_error(error) - state_mutex.synchronize do - @fatal_error ||= error - end - - boot_queue << error if boot_queue.empty? - on_idle&.call - end - - def raise_if_fatal_error! - error = state_mutex.synchronize { @fatal_error } - raise error if error - end - end - end -end diff --git a/lib/solid_queue/execution_pools/thread_pool.rb b/lib/solid_queue/execution_pools/thread_pool.rb deleted file mode 100644 index 75b135af..00000000 --- a/lib/solid_queue/execution_pools/thread_pool.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module SolidQueue - module ExecutionPools - class ThreadPool < Base - delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor - - private - DEFAULT_OPTIONS = { - min_threads: 0, - idletime: 60, - fallback_policy: :abort - } - - def schedule(execution) - Concurrent::Promises.future_on(executor, execution) do |thread_execution| - perform_execution(thread_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_thread_error(error) - end - end - - def executor - @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size) - end - end - end -end diff --git a/lib/solid_queue/fiber_pool.rb b/lib/solid_queue/fiber_pool.rb new file mode 100644 index 00000000..0a29795f --- /dev/null +++ b/lib/solid_queue/fiber_pool.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module SolidQueue + class FiberPool < Pool + def initialize(size, on_idle: nil) + super + + @state_mutex = Mutex.new + @shutdown = false + @fatal_error = nil + @boot_queue = Thread::Queue.new + @pending_executions = Thread::Queue.new + @reactor_thread = nil + end + + def post(execution) + raise_if_fatal_error! + raise RuntimeError, "Execution pool is shutting down" if shutdown? + + super + end + + def available_capacity + raise_if_fatal_error! + super + end + + def shutdown + state_mutex.synchronize do + next false if @shutdown + + @shutdown = true + end.tap do |shut_down| + # Wake the reactor: already-queued executions are drained before the + # blocked pop in +wait_for_executions+ returns nil + pending_executions.close if shut_down + end + end + + def shutdown? + state_mutex.synchronize { @shutdown } + end + + def wait_for_termination(timeout) + reactor_thread&.join(timeout) + end + + private + attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex + + def name + @name ||= "solid_queue-fiber-pool-#{object_id}" + end + + def schedule(execution) + start_reactor_if_needed + pending_executions << execution + end + + # The reactor thread is started lazily, when the first execution is posted, + # so that the pool can be safely built before forking: in the default fork + # supervisor mode, workers are instantiated in the supervisor process, and + # a thread started there wouldn't survive the fork. The async gem is also + # required lazily here, so that setups without fiber workers never load it. + def start_reactor_if_needed + @reactor_thread ||= begin + require "async" + require "async/semaphore" + + start_reactor.tap do + boot_result = boot_queue.pop + raise boot_result if boot_result.is_a?(Exception) + end + end + end + + def start_reactor + create_thread do + Async do |task| + semaphore = Async::Semaphore.new(size, parent: task) + boot_queue << :ready + + # The reactor exits when all in-flight execution fibers, children + # of this task, have finished + wait_for_executions(semaphore) + end + rescue Exception => error + register_fatal_error(error) + raise + end + end + + def wait_for_executions(semaphore) + # Thread::Queue#pop is fiber-scheduler-aware: it suspends this fiber, letting + # execution fibers run, and wakes the reactor when the poller thread pushes new + # work or closes the queue on shutdown, after which it drains any remaining + # executions and returns nil + while execution = pending_executions.pop + semaphore.async(execution) do |_execution_task, scheduled_execution| + perform_execution(scheduled_execution) + end + end + end + + def perform_execution(execution) + wrap_in_app_executor { execution.perform } + rescue Async::Stop => error + handle_thread_error(error) + register_fatal_error(error) + rescue Exception => error + handle_thread_error(error) + ensure + restore_capacity + end + + def register_fatal_error(error) + state_mutex.synchronize do + @fatal_error ||= error + end + + boot_queue << error if boot_queue.empty? + on_idle&.call + end + + def raise_if_fatal_error! + error = state_mutex.synchronize { @fatal_error } + raise error if error + end + end +end diff --git a/lib/solid_queue/pool.rb b/lib/solid_queue/pool.rb new file mode 100644 index 00000000..f58ac8fc --- /dev/null +++ b/lib/solid_queue/pool.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +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) + end + + attr_reader :size + + def initialize(size, on_idle: nil) + @size = size + @on_idle = on_idle + @available_capacity = size + @mutex = Mutex.new + end + + def type + self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym + end + + def post(execution) + reserve_capacity! + + begin + schedule(execution) + rescue Exception + restore_capacity + raise + end + end + + def available_capacity + mutex.synchronize { @available_capacity } + end + + def idle? + available_capacity.positive? + end + + private + attr_reader :mutex, :on_idle + + def schedule(execution) + raise NotImplementedError + end + + def perform_execution(execution) + wrap_in_app_executor { execution.perform } + rescue Exception => error + handle_thread_error(error) + ensure + restore_capacity + end + + def reserve_capacity! + mutex.synchronize do + raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0 + + @available_capacity -= 1 + end + end + + def restore_capacity + should_notify = mutex.synchronize do + @available_capacity += 1 + @available_capacity.positive? + end + + on_idle&.call if should_notify + end + end +end diff --git a/lib/solid_queue/thread_pool.rb b/lib/solid_queue/thread_pool.rb new file mode 100644 index 00000000..b1485264 --- /dev/null +++ b/lib/solid_queue/thread_pool.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SolidQueue + class ThreadPool < Pool + delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor + + private + DEFAULT_OPTIONS = { + min_threads: 0, + idletime: 60, + fallback_policy: :abort + } + + def schedule(execution) + Concurrent::Promises.future_on(executor, execution) do |thread_execution| + perform_execution(thread_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_thread_error(error) + end + end + + def executor + @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size) + end + end +end diff --git a/lib/solid_queue/worker.rb b/lib/solid_queue/worker.rb index a351fa67..9ba65bf3 100644 --- a/lib/solid_queue/worker.rb +++ b/lib/solid_queue/worker.rb @@ -19,7 +19,7 @@ def initialize(**options) # Ensure that the queues array is deep frozen to prevent accidental modification @queues = Array(options[:queues]).map(&:freeze).freeze - @pool = ExecutionPools.build \ + @pool = Pool.build \ type: execution_pool_type, size: execution_pool_size, on_idle: -> { wake_up } diff --git a/test/unit/execution_pools/fiber_pool_test.rb b/test/unit/fiber_pool_test.rb similarity index 86% rename from test/unit/execution_pools/fiber_pool_test.rb rename to test/unit/fiber_pool_test.rb index 14900d26..f82694ba 100644 --- a/test/unit/execution_pools/fiber_pool_test.rb +++ b/test/unit/fiber_pool_test.rb @@ -19,14 +19,14 @@ def perform def test_builds_a_fiber_pool pool = mock - SolidQueue::ExecutionPools::FiberPool.expects(:new).with(5, on_idle: nil).returns(pool) + SolidQueue::FiberPool.expects(:new).with(5, on_idle: nil).returns(pool) - assert_equal pool, SolidQueue::ExecutionPools.build(type: :fiber, size: 5) + assert_equal pool, SolidQueue::Pool.build(type: :fiber, size: 5) end def test_executes_jobs_as_fibers_on_a_single_reactor_thread with_execution_isolation(:fiber) do - pool = SolidQueue::ExecutionPools::FiberPool.new(2) + pool = SolidQueue::FiberPool.new(2) results = Thread::Queue.new pool.post Execution.new(nil, results, 0.05) @@ -45,7 +45,7 @@ def test_executes_jobs_as_fibers_on_a_single_reactor_thread def test_waits_for_in_flight_executions_during_shutdown with_execution_isolation(:fiber) do - pool = SolidQueue::ExecutionPools::FiberPool.new(1) + pool = SolidQueue::FiberPool.new(1) started = Thread::Queue.new pool.post Execution.new(started, nil, 0.1) @@ -63,7 +63,7 @@ def test_waits_for_in_flight_executions_during_shutdown def test_shutdown_wakes_the_reactor_when_idle with_execution_isolation(:fiber) do - pool = SolidQueue::ExecutionPools::FiberPool.new(1) + pool = SolidQueue::FiberPool.new(1) results = Thread::Queue.new pool.post Execution.new(nil, results, nil) @@ -80,7 +80,7 @@ def test_shutdown_wakes_the_reactor_when_idle def test_starts_the_reactor_lazily_so_the_pool_can_be_built_before_forking with_execution_isolation(:fiber) do - pool = SolidQueue::ExecutionPools::FiberPool.new(1) + pool = SolidQueue::FiberPool.new(1) pid = fork do results = Thread::Queue.new @@ -107,7 +107,7 @@ def test_marks_the_pool_as_fatal_when_an_execution_is_cancelled original_on_thread_error = SolidQueue.on_thread_error SolidQueue.on_thread_error = ->(error) { reported_errors << error.class.name } - pool = SolidQueue::ExecutionPools::FiberPool.new(1, on_idle: -> { notifications << :changed }) + pool = SolidQueue::FiberPool.new(1, on_idle: -> { notifications << :changed }) pool.post CancelledExecution.new(started) Timeout.timeout(1.second) { started.pop }