From bb122688330020ee08e3f69791333856a388a847 Mon Sep 17 00:00:00 2001 From: Andriichuk Date: Sun, 26 Oct 2025 14:26:06 +0000 Subject: [PATCH 01/85] Remove deprecated loading of statistics.rake from Rakefile --- Rakefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index bdb0ea233..412547017 100644 --- a/Rakefile +++ b/Rakefile @@ -5,7 +5,9 @@ require "bundler/setup" APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) load "rails/tasks/engine.rake" -load "rails/tasks/statistics.rake" +if Rails::VERSION::MAJOR < 8 + load "rails/tasks/statistics.rake" +end require "bundler/gem_tasks" require "rake/tasklib" From 777eb5cc432af62b9388d5284be086dd056f36f6 Mon Sep 17 00:00:00 2001 From: Andriichuk Date: Mon, 27 Oct 2025 15:03:15 +0000 Subject: [PATCH 02/85] Testing against Rails 8.1 - Updated Appraisals to include a new entry for Rails 8.1 with the appropriate railties version. - Modified GitHub Actions workflow to include the new Rails 8.1 gemfile in the CI process. - Created a new gemfile for Rails 8.1 to manage dependencies. This ensures compatibility with the latest Rails version and enhances CI testing. --- .github/workflows/main.yml | 4 +++- Appraisals | 4 ++++ gemfiles/rails_8_1.gemfile | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 gemfiles/rails_8_1.gemfile diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 998422c8e..ddc47f50c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,10 +29,12 @@ jobs: - 3.3 - 3.4 database: [ mysql, postgres, sqlite ] - gemfile: [ rails_7_1, rails_7_2, rails_8_0, rails_main ] + gemfile: [ rails_7_1, rails_7_2, rails_8_0, rails_8_1, rails_main ] exclude: - ruby-version: "3.1" gemfile: rails_8_0 + - ruby-version: "3.1" + gemfile: rails_8_1 - ruby-version: "3.1" gemfile: rails_main services: diff --git a/Appraisals b/Appraisals index 248605287..9f3c8df48 100644 --- a/Appraisals +++ b/Appraisals @@ -15,6 +15,10 @@ appraise "rails-8-0" do gem "railties", "~> 8.0.0" end +appraise "rails-8-1" do + gem "railties", "~> 8.1.0" +end + appraise "rails-main" do gem "railties", github: "rails/rails", branch: "main" end diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile new file mode 100644 index 000000000..5e111e3d1 --- /dev/null +++ b/gemfiles/rails_8_1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "railties", "~> 8.1.0" + +gemspec path: "../" From 6eb29d9600d666e20cfccc7af3f86f80bc1bf3e2 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Thu, 23 Oct 2025 11:26:17 -0300 Subject: [PATCH 03/85] Removing non-deterministic check from `processes_lifecycle_test.rb` --- test/integration/processes_lifecycle_test.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/integration/processes_lifecycle_test.rb b/test/integration/processes_lifecycle_test.rb index a86b2f15f..1e957c0f9 100644 --- a/test/integration/processes_lifecycle_test.rb +++ b/test/integration/processes_lifecycle_test.rb @@ -182,12 +182,6 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase assert_completed_job_results("no exit", :background, 4) assert_completed_job_results("paused no exit", :default, 1) - # The background worker exits because of the exit job, - # leaving the pause job claimed - [ exit_job, pause_job ].each do |job| - assert_job_status(job, :claimed) - end - assert process_exists?(@pid) terminate_process(@pid) From 1f45b80b6a425872cc48c82c23f409c3083e1148 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 28 Oct 2025 19:23:03 +0100 Subject: [PATCH 04/85] Revert "Wrap Supervisor#start and stop with the app executor" This reverts commit 5aa05ae0830900b30509e0eb3aeec298de921650. --- lib/solid_queue/supervisor.rb | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index 05deaa52d..7d0105937 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -29,22 +29,18 @@ def initialize(configuration) end def start - wrap_in_app_executor do - boot - run_start_hooks + boot + run_start_hooks - start_processes - launch_maintenance_task + start_processes + launch_maintenance_task - supervise - end + supervise end def stop - wrap_in_app_executor do - super - run_stop_hooks - end + super + run_stop_hooks end private From 6b36dccf99e8b90a076e69226e0b0cb4ce26b26b Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 28 Oct 2025 21:07:13 +0100 Subject: [PATCH 05/85] Bump solid_queue to 1.2.3 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 32f66b90a..ad81123cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.2.2) + solid_queue (1.2.3) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index f1718f9a8..cae6fd8ca 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.2.2" + VERSION = "1.2.3" end From 5c6d4b0e411e0076f65d25a9e355ecc06fd8ba8d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 29 Oct 2025 12:29:06 -0400 Subject: [PATCH 06/85] Add wrap_in_app_executor in a few necessary places Process#register and #degister for the supervisor to start up properly. RecurringSchedule#reload_tasks resolves all the records immediately to avoid deferred resolution outside the executor block. Supervisor#handle_claimed_jobs_by wraps its code in the executor. A second attempt at #655 without causing the issues from #670 --- lib/solid_queue/processes/registrable.rb | 18 ++++++++++-------- .../scheduler/recurring_schedule.rb | 2 +- lib/solid_queue/supervisor.rb | 8 +++++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/solid_queue/processes/registrable.rb b/lib/solid_queue/processes/registrable.rb index 2cc9036d6..c74280104 100644 --- a/lib/solid_queue/processes/registrable.rb +++ b/lib/solid_queue/processes/registrable.rb @@ -18,17 +18,19 @@ def process_id attr_accessor :process def register - @process = SolidQueue::Process.register \ - kind: kind, - name: name, - pid: pid, - hostname: hostname, - supervisor: try(:supervisor), - metadata: metadata.compact + wrap_in_app_executor do + @process = SolidQueue::Process.register \ + kind: kind, + name: name, + pid: pid, + hostname: hostname, + supervisor: try(:supervisor), + metadata: metadata.compact + end end def deregister - process&.deregister + wrap_in_app_executor { process&.deregister } end def registered? diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 4070a0eca..b765edf14 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -46,7 +46,7 @@ def persist_tasks end def reload_tasks - @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys) + @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a end def schedule(task) diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index 7d0105937..ef9c79d6b 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -176,9 +176,11 @@ def replace_fork(pid, status) # executions it had claimed as failed so that they can be retried # by some other worker. def handle_claimed_jobs_by(terminated_fork, status) - if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) - error = Processes::ProcessExitError.new(status) - registered_process.fail_all_claimed_executions_with(error) + wrap_in_app_executor do + if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) + error = Processes::ProcessExitError.new(status) + registered_process.fail_all_claimed_executions_with(error) + end end end From 68d4ebbbea602eebc9e2e66a4c76bd25e6453446 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 29 Oct 2025 13:49:17 -0400 Subject: [PATCH 07/85] Fix testing against Rails main Default logging levels changed in rails/rails@308594b5 --- test/test_helper.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/test_helper.rb b/test/test_helper.rb index 7c1c87923..9e743f96b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -53,6 +53,17 @@ class ActiveSupport::TestCase end end + def run(...) + # Rails 8.1.dev changed default logging levels + if defined?(with_debug_event_reporting) + with_debug_event_reporting do + super + end + else + super + end + end + private def wait_while_with_timeout(timeout, &block) wait_while_with_timeout!(timeout, &block) From 1a2e38031538a4a3a551496409c192dc118d0dd9 Mon Sep 17 00:00:00 2001 From: Mario Date: Wed, 29 Oct 2025 19:58:45 +0100 Subject: [PATCH 08/85] print warning on startup if path to configuration file does not exist (#661) * print warning on startup if path to configuration file does not exist * change `assert_predicate` to `assert` Co-authored-by: Rosa Gutierrez --------- Co-authored-by: Rosa Gutierrez --- lib/solid_queue/configuration.rb | 1 + test/unit/configuration_test.rb | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index a002b41dd..b0083a17a 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -188,6 +188,7 @@ def load_config_from_file(file) if file.exist? ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys else + puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration." {} end end diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index 2ccaa7288..11c2a5ff2 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -26,6 +26,13 @@ class ConfigurationTest < ActiveSupport::TestCase assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size] end + test "warns if provided configuration file does not exist" do + assert_output "[solid_queue] WARNING: Provided configuration file '/path/to/nowhere.yml' does not exist. Falling back to default configuration.\n" do + configuration = SolidQueue::Configuration.new(config_file: Pathname.new("/path/to/nowhere.yml")) + assert configuration.valid? + end + end + test "read configuration from default file" do configuration = SolidQueue::Configuration.new assert 3, configuration.configured_processes.count @@ -120,16 +127,20 @@ class ConfigurationTest < ActiveSupport::TestCase assert error.include?("periodic_invalid_class: Class name doesn't correspond to an existing class") assert error.include?("periodic_incorrect_schedule: Schedule is not a supported recurring schedule") - assert SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:empty_recurring)).valid? + assert_output(/Provided configuration file '[^']+' does not exist\./) do + assert SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:empty_recurring)).valid? + end assert SolidQueue::Configuration.new(skip_recurring: true).valid? configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_production_only)) assert configuration.valid? assert_processes configuration, :scheduler, 0 - configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_empty)) - assert configuration.valid? - assert_processes configuration, :scheduler, 0 + assert_output(/Provided configuration file '[^']+' does not exist\./) do + configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_empty)) + assert configuration.valid? + assert_processes configuration, :scheduler, 0 + end # No processes configuration = SolidQueue::Configuration.new(skip_recurring: true, dispatchers: [], workers: []) From a6ac0c6b38b5643be90a2b56ba8515c19f9bae61 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Tue, 28 Oct 2025 14:41:00 -0300 Subject: [PATCH 09/85] Ensure dispatcher is stopped after assertion --- test/unit/dispatcher_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/dispatcher_test.rb b/test/unit/dispatcher_test.rb index 9aa2196ea..89d87c1ac 100644 --- a/test/unit/dispatcher_test.rb +++ b/test/unit/dispatcher_test.rb @@ -107,6 +107,8 @@ class DispatcherTest < ActiveSupport::TestCase assert_equal 0, SolidQueue::ScheduledExecution.count assert_equal 3, SolidQueue::ReadyExecution.count + ensure + dispatcher.stop end test "sleeps `polling_interval` between polls if there are no un-dispatched jobs" do @@ -117,6 +119,8 @@ class DispatcherTest < ActiveSupport::TestCase dispatcher.start wait_while_with_timeout(1.second) { !SolidQueue::ScheduledExecution.exists? } + ensure + dispatcher.stop end private From 4b6159c2a8622b2c0cb57b10c0d37231c44b88de Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Tue, 28 Oct 2025 14:41:58 -0300 Subject: [PATCH 10/85] Increase `retention-days` for failed CI logs --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ddc47f50c..7485e17ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -79,4 +79,4 @@ jobs: path: | test/dummy/log/test.log if-no-files-found: ignore - retention-days: 7 + retention-days: 30 From ac912dd90cd120ca7b88bd22b4df6de2a7489e29 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Tue, 28 Oct 2025 14:42:56 -0300 Subject: [PATCH 11/85] Reducing flakiness of `test/integration/concurrency_controls_test.rb` --- test/integration/concurrency_controls_test.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index 178c796d8..b3be95c5e 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -36,14 +36,16 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase end test "schedule several conflicting jobs over the same record sequentially" do - UpdateResultJob.set(wait: 0.23.seconds).perform_later(@result, name: "000", pause: 0.1.seconds) + # Writes to @result at 0.4s + UpdateResultJob.set(wait: 0.2.seconds).perform_later(@result, name: "000", pause: 0.2.seconds) ("A".."F").each_with_index do |name, i| - NonOverlappingUpdateResultJob.set(wait: (0.2 + i * 0.01).seconds).perform_later(@result, name: name, pause: 0.3.seconds) + # "A" is enqueued at 0.2s and writes to @result at 0.6s, the write at 0.4s gets overwritten + NonOverlappingUpdateResultJob.set(wait: (0.2 + i * 0.1).seconds).perform_later(@result, name: name, pause: 0.4.seconds) end ("G".."K").each_with_index do |name, i| - NonOverlappingUpdateResultJob.set(wait: (0.3 + i * 0.01).seconds).perform_later(@result, name: name) + NonOverlappingUpdateResultJob.set(wait: (1 + i * 0.1).seconds).perform_later(@result, name: name) end wait_for_jobs_to_finish_for(5.seconds) From 4fcd81b9384f682d7b7deb56a8772a35bb461d9f Mon Sep 17 00:00:00 2001 From: Shota Iguchi Date: Thu, 30 Oct 2025 17:18:05 +0900 Subject: [PATCH 12/85] Fix error class name in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92a018d4a..0f3e0b74f 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ When receiving a `QUIT` signal, if workers still have jobs in-flight, these will If processes have no chance of cleaning up before exiting (e.g. if someone pulls a cable somewhere), in-flight jobs might remain claimed by the processes executing them. Processes send heartbeats, and the supervisor checks and prunes processes with expired heartbeats. Jobs that were claimed by processes with an expired heartbeat will be marked as failed with a `SolidQueue::Processes::ProcessPrunedError`. You can configure both the frequency of heartbeats and the threshold to consider a process dead. See the section below for this. -In a similar way, if a worker is terminated in any other way not initiated by the above signals (e.g. a worker is sent a `KILL` signal), jobs in progress will be marked as failed so that they can be inspected, with a `SolidQueue::Processes::Process::ProcessExitError`. Sometimes a job in particular is responsible for this, for example, if it has a memory leak and you have a mechanism to kill processes over a certain memory threshold, so this will help identifying this kind of situation. +In a similar way, if a worker is terminated in any other way not initiated by the above signals (e.g. a worker is sent a `KILL` signal), jobs in progress will be marked as failed so that they can be inspected, with a `SolidQueue::Processes::ProcessExitError`. Sometimes a job in particular is responsible for this, for example, if it has a memory leak and you have a mechanism to kill processes over a certain memory threshold, so this will help identifying this kind of situation. ### Database configuration From c09de4705493aed68851b3baddcf22450a921558 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 30 Oct 2025 10:02:58 +0100 Subject: [PATCH 13/85] Bump solid_queue to 1.2.4 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ad81123cd..a930856ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.2.3) + solid_queue (1.2.4) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index cae6fd8ca..83e67ca87 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.2.3" + VERSION = "1.2.4" end From 2a90213a5f0f40f42f951d2a8d5eb0e1f32fa111 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Mon, 3 Nov 2025 15:54:46 -0300 Subject: [PATCH 14/85] Minimizing flakiness of `dispatcher_test.rb` --- test/unit/dispatcher_test.rb | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/unit/dispatcher_test.rb b/test/unit/dispatcher_test.rb index 89d87c1ac..359bb5046 100644 --- a/test/unit/dispatcher_test.rb +++ b/test/unit/dispatcher_test.rb @@ -40,8 +40,9 @@ class DispatcherTest < ActiveSupport::TestCase log = StringIO.new with_active_record_logger(ActiveSupport::Logger.new(log)) do with_polling(silence: false) do + rewind_io(log) @dispatcher.start - sleep 0.2 + sleep 0.5.second end end @@ -52,8 +53,9 @@ class DispatcherTest < ActiveSupport::TestCase log = StringIO.new with_active_record_logger(ActiveSupport::Logger.new(log)) do with_polling(silence: true) do + rewind_io(log) @dispatcher.start - sleep 0.2 + sleep 0.5.second end end @@ -64,7 +66,7 @@ class DispatcherTest < ActiveSupport::TestCase with_active_record_logger(nil) do with_polling(silence: true) do @dispatcher.start - sleep 0.2 + sleep 0.5.second end end @@ -75,8 +77,9 @@ class DispatcherTest < ActiveSupport::TestCase test "run more than one instance of the dispatcher" do 15.times do - AddToBufferJob.set(wait: 0.2).perform_later("I'm scheduled") + AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") end + sleep 0.5.second assert_equal 15, SolidQueue::ScheduledExecution.count another_dispatcher = SolidQueue::Dispatcher.new(polling_interval: 0.1, batch_size: 10) @@ -98,9 +101,9 @@ class DispatcherTest < ActiveSupport::TestCase dispatcher.expects(:interruptible_sleep).with(dispatcher.polling_interval).at_least_once dispatcher.expects(:handle_thread_error).never - 3.times { AddToBufferJob.set(wait: 0.1).perform_later("I'm scheduled") } + 3.times { AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") } + sleep 0.5.second assert_equal 3, SolidQueue::ScheduledExecution.count - sleep 0.1 dispatcher.start wait_while_with_timeout(1.second) { SolidQueue::ScheduledExecution.any? } @@ -137,4 +140,9 @@ def with_active_record_logger(logger) ensure ActiveRecord::Base.logger = old_logger end + + def rewind_io(log) + log.truncate(0) + log.rewind + end end From 692446dc96de524e93517ce603612c613a6fa180 Mon Sep 17 00:00:00 2001 From: Conrad Beach Date: Wed, 5 Nov 2025 13:23:05 -0700 Subject: [PATCH 15/85] docs: Improve consistency of README tone Generally in the README, it uses "we" and makes statements like "we recommend". Changing the instances of "I'd" to "we" seems to better match the rest of the document and feels like better phrasing for a project README. [skip ci] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f3e0b74f..f048ce580 100644 --- a/README.md +++ b/README.md @@ -519,11 +519,11 @@ DeliverAnnouncementToContactJob.set(wait: 30.minutes).perform_later(contact) The 3 jobs will go into the scheduled queue and will wait there until they're due. Then, 10 minutes after, the first two jobs will be enqueued and the second one most likely will be blocked because the first one will be running first. Then, assuming the jobs are fast and finish in a few seconds, when the third job is due, it'll be enqueued normally. -Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. I'd generally advise against mixing concurrency controls with waiting/scheduling in the future. +Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. We generally advise against mixing concurrency controls with waiting/scheduling in the future. ### Performance considerations -Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, I'd encourage relying on a limited number of workers per queue instead. For example: +Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, we encourage relying on a limited number of workers per queue instead. For example: ```ruby class ThrottledJob < ApplicationJob From c7e161c8dfb2ceef67a2a607db2ad0bfcf44d1cd Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Mon, 10 Nov 2025 10:37:15 -0300 Subject: [PATCH 16/85] Setting `timeout-minutes` to prevent deadlocked actions consuming GH quota --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7485e17ee..d7d307f52 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,6 +20,7 @@ jobs: tests: name: Tests runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: From a0d00eeaca2f94aba12089b5eb6bf7d403d898d9 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Mon, 10 Nov 2025 10:37:26 -0300 Subject: [PATCH 17/85] Increasing timeout gaps in `processes_lifecycle_test` --- test/integration/processes_lifecycle_test.rb | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/integration/processes_lifecycle_test.rb b/test/integration/processes_lifecycle_test.rb index 1e957c0f9..47d56b4d2 100644 --- a/test/integration/processes_lifecycle_test.rb +++ b/test/integration/processes_lifecycle_test.rb @@ -58,7 +58,7 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase signal_process(@pid, :TERM, wait: 0.1.second) end - sleep(1.second) + wait_while_with_timeout(SolidQueue.shutdown_timeout + 1.second) { process_exists?(@pid) } assert_clean_termination end @@ -121,14 +121,14 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase test "term supervisor exceeding timeout while there are jobs in-flight" do no_pause = enqueue_store_result_job("no pause") - pause = enqueue_store_result_job("pause", pause: SolidQueue.shutdown_timeout + 10.second) + pause = enqueue_store_result_job("pause", pause: SolidQueue.shutdown_timeout + 10.seconds) wait_while_with_timeout(1.second) { SolidQueue::ReadyExecution.count > 1 } signal_process(@pid, :TERM, wait: 0.5.second) wait_for_jobs_to_finish_for(2.seconds, except: pause) - wait_while_with_timeout!(SolidQueue.shutdown_timeout + 1.second) { process_exists?(@pid) } + wait_while_with_timeout!(SolidQueue.shutdown_timeout + 5.seconds) { process_exists?(@pid) } assert_not process_exists?(@pid) assert_completed_job_results("no pause") @@ -170,10 +170,13 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase enqueue_store_result_job("no exit", :background) enqueue_store_result_job("no exit", :default) end - enqueue_store_result_job("paused no exit", :default, pause: 0.5) - exit_job = enqueue_store_result_job("exit", :background, exit_value: 1, pause: 0.2) - pause_job = enqueue_store_result_job("exit", :background, pause: 0.3) + enqueue_store_result_job("paused no exit", :default, pause: 0.5.second) + # the worker for :background queue will exit abnormally + exit_job = enqueue_store_result_job("exit", :background, exit_value: 1, pause: 0.5.second) + # this will run *after* exit_job (pause: 1.second) - should also be marked as failed + pause_job = enqueue_store_result_job("exit", :background, pause: 1.second) + # this will run *before* exit_job (no pause) - should complete normally 2.times { enqueue_store_result_job("no exit", :background) } wait_for_jobs_to_finish_for(3.seconds, except: [ exit_job, pause_job ]) @@ -228,9 +231,11 @@ class ProcessesLifecycleTest < ActiveSupport::TestCase end test "kill worker individually" do - killed_pause = enqueue_store_result_job("killed_pause", pause: 1.second) + killed_pause = enqueue_store_result_job("killed_pause", pause: 2.seconds) enqueue_store_result_job("pause", :default, pause: 0.5.seconds) + wait_for_jobs_to_finish_for(1.second, except: [ killed_pause ]) + worker = find_processes_registered_as("Worker").detect { |process| process.metadata["queues"].include? "background" } signal_process(worker.pid, :KILL, wait: 0.5.seconds) From 4db1f1f5b04d479559ba97e569c052e270bcdc0d Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Mon, 17 Nov 2025 12:01:10 -0300 Subject: [PATCH 18/85] Attempt to reduce flakiness of `recurring_tasks_test` --- test/integration/recurring_tasks_test.rb | 43 +++++++++++++----------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/test/integration/recurring_tasks_test.rb b/test/integration/recurring_tasks_test.rb index 7367bc062..e8c711048 100644 --- a/test/integration/recurring_tasks_test.rb +++ b/test/integration/recurring_tasks_test.rb @@ -6,26 +6,17 @@ class RecurringTasksTest < ActiveSupport::TestCase self.use_transactional_tests = false setup do - @pid = run_supervisor_as_fork(skip_recurring: false) - # 1 supervisor + 2 workers + 1 dispatcher + 1 scheduler - wait_for_registered_processes(5, timeout: 3.second) + @pid = run_recurring_supervisor end teardown do - terminate_process(@pid) if process_exists?(@pid) - - SolidQueue::Process.destroy_all - SolidQueue::Job.destroy_all - SolidQueue::RecurringTask.delete_all - JobResult.delete_all + terminate_gracefully(@pid) end test "enqueue and process periodic tasks" do wait_for_jobs_to_be_enqueued(2, timeout: 2.5.seconds) wait_for_jobs_to_finish_for(2.5.seconds) - terminate_process(@pid) - skip_active_record_query_cache do assert SolidQueue::Job.count >= 2 SolidQueue::Job.all.each do |job| @@ -39,24 +30,20 @@ class RecurringTasksTest < ActiveSupport::TestCase assert_equal "42", result.value end end + + # no need to stop @pid supervisor - that will be handled in teardown end test "persist and delete configured tasks" do configured_task = { periodic_store_result: { class: "StoreResultJob", schedule: "every second" } } - # Wait for concurrency schedule loading after process registration - sleep(0.5) assert_recurring_tasks configured_task - terminate_process(@pid) - task = SolidQueue::RecurringTask.find_by(key: "periodic_store_result") task.update!(class_name: "StoreResultJob", schedule: "every minute", arguments: [ 42 ]) - @pid = run_supervisor_as_fork(skip_recurring: false) - wait_for_registered_processes(5, timeout: 3.second) + terminate_gracefully(@pid) - # Wait for concurrency schedule loading after process registration - sleep(0.5) + @pid = run_recurring_supervisor assert_recurring_tasks configured_task @@ -72,9 +59,10 @@ class RecurringTasksTest < ActiveSupport::TestCase assert_recurring_tasks updated_task - terminate_process(@pid) scheduler1.stop scheduler2.stop + + # no need to stop @pid supervisor - that will be handled in teardown end private @@ -92,4 +80,19 @@ def assert_recurring_tasks(expected_tasks) end end end + + def run_recurring_supervisor + pid = run_supervisor_as_fork(skip_recurring: false) + wait_for_registered_processes(5, timeout: 3.seconds) # 1 supervisor + 2 workers + 1 dispatcher + 1 scheduler + sleep 1.second # Wait for concurrency schedule loading after process registration + pid + end + + def terminate_gracefully(pid) + return if pid.nil? || !process_exists?(pid) + + terminate_process(pid) + wait_for_registered_processes(0, timeout: SolidQueue.shutdown_timeout) + sleep SolidQueue.shutdown_timeout + end end From b61bbdf8c3b4464cec33d505a87d490abd0508a1 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Mon, 17 Nov 2025 12:57:33 -0300 Subject: [PATCH 19/85] Removing comments && extra sleep --- test/integration/recurring_tasks_test.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/integration/recurring_tasks_test.rb b/test/integration/recurring_tasks_test.rb index e8c711048..b50e61c3f 100644 --- a/test/integration/recurring_tasks_test.rb +++ b/test/integration/recurring_tasks_test.rb @@ -30,8 +30,6 @@ class RecurringTasksTest < ActiveSupport::TestCase assert_equal "42", result.value end end - - # no need to stop @pid supervisor - that will be handled in teardown end test "persist and delete configured tasks" do @@ -61,8 +59,6 @@ class RecurringTasksTest < ActiveSupport::TestCase scheduler1.stop scheduler2.stop - - # no need to stop @pid supervisor - that will be handled in teardown end private @@ -93,6 +89,5 @@ def terminate_gracefully(pid) terminate_process(pid) wait_for_registered_processes(0, timeout: SolidQueue.shutdown_timeout) - sleep SolidQueue.shutdown_timeout end end From 839ba646072335cb65039fa38009ce64489e263b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=82=E3=81=98?= <221018698+simaaaji@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:33:24 +0900 Subject: [PATCH 20/85] =?UTF-8?q?Fixed=20indentation=20for=20the=20adapter?= =?UTF-8?q?=20line=20in=20the=20README=E2=80=99s=20cable.yaml=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f048ce580..58b26ed4f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ In `config/cable.yml` ```diff development: - adapter: async -+ adapter: solid_cable ++ adapter: solid_cable + connects_to: + database: + writing: cable From 78b8d94fbb7bfa0e8a24ef0438fc6f0dc8f28e1e Mon Sep 17 00:00:00 2001 From: Daniel Vu Dao Date: Tue, 2 Dec 2025 07:55:30 -0800 Subject: [PATCH 21/85] Fix spacing --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 58b26ed4f..8ae76bb0a 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ development: + primary: <<: *default database: storage/development.sqlite3 -+ queue: -+ <<: *default -+ database: storage/development_queue.sqlite3 -+ migrations_paths: db/queue_migrate ++ queue: ++ <<: *default ++ database: storage/development_queue.sqlite3 ++ migrations_paths: db/queue_migrate ``` Next, add the following to `development.rb` From d0bdafc1b2558d4e69d6f578c2e33ae89b00992f Mon Sep 17 00:00:00 2001 From: chapmajs Date: Sun, 30 Nov 2025 17:21:15 -0500 Subject: [PATCH 22/85] Update README.md with MariaDB `SKIP LOCKED` version information This PR notes that `SKIP LOCKED` is supported on MariaDB 10.6 and newer, and that one may need to set `config.solid_queue.use_skip_locked = false` for MariaDB versions < 10.6. I ran into this when setting up `solid_queue` on Slackware64 15.0, which is shipping MariaDB 10.5 series still. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ae76bb0a..7528905d8 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ end ### High performance requirements -Solid Queue was designed for the highest throughput when used with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications. +Solid Queue was designed for the highest throughput when used with MySQL 8+, MariaDB 10.6+, or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications. ## Configuration @@ -366,7 +366,7 @@ There are several settings that control how Solid Queue works that you can set a **This is not used for errors raised within a job execution**. Errors happening in jobs are handled by Active Job's `retry_on` or `discard_on`, and ultimately will result in [failed jobs](#failed-jobs-and-retries). This is for errors happening within Solid Queue itself. -- `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8, and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential. +- `use_skip_locked`: whether to use `FOR UPDATE SKIP LOCKED` when performing locking reads. This will be automatically detected in the future, and for now, you only need to set this to `false` if your database doesn't support it. For MySQL, that'd be versions < 8; for MariaDB, versions < 10.6; and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential. - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds. - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes. - `shutdown_timeout`: time the supervisor will wait since it sent the `TERM` signal to its supervised processes before sending a `QUIT` version to them requesting immediate termination—defaults to 5 seconds. From d00184ac77de41ee86c6e4ca324838842bdb2734 Mon Sep 17 00:00:00 2001 From: Pedro Mendes Date: Tue, 18 Nov 2025 11:54:21 -0300 Subject: [PATCH 23/85] Attempt to reduce flakiness of `scheduler_test` --- test/integration/instrumentation_test.rb | 4 ++-- test/test_helper.rb | 18 +++++++++++++----- test/unit/scheduler_test.rb | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/test/integration/instrumentation_test.rb b/test/integration/instrumentation_test.rb index 046700d0c..4440fe610 100644 --- a/test/integration/instrumentation_test.rb +++ b/test/integration/instrumentation_test.rb @@ -53,7 +53,7 @@ class InstrumentationTest < ActiveSupport::TestCase end test "stopping a worker with claimed executions emits release_claimed events" do - StoreResultJob.perform_later(42, pause: SolidQueue.shutdown_timeout + 100.second) + StoreResultJob.perform_later(42, pause: SolidQueue.shutdown_timeout + 15.seconds) process = nil events = subscribed(/release.*_claimed\.solid_queue/) do @@ -88,7 +88,7 @@ class InstrumentationTest < ActiveSupport::TestCase end test "starting and stopping a worker emits register_process and deregister_process events" do - StoreResultJob.perform_later(42, pause: SolidQueue.shutdown_timeout + 100.second) + StoreResultJob.perform_later(42, pause: SolidQueue.shutdown_timeout + 15.seconds) process = nil events = subscribed(/(register|deregister)_process\.solid_queue/) do diff --git a/test/test_helper.rb b/test/test_helper.rb index 9e743f96b..89e8df9fa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -30,10 +30,22 @@ class ExpectedTestError < RuntimeError; end class ActiveSupport::TestCase include ConfigurationTestHelper, ProcessesTestHelper, JobsTestHelper + def destroy_records + SolidQueue::Job.destroy_all + SolidQueue::Process.destroy_all + SolidQueue::Semaphore.delete_all + SolidQueue::RecurringTask.delete_all + JobResult.delete_all + end + setup do @_on_thread_error = SolidQueue.on_thread_error SolidQueue.on_thread_error = silent_on_thread_error_for(ExpectedTestError, @_on_thread_error) ActiveJob::QueueAdapters::SolidQueueAdapter.stopping = false + + unless self.class.use_transactional_tests + destroy_records + end end teardown do @@ -45,11 +57,7 @@ class ActiveSupport::TestCase end unless self.class.use_transactional_tests - SolidQueue::Job.destroy_all - SolidQueue::Process.destroy_all - SolidQueue::Semaphore.delete_all - SolidQueue::RecurringTask.delete_all - JobResult.delete_all + destroy_records end end diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 9478b9f18..6608b31e3 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -24,7 +24,7 @@ class SchedulerTest < ActiveSupport::TestCase end schedulers.each(&:start) - sleep 2 + wait_while_with_timeout(2.5.seconds) { SolidQueue::RecurringExecution.count != 2 } schedulers.each(&:stop) assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count From 24946304f92749f2a1b962f1887498866ff831ab Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 30 Dec 2025 21:21:52 +0100 Subject: [PATCH 24/85] Add Ruby 4 and stop using `minitest/mock` for `stub` For some reason this stopped working in CI and we don't really need it. I'm not sure why we did it in this way instead of using `mocha` ^_^U Rails still requires it, so let's make sure it's loaded in the test_helper. --- .github/workflows/main.yml | 3 +++ test/models/solid_queue/process_test.rb | 15 +++++++-------- test/test_helper.rb | 3 +++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d7d307f52..bc6c9a5e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,6 +13,7 @@ jobs: with: ruby-version: 3.4 bundler-cache: true + cache-version: 1 - name: Run rubocop run: | bundle exec rubocop --parallel @@ -29,6 +30,7 @@ jobs: - 3.2 - 3.3 - 3.4 + - 4.0 database: [ mysql, postgres, sqlite ] gemfile: [ rails_7_1, rails_7_2, rails_8_0, rails_8_1, rails_main ] exclude: @@ -64,6 +66,7 @@ jobs: with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true + cache-version: 1 - name: Update to latest Rails run: | bundle update railties diff --git a/test/models/solid_queue/process_test.rb b/test/models/solid_queue/process_test.rb index 489b2aca7..b69a67ded 100644 --- a/test/models/solid_queue/process_test.rb +++ b/test/models/solid_queue/process_test.rb @@ -1,5 +1,4 @@ require "test_helper" -require "minitest/mock" class SolidQueue::ProcessTest < ActiveSupport::TestCase test "prune processes with expired heartbeats" do @@ -58,16 +57,16 @@ class SolidQueue::ProcessTest < ActiveSupport::TestCase test "hostname's with special characters are properly loaded" do worker = SolidQueue::Worker.new(queues: "*", threads: 3, polling_interval: 0.2) - hostname = "Basecamp’s-Computer" + hostname = "Basecamp's-Computer" - Socket.stub :gethostname, hostname.dup.force_encoding("ASCII-8BIT") do - worker.start - wait_for_registered_processes(1, timeout: 1.second) + Socket.stubs(:gethostname).returns(hostname.dup.force_encoding("ASCII-8BIT")) - assert_equal hostname, SolidQueue::Process.last.hostname + worker.start + wait_for_registered_processes(1, timeout: 1.second) - worker.stop - end + assert_equal hostname, SolidQueue::Process.last.hostname + + worker.stop end test "clear unregistered changes before locking for heartbeat" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 89e8df9fa..30caca0e6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,9 @@ # Configure Rails Environment ENV["RAILS_ENV"] = "test" +# Ensure minitest gem is loaded before Rails tries to use minitest/mock +require "minitest" + require_relative "../test/dummy/config/environment" ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] require "rails/test_help" From c211352c56b81a9a140241139c7cff17f0a08eb5 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 30 Dec 2025 23:36:46 +0100 Subject: [PATCH 25/85] Use minitest 5.x as we still support Ruby 3.1 And minitest 6.x requires Ruby 3.2+ --- Gemfile.lock | 1 + solid_queue.gemspec | 1 + 2 files changed, 2 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index a930856ed..28252d35e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -201,6 +201,7 @@ DEPENDENCIES appraisal debug (~> 1.9) logger + minitest (~> 5.0) mocha mysql2 pg diff --git a/solid_queue.gemspec b/solid_queue.gemspec index 64818a2e2..3a5c9693e 100644 --- a/solid_queue.gemspec +++ b/solid_queue.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "appraisal" spec.add_development_dependency "debug", "~> 1.9" + spec.add_development_dependency "minitest", "~> 5.0" spec.add_development_dependency "mocha" spec.add_development_dependency "puma", "~> 7.0" spec.add_development_dependency "mysql2" From a0b35ead31216c198af4cff01cd289cd5b200208 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 1 Jan 2026 17:14:14 +0100 Subject: [PATCH 26/85] Use immediate transactions for Rails < 8.2 --- test/dummy/config/initializers/sqlite3.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/dummy/config/initializers/sqlite3.rb b/test/dummy/config/initializers/sqlite3.rb index bb3ee5db8..4a29f6574 100644 --- a/test/dummy/config/initializers/sqlite3.rb +++ b/test/dummy/config/initializers/sqlite3.rb @@ -1,8 +1,10 @@ module SqliteImmediateTransactions def begin_db_transaction - log("begin immediate transaction", "TRANSACTION") do - with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn| - conn.transaction(:immediate) + if Rails.gem_version < Gem::Version.new("8.2") + log("begin immediate transaction", "TRANSACTION") do + with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn| + conn.transaction(:immediate) + end end end end From 410acf12faa0d9d00b71df7916025e7468160c57 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 21:11:12 +0100 Subject: [PATCH 27/85] Adapt tests to Rails 8.2+ re-introduction of `enqueue_after_transaction_commit` See https://github.com/rails/rails/pull/55788 This setting has gone through different stages, and we must account for them: - Rails 7.1: Method not available (no deferred enqueue) - Rails 7.2: :default (defers to adapter's method, which returns true in Solid Queue's case) - Rails 8.0-8.1: false (no deferred enqueue, the setting was deprecated) - Rails 8.2: true (deferred enqueue enabled by default) --- test/integration/concurrency_controls_test.rb | 5 +++-- test/models/solid_queue/job_test.rb | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index b3be95c5e..d9e72b850 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -178,8 +178,9 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase end test "verify transactions remain valid after Job creation conflicts via limits_concurrency" do - # Doesn't work with enqueue_after_transaction_commit? true on SolidQueueAdapter, but only Rails 7.2 uses this - skip if Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 2 + # Doesn't work when enqueue_after_transaction_commit is enabled + skip if ActiveJob::Base.respond_to?(:enqueue_after_transaction_commit) && + [ true, :default ].include?(ActiveJob::Base.enqueue_after_transaction_commit) ActiveRecord::Base.transaction do NonOverlappingUpdateResultJob.perform_later(@result, name: "A", pause: 0.2.seconds) diff --git a/test/models/solid_queue/job_test.rb b/test/models/solid_queue/job_test.rb index 201e6d720..47702bd18 100644 --- a/test/models/solid_queue/job_test.rb +++ b/test/models/solid_queue/job_test.rb @@ -340,8 +340,9 @@ class DiscardableNonOverlappingGroupedJob2 < NonOverlappingJob end test "enqueue successfully inside a rolled-back transaction in the app DB" do - # Doesn't work with enqueue_after_transaction_commit? true on SolidQueueAdapter, but only Rails 7.2 uses this - skip if Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 2 + # Doesn't work when enqueue_after_transaction_commit is enabled + skip if ActiveJob::Base.respond_to?(:enqueue_after_transaction_commit) && + [ true, :default ].include?(ActiveJob::Base.enqueue_after_transaction_commit) assert_difference -> { SolidQueue::Job.count } do assert_no_difference -> { JobResult.count } do JobResult.transaction do From cfcbc3b71bf701be461ace4d6ae7d8d8b2d107b9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 21:27:47 +0100 Subject: [PATCH 28/85] Skip Ruby 3.2 with `rails_main` Rails main now requires Ruby >= 3.3 --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bc6c9a5e0..07b3475f7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,6 +40,8 @@ jobs: gemfile: rails_8_1 - ruby-version: "3.1" gemfile: rails_main + - ruby-version: "3.2" + gemfile: rails_main services: mysql: image: mysql:8.0.31 From 9ac1dbb19d325df83a30ff67ae6181f376697bb1 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 6 Jan 2026 10:45:26 +0100 Subject: [PATCH 29/85] Don't force SQLite immediate transaction for Rails >= 8 These are default built-in since Rails 8 See: https://github.com/rails/rails/pull/50371 --- test/dummy/config/initializers/sqlite3.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/dummy/config/initializers/sqlite3.rb b/test/dummy/config/initializers/sqlite3.rb index 4a29f6574..a5e45c339 100644 --- a/test/dummy/config/initializers/sqlite3.rb +++ b/test/dummy/config/initializers/sqlite3.rb @@ -1,10 +1,8 @@ module SqliteImmediateTransactions def begin_db_transaction - if Rails.gem_version < Gem::Version.new("8.2") - log("begin immediate transaction", "TRANSACTION") do - with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn| - conn.transaction(:immediate) - end + log("begin immediate transaction", "TRANSACTION") do + with_raw_connection(allow_retry: true, materialize_transactions: false) do |conn| + conn.transaction(:immediate) end end end @@ -26,7 +24,10 @@ def configure_connection ActiveSupport.on_load :active_record do if defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter) - ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend SqliteImmediateTransactions + # Rails 8.0+ has immediate transactions built-in + if Rails::VERSION::MAJOR < 8 + ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend SqliteImmediateTransactions + end ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend SQLite3Configuration end From d3305163aa758d3965755392c1d5adca3224a660 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 6 Jan 2026 12:09:22 +0100 Subject: [PATCH 30/85] Try to make concurrency-on-conflict-discard tests less flaky --- test/integration/concurrency_controls_test.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index d9e72b850..a12c48e42 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -197,6 +197,8 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase test "discard jobs when concurrency limit is reached with on_conflict: :discard" do job1 = DiscardableUpdateResultJob.perform_later(@result, name: "1", pause: 3) + sleep(0.1) + # should be discarded due to concurrency limit job2 = DiscardableUpdateResultJob.perform_later(@result, name: "2") # should also be discarded @@ -219,8 +221,9 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase test "discard on conflict across different concurrency keys" do another_result = JobResult.create!(queue_name: "default", status: "") - DiscardableUpdateResultJob.perform_later(@result, name: "1", pause: 0.2) - DiscardableUpdateResultJob.perform_later(another_result, name: "2", pause: 0.2) + DiscardableUpdateResultJob.perform_later(@result, name: "1", pause: 2) + DiscardableUpdateResultJob.perform_later(another_result, name: "2", pause: 2) + sleep(0.1) DiscardableUpdateResultJob.perform_later(@result, name: "3") # Should be discarded DiscardableUpdateResultJob.perform_later(another_result, name: "4") # Should be discarded From d95b8d4970df515cf011a955762d4290da7705a7 Mon Sep 17 00:00:00 2001 From: Joshua LeBlanc Date: Sun, 28 Sep 2025 16:20:25 -0300 Subject: [PATCH 31/85] Re-introduce async mode Which was removed in https://github.com/rails/solid_queue/pull/308 refactor: remove unused supervisor require statements fix: use 6075 instead of $ to get correct process ID in solid_queue plugin fix: don't modify array during iteration chore: remove duplicate code chore: logging refactor: remove async thread creation option from runnable process --- lib/puma/plugin/solid_queue.rb | 66 ++++++++++----- lib/solid_queue/async_supervisor.rb | 98 ++++++++++++++++++++++ lib/solid_queue/cli.rb | 2 + lib/solid_queue/configuration.rb | 11 ++- lib/solid_queue/fork_supervisor.rb | 113 ++++++++++++++++++++++++++ lib/solid_queue/processes/runnable.rb | 6 +- lib/solid_queue/supervisor.rb | 110 ++----------------------- 7 files changed, 278 insertions(+), 128 deletions(-) create mode 100644 lib/solid_queue/async_supervisor.rb create mode 100644 lib/solid_queue/fork_supervisor.rb diff --git a/lib/puma/plugin/solid_queue.rb b/lib/puma/plugin/solid_queue.rb index 434b8f65d..f67d57091 100644 --- a/lib/puma/plugin/solid_queue.rb +++ b/lib/puma/plugin/solid_queue.rb @@ -1,5 +1,13 @@ require "puma/plugin" +module Puma + class DSL + def solid_queue_mode(mode = :fork) + @options[:solid_queue_mode] = mode.to_sym + end + end +end + Puma::Plugin.create do attr_reader :puma_pid, :solid_queue_pid, :log_writer, :solid_queue_supervisor @@ -7,34 +15,54 @@ def start(launcher) @log_writer = launcher.log_writer @puma_pid = $$ - in_background do - monitor_solid_queue + if launcher.options[:solid_queue_mode] == :async + start_async(launcher) + else + start_forked(launcher) end + end - if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7") - launcher.events.on_booted do - @solid_queue_pid = fork do - Thread.new { monitor_puma } - SolidQueue::Supervisor.start - end + private + def start_forked(launcher) + in_background do + monitor_solid_queue end - launcher.events.on_stopped { stop_solid_queue } - launcher.events.on_restart { stop_solid_queue } - else - launcher.events.after_booted do - @solid_queue_pid = fork do - Thread.new { monitor_puma } - SolidQueue::Supervisor.start + if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7") + launcher.events.on_booted do + @solid_queue_pid = fork do + Thread.new { monitor_puma } + SolidQueue::Supervisor.start(mode: :fork) + end + end + + launcher.events.on_stopped { stop_solid_queue } + launcher.events.on_restart { stop_solid_queue } + else + launcher.events.after_booted do + @solid_queue_pid = fork do + Thread.new { monitor_puma } + SolidQueue::Supervisor.start(mode: :fork) + end end + + launcher.events.after_stopped { stop_solid_queue } + launcher.events.before_restart { stop_solid_queue } end + end - launcher.events.after_stopped { stop_solid_queue } - launcher.events.before_restart { stop_solid_queue } + def start_async(launcher) + if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7") + launcher.events.on_booted { @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + launcher.events.on_stopped { solid_queue_supervisor&.stop } + launcher.events.on_restart { solid_queue_supervisor&.stop; @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + else + launcher.events.after_booted { @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + launcher.events.after_stopped { solid_queue_supervisor&.stop } + launcher.events.before_restart { solid_queue_supervisor&.stop; @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + end end - end - private def stop_solid_queue Process.waitpid(solid_queue_pid, Process::WNOHANG) log "Stopping Solid Queue..." diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb new file mode 100644 index 000000000..329c1c070 --- /dev/null +++ b/lib/solid_queue/async_supervisor.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module SolidQueue + class AsyncSupervisor < Supervisor + private + attr_reader :threads + + def start_processes + @threads = {} + + configuration.configured_processes.each { |configured_process| start_process(configured_process) } + end + + def start_process(configured_process) + process_instance = configured_process.instantiate.tap do |instance| + instance.supervised_by process + instance.mode = :async + end + + thread = Thread.new do + begin + process_instance.start + rescue Exception => e + puts "Error in thread: #{e.message}" + puts e.backtrace + end + end + threads[thread] = [ process_instance, configured_process ] + end + + def terminate_gracefully + SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload| + processes.each(&:stop) + + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_threads_terminated? }) do + # No-op, we just wait + end + + unless all_threads_terminated? + payload[:shutdown_timeout_exceeded] = true + terminate_immediately + end + end + end + + def terminate_immediately + SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do + threads.keys.each(&:kill) + end + end + + def supervised_processes + processes.map(&:to_s) + end + + def reap_and_replace_terminated_forks + # No-op in async mode, we'll check for dead threads in the supervise loop + end + + def all_threads_terminated? + threads.keys.all? { |thread| !thread.alive? } + end + + def supervise + loop do + break if stopped? + + set_procline + process_signal_queue + + unless stopped? + check_and_replace_terminated_threads + interruptible_sleep(1.second) + end + end + ensure + shutdown + end + + def check_and_replace_terminated_threads + terminated_threads = {} + threads.each do |thread, (process, configured_process)| + unless thread.alive? + terminated_threads[thread] = configured_process + end + end + + terminated_threads.each do |thread, configured_process| + threads.delete(thread) + start_process(configured_process) + end + end + + def processes + threads.values.map(&:first) + end + end +end \ No newline at end of file diff --git a/lib/solid_queue/cli.rb b/lib/solid_queue/cli.rb index 7bfe555bc..930ddaab4 100644 --- a/lib/solid_queue/cli.rb +++ b/lib/solid_queue/cli.rb @@ -8,6 +8,8 @@ class Cli < Thor desc: "Path to config file (default: #{Configuration::DEFAULT_CONFIG_FILE_PATH}).", banner: "SOLID_QUEUE_CONFIG" + class_option :mode, type: :string, default: "fork", enum: %w[ fork async ], desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async)" + class_option :recurring_schedule_file, type: :string, desc: "Path to recurring schedule definition (default: #{Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH}).", banner: "SOLID_QUEUE_RECURRING_SCHEDULE" diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index b0083a17a..c942b6a53 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -31,8 +31,11 @@ def instantiate DEFAULT_CONFIG_FILE_PATH = "config/queue.yml" DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml" + attr_reader :mode + def initialize(**options) @options = options.with_defaults(default_options) + @mode = @options[:mode].to_s.inquiry end def configured_processes @@ -84,6 +87,7 @@ def ensure_correctly_sized_thread_pool def default_options { + mode: :fork, config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH), recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH), only_work: false, @@ -110,7 +114,12 @@ def skip_recurring_tasks? def workers workers_options.flat_map do |worker_options| - processes = worker_options.fetch(:processes, WORKER_DEFAULTS[:processes]) + processes = if mode.fork? + worker_options.fetch(:processes, WORKER_DEFAULTS[:processes]) + else + 1 + end + processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) } end end diff --git a/lib/solid_queue/fork_supervisor.rb b/lib/solid_queue/fork_supervisor.rb new file mode 100644 index 000000000..9e2ecea57 --- /dev/null +++ b/lib/solid_queue/fork_supervisor.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module SolidQueue + class ForkSupervisor < Supervisor + private + attr_reader :forks, :configured_processes + + def start_processes + @forks = {} + @configured_processes = {} + + configuration.configured_processes.each { |configured_process| start_process(configured_process) } + end + + def start_process(configured_process) + process_instance = configured_process.instantiate.tap do |instance| + instance.supervised_by process + instance.mode = :fork + end + + pid = fork do + process_instance.start + end + + configured_processes[pid] = configured_process + forks[pid] = process_instance + end + + def terminate_gracefully + SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload| + term_forks + + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do + reap_terminated_forks + end + + unless all_forks_terminated? + payload[:shutdown_timeout_exceeded] = true + terminate_immediately + end + end + end + + def terminate_immediately + SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do + quit_forks + end + end + + def supervised_processes + forks.keys + end + + def term_forks + signal_processes(forks.keys, :TERM) + end + + def quit_forks + signal_processes(forks.keys, :QUIT) + end + + def reap_and_replace_terminated_forks + loop do + pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) + break unless pid + + replace_fork(pid, status) + end + end + + def reap_terminated_forks + loop do + pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) + break unless pid + + if (terminated_fork = forks.delete(pid)) && (!status.exited? || status.exitstatus > 0) + handle_claimed_jobs_by(terminated_fork, status) + end + + configured_processes.delete(pid) + end + rescue SystemCallError + # All children already reaped + end + + def replace_fork(pid, status) + SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload| + if terminated_fork = forks.delete(pid) + payload[:fork] = terminated_fork + handle_claimed_jobs_by(terminated_fork, status) + + start_process(configured_processes.delete(pid)) + end + end + end + + # When a supervised fork crashes or exits we need to mark all the + # executions it had claimed as failed so that they can be retried + # by some other worker. + def handle_claimed_jobs_by(terminated_fork, status) + wrap_in_app_executor do + if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) + error = Processes::ProcessExitError.new(status) + registered_process.fail_all_claimed_executions_with(error) + end + end + end + + def all_forks_terminated? + forks.empty? + end + end +end diff --git a/lib/solid_queue/processes/runnable.rb b/lib/solid_queue/processes/runnable.rb index 33b441f61..aa5bb8f6c 100644 --- a/lib/solid_queue/processes/runnable.rb +++ b/lib/solid_queue/processes/runnable.rb @@ -9,11 +9,7 @@ module Runnable def start boot - if running_async? - @thread = create_thread { run } - else - run - end + run end def stop diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index ef9c79d6b..5c999a845 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -13,7 +13,8 @@ def start(**options) configuration = Configuration.new(**options) if configuration.valid? - new(configuration).tap(&:start) + klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor + klass.new(configuration).tap(&:start) else abort configuration.errors.full_messages.join("\n") + "\nExiting..." end @@ -22,9 +23,6 @@ def start(**options) def initialize(configuration) @configuration = configuration - @forks = {} - @configured_processes = {} - super end @@ -44,7 +42,7 @@ def stop end private - attr_reader :configuration, :forks, :configured_processes + attr_reader :configuration def boot SolidQueue.instrument(:start_process, process: self) do @@ -54,10 +52,6 @@ def boot end end - def start_processes - configuration.configured_processes.each { |configured_process| start_process(configured_process) } - end - def supervise loop do break if stopped? @@ -74,45 +68,14 @@ def supervise shutdown end - def start_process(configured_process) - process_instance = configured_process.instantiate.tap do |instance| - instance.supervised_by process - instance.mode = :fork - end - - pid = fork do - process_instance.start - end - - configured_processes[pid] = configured_process - forks[pid] = process_instance + def start_processes + raise NotImplementedError end def set_procline procline "supervising #{supervised_processes.join(", ")}" end - def terminate_gracefully - SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload| - term_forks - - Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do - reap_terminated_forks - end - - unless all_forks_terminated? - payload[:shutdown_timeout_exceeded] = true - terminate_immediately - end - end - end - - def terminate_immediately - SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do - quit_forks - end - end - def shutdown SolidQueue.instrument(:shutdown_process, process: self) do run_callbacks(:shutdown) do @@ -125,67 +88,8 @@ def sync_std_streams STDOUT.sync = STDERR.sync = true end - def supervised_processes - forks.keys - end - - def term_forks - signal_processes(forks.keys, :TERM) - end - - def quit_forks - signal_processes(forks.keys, :QUIT) - end - def reap_and_replace_terminated_forks - loop do - pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) - break unless pid - - replace_fork(pid, status) - end - end - - def reap_terminated_forks - loop do - pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) - break unless pid - - if (terminated_fork = forks.delete(pid)) && (!status.exited? || status.exitstatus > 0) - handle_claimed_jobs_by(terminated_fork, status) - end - - configured_processes.delete(pid) - end - rescue SystemCallError - # All children already reaped - end - - def replace_fork(pid, status) - SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload| - if terminated_fork = forks.delete(pid) - payload[:fork] = terminated_fork - handle_claimed_jobs_by(terminated_fork, status) - - start_process(configured_processes.delete(pid)) - end - end - end - - # When a supervised fork crashes or exits we need to mark all the - # executions it had claimed as failed so that they can be retried - # by some other worker. - def handle_claimed_jobs_by(terminated_fork, status) - wrap_in_app_executor do - if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) - error = Processes::ProcessExitError.new(status) - registered_process.fail_all_claimed_executions_with(error) - end - end - end - - def all_forks_terminated? - forks.empty? + # No-op by default, implemented in ForkSupervisor end end -end +end \ No newline at end of file From 50438703577ce9b0a12e7fe21776ef2f964950f1 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 30 Dec 2025 20:04:29 +0100 Subject: [PATCH 32/85] Refactor async mode and restore tests for it The tests were removed in 4b3483a9f85c408bba509b179c6ebccd5978e4c9 This also restores support for "standalone" async mode, which means the Solid Queue's supervisor running on its own process, via `bin/jobs`, but having all other processes (workers, dispatchers, schedulers) run as threads instead of processes. When Solid Queue is run via the Puma plugin, it won't be considered standalone, and it won't need to handle signals and act on them. --- lib/puma/plugin/solid_queue.rb | 40 +++- lib/solid_queue/async_supervisor.rb | 99 ++------ lib/solid_queue/configuration.rb | 12 +- lib/solid_queue/fork_supervisor.rb | 135 ++++------- lib/solid_queue/processes/runnable.rb | 32 ++- .../processes/thread_terminated_error.rb | 11 + lib/solid_queue/supervisor.rb | 74 +++++- lib/solid_queue/supervisor/signals.rb | 4 +- lib/solid_queue/timer.rb | 6 +- test/dummy/config/puma.rb | 45 +--- test/dummy/config/puma_async.rb | 46 ++++ test/dummy/config/puma_fork.rb | 46 ++++ .../async_processes_lifecycle_test.rb | 222 ++++++++++++++++++ ....rb => forked_processes_lifecycle_test.rb} | 4 +- test/integration/instrumentation_test.rb | 3 +- test/integration/lifecycle_hooks_test.rb | 2 +- test/integration/puma/plugin_async_test.rb | 13 + test/integration/puma/plugin_fork_test.rb | 30 +++ test/integration/puma/plugin_test.rb | 63 ----- test/integration/puma/plugin_testing.rb | 61 +++++ test/models/solid_queue/process_test.rb | 2 +- test/test_helper.rb | 2 + test/unit/async_supervisor_test.rb | 115 +++++++++ test/unit/dispatcher_test.rb | 74 +++--- ...rvisor_test.rb => fork_supervisor_test.rb} | 6 +- test/unit/process_recovery_test.rb | 2 +- test/unit/worker_test.rb | 1 + 27 files changed, 797 insertions(+), 353 deletions(-) create mode 100644 lib/solid_queue/processes/thread_terminated_error.rb mode change 100644 => 120000 test/dummy/config/puma.rb create mode 100644 test/dummy/config/puma_async.rb create mode 100644 test/dummy/config/puma_fork.rb create mode 100644 test/integration/async_processes_lifecycle_test.rb rename test/integration/{processes_lifecycle_test.rb => forked_processes_lifecycle_test.rb} (98%) create mode 100644 test/integration/puma/plugin_async_test.rb create mode 100644 test/integration/puma/plugin_fork_test.rb delete mode 100644 test/integration/puma/plugin_test.rb create mode 100644 test/integration/puma/plugin_testing.rb create mode 100644 test/unit/async_supervisor_test.rb rename test/unit/{supervisor_test.rb => fork_supervisor_test.rb} (97%) diff --git a/lib/puma/plugin/solid_queue.rb b/lib/puma/plugin/solid_queue.rb index f67d57091..70acfb9a9 100644 --- a/lib/puma/plugin/solid_queue.rb +++ b/lib/puma/plugin/solid_queue.rb @@ -36,8 +36,8 @@ def start_forked(launcher) end end - launcher.events.on_stopped { stop_solid_queue } - launcher.events.on_restart { stop_solid_queue } + launcher.events.on_stopped { stop_solid_queue_fork } + launcher.events.on_restart { stop_solid_queue_fork } else launcher.events.after_booted do @solid_queue_pid = fork do @@ -46,24 +46,38 @@ def start_forked(launcher) end end - launcher.events.after_stopped { stop_solid_queue } - launcher.events.before_restart { stop_solid_queue } + launcher.events.after_stopped { stop_solid_queue_fork } + launcher.events.before_restart { stop_solid_queue_fork } end end def start_async(launcher) if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7") - launcher.events.on_booted { @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } - launcher.events.on_stopped { solid_queue_supervisor&.stop } - launcher.events.on_restart { solid_queue_supervisor&.stop; @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + launcher.events.on_booted do + @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + end + + launcher.events.on_stopped { @solid_queue_supervisor&.stop } + + launcher.events.on_restart do + solid_queue_supervisor&.stop + @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + end else - launcher.events.after_booted { @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } - launcher.events.after_stopped { solid_queue_supervisor&.stop } - launcher.events.before_restart { solid_queue_supervisor&.stop; @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async) } + launcher.events.after_booted do + @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + end + + launcher.events.after_stopped { @solid_queue_supervisor&.stop } + + launcher.events.before_restart do + solid_queue_supervisor&.stop + @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + end end end - def stop_solid_queue + def stop_solid_queue_fork Process.waitpid(solid_queue_pid, Process::WNOHANG) log "Stopping Solid Queue..." Process.kill(:INT, solid_queue_pid) if solid_queue_pid @@ -76,7 +90,7 @@ def monitor_puma end def monitor_solid_queue - monitor(:solid_queue_dead?, "Detected Solid Queue has gone away, stopping Puma...") + monitor(:solid_queue_fork_dead?, "Detected Solid Queue has gone away, stopping Puma...") end def monitor(process_dead, message) @@ -90,7 +104,7 @@ def monitor(process_dead, message) end end - def solid_queue_dead? + def solid_queue_fork_dead? if solid_queue_started? Process.waitpid(solid_queue_pid, Process::WNOHANG) end diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index 329c1c070..f1a8aed28 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -3,96 +3,43 @@ module SolidQueue class AsyncSupervisor < Supervisor private - attr_reader :threads - - def start_processes - @threads = {} - - configuration.configured_processes.each { |configured_process| start_process(configured_process) } + def check_and_replace_terminated_processes + terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } + terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } end - def start_process(configured_process) - process_instance = configured_process.instantiate.tap do |instance| - instance.supervised_by process - instance.mode = :async - end + def replace_thread(thread_id, instance) + SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| + payload[:thread] = instance + handle_claimed_jobs_by(terminated_instance, thread) - thread = Thread.new do - begin - process_instance.start - rescue Exception => e - puts "Error in thread: #{e.message}" - puts e.backtrace - end + start_process(configured_processes.delete(thread_id)) end - threads[thread] = [ process_instance, configured_process ] end - def terminate_gracefully - SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload| - processes.each(&:stop) - - Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_threads_terminated? }) do - # No-op, we just wait - end - - unless all_threads_terminated? - payload[:shutdown_timeout_exceeded] = true - terminate_immediately - end - end - end + def perform_graceful_termination + process_instances.values.each(&:stop) - def terminate_immediately - SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do - threads.keys.each(&:kill) - end + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) end - def supervised_processes - processes.map(&:to_s) + def perform_immediate_termination + exit! end - def reap_and_replace_terminated_forks - # No-op in async mode, we'll check for dead threads in the supervise loop + def all_processes_terminated? + process_instances.values.none?(&:alive?) end - def all_threads_terminated? - threads.keys.all? { |thread| !thread.alive? } - end - - def supervise - loop do - break if stopped? - - set_procline - process_signal_queue - - unless stopped? - check_and_replace_terminated_threads - interruptible_sleep(1.second) + # When a supervised thread terminates unexpectedly, mark all executions + # it had claimed as failed so they can be retried by another worker. + def handle_claimed_jobs_by(terminated_instance, thread) + wrap_in_app_executor do + if registered_process = SolidQueue::Process.find_by(name: terminated_instance.name) + error = Processes::ThreadTerminatedError.new(terminated_instance.name) + registered_process.fail_all_claimed_executions_with(error) end end - ensure - shutdown - end - - def check_and_replace_terminated_threads - terminated_threads = {} - threads.each do |thread, (process, configured_process)| - unless thread.alive? - terminated_threads[thread] = configured_process - end - end - - terminated_threads.each do |thread, configured_process| - threads.delete(thread) - start_process(configured_process) - end - end - - def processes - threads.values.map(&:first) end end -end \ No newline at end of file +end diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index c942b6a53..c62d61419 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -31,11 +31,8 @@ def instantiate DEFAULT_CONFIG_FILE_PATH = "config/queue.yml" DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml" - attr_reader :mode - def initialize(**options) @options = options.with_defaults(default_options) - @mode = @options[:mode].to_s.inquiry end def configured_processes @@ -59,6 +56,14 @@ def error_messages end end + def mode + @options[:mode].to_s.inquiry + end + + def standalone? + mode.fork? || @options[:standalone] + end + private attr_reader :options @@ -88,6 +93,7 @@ def ensure_correctly_sized_thread_pool def default_options { mode: :fork, + standalone: true, config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH), recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH), only_work: false, diff --git a/lib/solid_queue/fork_supervisor.rb b/lib/solid_queue/fork_supervisor.rb index 9e2ecea57..126b21710 100644 --- a/lib/solid_queue/fork_supervisor.rb +++ b/lib/solid_queue/fork_supervisor.rb @@ -3,111 +3,76 @@ module SolidQueue class ForkSupervisor < Supervisor private - attr_reader :forks, :configured_processes - def start_processes - @forks = {} - @configured_processes = {} + def perform_graceful_termination + term_forks - configuration.configured_processes.each { |configured_process| start_process(configured_process) } + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) do + reap_terminated_forks end + end - def start_process(configured_process) - process_instance = configured_process.instantiate.tap do |instance| - instance.supervised_by process - instance.mode = :fork - end - - pid = fork do - process_instance.start - end - - configured_processes[pid] = configured_process - forks[pid] = process_instance - end - - def terminate_gracefully - SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload| - term_forks - - Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do - reap_terminated_forks - end - - unless all_forks_terminated? - payload[:shutdown_timeout_exceeded] = true - terminate_immediately - end - end - end + def perform_immediate_termination + quit_forks + end - def terminate_immediately - SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do - quit_forks - end - end + def term_forks + signal_processes(process_instances.keys, :TERM) + end - def supervised_processes - forks.keys - end + def quit_forks + signal_processes(process_instances.keys, :QUIT) + end - def term_forks - signal_processes(forks.keys, :TERM) - end + def check_and_replace_terminated_processes + loop do + pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) + break unless pid - def quit_forks - signal_processes(forks.keys, :QUIT) + replace_fork(pid, status) end + end - def reap_and_replace_terminated_forks - loop do - pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) - break unless pid + def reap_terminated_forks + loop do + pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) + break unless pid - replace_fork(pid, status) + if (terminated_fork = process_instances.delete(pid)) && !status.exited? || status.exitstatus > 0 + handle_claimed_jobs_by(terminated_fork, status) end - end - def reap_terminated_forks - loop do - pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) - break unless pid - - if (terminated_fork = forks.delete(pid)) && (!status.exited? || status.exitstatus > 0) - handle_claimed_jobs_by(terminated_fork, status) - end - - configured_processes.delete(pid) - end - rescue SystemCallError - # All children already reaped + configured_processes.delete(pid) end + rescue SystemCallError + # All children already reaped + end - def replace_fork(pid, status) - SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload| - if terminated_fork = forks.delete(pid) - payload[:fork] = terminated_fork - handle_claimed_jobs_by(terminated_fork, status) + def replace_fork(pid, status) + SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload| + if terminated_fork = process_instances.delete(pid) + payload[:fork] = terminated_fork + handle_claimed_jobs_by(terminated_fork, status) - start_process(configured_processes.delete(pid)) - end + start_process(configured_processes.delete(pid)) end end - - # When a supervised fork crashes or exits we need to mark all the - # executions it had claimed as failed so that they can be retried - # by some other worker. - def handle_claimed_jobs_by(terminated_fork, status) - wrap_in_app_executor do - if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) - error = Processes::ProcessExitError.new(status) - registered_process.fail_all_claimed_executions_with(error) - end + end + + # When a supervised fork crashes or exits we need to mark all the + # executions it had claimed as failed so that they can be retried + # by some other worker. + def handle_claimed_jobs_by(terminated_fork, status) + wrap_in_app_executor do + if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) + error = Processes::ProcessExitError.new(status) + registered_process.fail_all_claimed_executions_with(error) end end + end - def all_forks_terminated? - forks.empty? - end + def all_processes_terminated? + process_instances.empty? + end end end diff --git a/lib/solid_queue/processes/runnable.rb b/lib/solid_queue/processes/runnable.rb index aa5bb8f6c..a3aa4fab1 100644 --- a/lib/solid_queue/processes/runnable.rb +++ b/lib/solid_queue/processes/runnable.rb @@ -7,16 +7,26 @@ module Runnable attr_writer :mode def start - boot - - run + run_in_mode do + boot + run + end end def stop super - wake_up - @thread&.join + + # When not supervised, block until the thread terminates for backward + # compatibility with code that expects stop to be synchronous. + # When supervised, the supervisor controls the shutdown timeout. + unless supervised? + @thread&.join + end + end + + def alive? + !running_async? || @thread&.alive? end private @@ -26,6 +36,18 @@ def mode (@mode || DEFAULT_MODE).to_s.inquiry end + def run_in_mode(&block) + case + when running_as_fork? + fork(&block) + when running_async? + @thread = create_thread(&block) + @thread.object_id + else + block.call + end + end + def boot SolidQueue.instrument(:start_process, process: self) do run_callbacks(:boot) do diff --git a/lib/solid_queue/processes/thread_terminated_error.rb b/lib/solid_queue/processes/thread_terminated_error.rb new file mode 100644 index 000000000..7f1d6f7af --- /dev/null +++ b/lib/solid_queue/processes/thread_terminated_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SolidQueue + module Processes + class ThreadTerminatedError < RuntimeError + def initialize(name) + super("Thread #{name} terminated unexpectedly") + end + end + end +end diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index 5c999a845..89696eba5 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -21,8 +21,14 @@ def start(**options) end end + delegate :mode, :standalone?, to: :configuration + def initialize(configuration) @configuration = configuration + + @configured_processes = {} + @process_instances = {} + super end @@ -41,8 +47,12 @@ def stop run_stop_hooks end + def kind + "Supervisor(#{mode})" + end + private - attr_reader :configuration + attr_reader :configuration, :configured_processes, :process_instances def boot SolidQueue.instrument(:start_process, process: self) do @@ -52,15 +62,21 @@ def boot end end + def start_processes + configuration.configured_processes.each { |configured_process| start_process(configured_process) } + end + def supervise loop do break if stopped? - set_procline - process_signal_queue + if standalone? + set_procline + process_signal_queue + end unless stopped? - reap_and_replace_terminated_forks + check_and_replace_terminated_processes interruptible_sleep(1.second) end end @@ -68,12 +84,48 @@ def supervise shutdown end - def start_processes + def start_process(configured_process) + process_instance = configured_process.instantiate.tap do |instance| + instance.supervised_by process + instance.mode = mode + end + + process_id = process_instance.start + + configured_processes[process_id] = configured_process + process_instances[process_id] = process_instance + end + + def check_and_replace_terminated_processes + end + + def terminate_gracefully + SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do |payload| + perform_graceful_termination + + unless all_processes_terminated? + payload[:shutdown_timeout_exceeded] = true + terminate_immediately + end + end + end + + def terminate_immediately + SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do + perform_immediate_termination + end + end + + def perform_graceful_termination raise NotImplementedError end - def set_procline - procline "supervising #{supervised_processes.join(", ")}" + def perform_immediate_termination + raise NotImplementedError + end + + def all_processes_terminated? + raise NotImplementedError end def shutdown @@ -84,12 +136,12 @@ def shutdown end end - def sync_std_streams - STDOUT.sync = STDERR.sync = true + def set_procline + procline "supervising #{configured_processes.keys.join(", ")}" end - def reap_and_replace_terminated_forks - # No-op by default, implemented in ForkSupervisor + def sync_std_streams + STDOUT.sync = STDERR.sync = true end end end \ No newline at end of file diff --git a/lib/solid_queue/supervisor/signals.rb b/lib/solid_queue/supervisor/signals.rb index fe0960d5b..7bee107d5 100644 --- a/lib/solid_queue/supervisor/signals.rb +++ b/lib/solid_queue/supervisor/signals.rb @@ -6,8 +6,8 @@ module Signals extend ActiveSupport::Concern included do - before_boot :register_signal_handlers - after_shutdown :restore_default_signal_handlers + before_boot :register_signal_handlers, if: :standalone? + after_shutdown :restore_default_signal_handlers, if: :standalone? end private diff --git a/lib/solid_queue/timer.rb b/lib/solid_queue/timer.rb index ca16466d2..19e691f53 100644 --- a/lib/solid_queue/timer.rb +++ b/lib/solid_queue/timer.rb @@ -4,18 +4,18 @@ module SolidQueue module Timer extend self - def wait_until(timeout, condition, &block) + def wait_until(timeout, condition) if timeout > 0 deadline = monotonic_time_now + timeout while monotonic_time_now < deadline && !condition.call sleep 0.1 - block.call + yield if block_given? end else while !condition.call sleep 0.5 - block.call + yield if block_given? end end end diff --git a/test/dummy/config/puma.rb b/test/dummy/config/puma.rb deleted file mode 100644 index d4f1ae108..000000000 --- a/test/dummy/config/puma.rb +++ /dev/null @@ -1,44 +0,0 @@ -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. -# -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# Specifies the `worker_timeout` threshold that Puma will use to wait before -# terminating a worker in development environments. -# -worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -# -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. -# -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked web server processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). -# -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. -# -# preload_app! - -# Allow puma to be restarted by `bin/rails restart` command. -plugin :tmp_restart -plugin :solid_queue diff --git a/test/dummy/config/puma.rb b/test/dummy/config/puma.rb new file mode 120000 index 000000000..f923a8263 --- /dev/null +++ b/test/dummy/config/puma.rb @@ -0,0 +1 @@ +puma_fork.rb \ No newline at end of file diff --git a/test/dummy/config/puma_async.rb b/test/dummy/config/puma_async.rb new file mode 100644 index 000000000..beb652595 --- /dev/null +++ b/test/dummy/config/puma_async.rb @@ -0,0 +1,46 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +# workers ENV.fetch("WEB_CONCURRENCY") { 2 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +# preload_app! + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart +plugin :solid_queue + +solid_queue_mode :async diff --git a/test/dummy/config/puma_fork.rb b/test/dummy/config/puma_fork.rb new file mode 100644 index 000000000..4cdbbfd1d --- /dev/null +++ b/test/dummy/config/puma_fork.rb @@ -0,0 +1,46 @@ +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PORT") { 3000 } + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +# workers ENV.fetch("WEB_CONCURRENCY") { 2 } + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +# preload_app! + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart +plugin :solid_queue + +solid_queue_mode :fork diff --git a/test/integration/async_processes_lifecycle_test.rb b/test/integration/async_processes_lifecycle_test.rb new file mode 100644 index 000000000..1d22a2c9a --- /dev/null +++ b/test/integration/async_processes_lifecycle_test.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "test_helper" + +class AsyncProcessesLifecycleTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + setup do + @pid = run_supervisor_as_fork(mode: :async, workers: [ { queues: :background }, { queues: :default, threads: 5 } ]) + + wait_for_registered_processes(3, timeout: 3.second) + assert_registered_workers_for(:background, :default, supervisor_pid: @pid) + end + + teardown do + terminate_process(@pid) if process_exists?(@pid) + end + + test "enqueue jobs in multiple queues" do + 6.times { |i| enqueue_store_result_job("job_#{i}") } + 6.times { |i| enqueue_store_result_job("job_#{i}", :default) } + + wait_for_jobs_to_finish_for(2.seconds) + + assert_equal 12, JobResult.count + 6.times { |i| assert_completed_job_results("job_#{i}", :background) } + 6.times { |i| assert_completed_job_results("job_#{i}", :default) } + + terminate_process(@pid) + assert_clean_termination + end + + test "kill supervisor while there are jobs in-flight" do + no_pause = enqueue_store_result_job("no pause") + pause = enqueue_store_result_job("pause", pause: 1.second) + + signal_process(@pid, :KILL, wait: 0.2.seconds) + wait_for_jobs_to_finish_for(2.seconds) + wait_for_registered_processes(1, timeout: 3.second) + + assert_not process_exists?(@pid) + + assert_completed_job_results("no pause") + assert_job_status(no_pause, :finished) + + # In async mode, killing the supervisor kills all threads too, + # so we can't complete in-flight jobs + assert_registered_supervisor + assert_registered_workers_for(:background, :default, supervisor_pid: @pid) + assert_started_job_result("pause") + assert_claimed_jobs + end + + test "term supervisor multiple times" do + 5.times do + signal_process(@pid, :TERM, wait: 0.1.second) + end + + sleep(1.second) + assert_clean_termination + end + + test "quit supervisor while there are jobs in-flight" do + no_pause = enqueue_store_result_job("no pause") + pause = enqueue_store_result_job("pause", pause: 1.second) + + wait_while_with_timeout(1.second) { SolidQueue::ReadyExecution.count > 0 } + + signal_process(@pid, :QUIT, wait: 0.4.second) + wait_for_jobs_to_finish_for(2.seconds, except: pause) + + wait_while_with_timeout(2.seconds) { process_exists?(@pid) } + assert_not process_exists?(@pid) + + # In async mode, QUIT calls exit! which terminates immediately without cleanup. + # The in-flight job remains claimed and the process/workers remain registered. + # A future supervisor will need to prune and fail these orphaned executions. + assert_completed_job_results("no pause") + assert_job_status(no_pause, :finished) + assert_started_job_result("pause") + assert_job_status(pause, :claimed) + + assert_registered_supervisor + assert_registered_workers_for(:background, :default, supervisor_pid: @pid) + assert_claimed_jobs + end + + test "term supervisor while there are jobs in-flight" do + no_pause = enqueue_store_result_job("no pause") + pause = enqueue_store_result_job("pause", pause: 0.2.seconds) + + signal_process(@pid, :TERM, wait: 0.3.second) + wait_for_jobs_to_finish_for(3.seconds) + + assert_completed_job_results("no pause") + assert_completed_job_results("pause") + + assert_job_status(no_pause, :finished) + assert_job_status(pause, :finished) + + wait_for_process_termination_with_timeout(@pid, timeout: 1.second) + assert_clean_termination + end + + test "int supervisor while there are jobs in-flight" do + no_pause = enqueue_store_result_job("no pause") + pause = enqueue_store_result_job("pause", pause: 0.2.seconds) + + signal_process(@pid, :INT, wait: 0.3.second) + wait_for_jobs_to_finish_for(2.second) + + assert_completed_job_results("no pause") + assert_completed_job_results("pause") + + assert_job_status(no_pause, :finished) + assert_job_status(pause, :finished) + + wait_for_process_termination_with_timeout(@pid, timeout: 1.second) + assert_clean_termination + end + + test "term supervisor exceeding timeout while there are jobs in-flight" do + no_pause = enqueue_store_result_job("no pause") + pause = enqueue_store_result_job("pause", pause: SolidQueue.shutdown_timeout + 10.second) + + wait_while_with_timeout(1.second) { SolidQueue::ReadyExecution.count > 1 } + + signal_process(@pid, :TERM, wait: 0.5.second) + wait_for_jobs_to_finish_for(2.seconds, except: pause) + + # exit! exits with status 1 by default + wait_for_process_termination_with_timeout(@pid, timeout: SolidQueue.shutdown_timeout + 5.seconds, exitstatus: 1) + assert_not process_exists?(@pid) + + assert_completed_job_results("no pause") + assert_job_status(no_pause, :finished) + + # When timeout is exceeded, exit! is called without cleanup. + # The in-flight job stays claimed and processes stay registered. + # A future supervisor will need to prune and fail these orphaned executions. + assert_started_job_result("pause") + assert_job_status(pause, :claimed) + + assert_registered_supervisor + assert find_processes_registered_as("Worker").any? { |w| w.metadata["queues"].include?("background") } + assert_claimed_jobs + end + + test "process some jobs that raise errors" do + 2.times { enqueue_store_result_job("no error", :background) } + 2.times { enqueue_store_result_job("no error", :default) } + error1 = enqueue_store_result_job("error", :background, exception: ExpectedTestError) + enqueue_store_result_job("no error", :background, pause: 0.03) + error2 = enqueue_store_result_job("error", :background, exception: ExpectedTestError, pause: 0.05) + 2.times { enqueue_store_result_job("no error", :default, pause: 0.01) } + error3 = enqueue_store_result_job("error", :default, exception: ExpectedTestError) + + wait_for_jobs_to_finish_for(2.second, except: [ error1, error2, error3 ]) + + assert_completed_job_results("no error", :background, 3) + assert_completed_job_results("no error", :default, 4) + + wait_while_with_timeout(1.second) { SolidQueue::FailedExecution.count < 3 } + [ error1, error2, error3 ].each do |job| + assert_job_status(job, :failed) + end + + terminate_process(@pid) + assert_clean_termination + end + + + private + def assert_clean_termination + wait_for_registered_processes 0, timeout: 0.2.second + assert_no_registered_processes + assert_no_claimed_jobs + assert_not process_exists?(@pid) + end + + def assert_registered_workers_for(*queues, supervisor_pid: nil) + workers = find_processes_registered_as("Worker") + registered_queues = workers.map { |process| process.metadata["queues"] }.compact + assert_equal queues.map(&:to_s).sort, registered_queues.sort + if supervisor_pid + assert_equal [ supervisor_pid ], workers.map { |process| process.supervisor.pid }.uniq + end + end + + def assert_registered_supervisor + processes = find_processes_registered_as("Supervisor(async)") + assert_equal 1, processes.count + assert_equal @pid, processes.first.pid + end + + def assert_no_registered_workers + assert_empty find_processes_registered_as("Worker").to_a + end + + def enqueue_store_result_job(value, queue_name = :background, **options) + StoreResultJob.set(queue: queue_name).perform_later(value, **options) + end + + def assert_completed_job_results(value, queue_name = :background, count = 1) + skip_active_record_query_cache do + assert_equal count, JobResult.where(queue_name: queue_name, status: "completed", value: value).count + end + end + + def assert_started_job_result(value, queue_name = :background, count = 1) + skip_active_record_query_cache do + assert_equal count, JobResult.where(queue_name: queue_name, status: "started", value: value).count + end + end + + def assert_job_status(active_job, status) + skip_active_record_query_cache do + job = SolidQueue::Job.find_by(active_job_id: active_job.job_id) + assert job.public_send("#{status}?") + end + end +end diff --git a/test/integration/processes_lifecycle_test.rb b/test/integration/forked_processes_lifecycle_test.rb similarity index 98% rename from test/integration/processes_lifecycle_test.rb rename to test/integration/forked_processes_lifecycle_test.rb index 47d56b4d2..561166c50 100644 --- a/test/integration/processes_lifecycle_test.rb +++ b/test/integration/forked_processes_lifecycle_test.rb @@ -2,7 +2,7 @@ require "test_helper" -class ProcessesLifecycleTest < ActiveSupport::TestCase +class ForkedProcessesLifecycleTest < ActiveSupport::TestCase self.use_transactional_tests = false setup do @@ -283,7 +283,7 @@ def assert_registered_workers_for(*queues, supervisor_pid: nil) end def assert_registered_supervisor_with(pid) - processes = find_processes_registered_as("Supervisor") + processes = find_processes_registered_as("Supervisor(fork)") assert_equal 1, processes.count assert_equal pid, processes.first.pid end diff --git a/test/integration/instrumentation_test.rb b/test/integration/instrumentation_test.rb index 4440fe610..7e2a51ca1 100644 --- a/test/integration/instrumentation_test.rb +++ b/test/integration/instrumentation_test.rb @@ -166,12 +166,11 @@ class InstrumentationTest < ActiveSupport::TestCase SolidQueue::Process.any_instance.expects(:destroy!).raises(error).at_least_once events = subscribed("deregister_process.solid_queue") do - assert_raises RuntimeError do + assert_raises ExpectedTestError do worker = SolidQueue::Worker.new.tap(&:start) wait_for_registered_processes(1, timeout: 1.second) worker.stop - wait_for_registered_processes(0, timeout: 1.second) end end diff --git a/test/integration/lifecycle_hooks_test.rb b/test/integration/lifecycle_hooks_test.rb index da7feedce..7cd04a82d 100644 --- a/test/integration/lifecycle_hooks_test.rb +++ b/test/integration/lifecycle_hooks_test.rb @@ -87,7 +87,7 @@ class LifecycleHooksTest < ActiveSupport::TestCase end assert_equal %w[ - supervisor_start supervisor_stop supervisor_exit + forksupervisor_start forksupervisor_stop forksupervisor_exit worker_first_queue_start worker_first_queue_stop worker_first_queue_exit worker_second_queue_start worker_second_queue_stop worker_second_queue_exit dispatcher_100_start dispatcher_100_stop dispatcher_100_exit diff --git a/test/integration/puma/plugin_async_test.rb b/test/integration/puma/plugin_async_test.rb new file mode 100644 index 000000000..551ebd634 --- /dev/null +++ b/test/integration/puma/plugin_async_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "plugin_testing" + +class PluginAsyncTest < ActiveSupport::TestCase + include PluginTesting + + private + def solid_queue_mode + :async + end +end diff --git a/test/integration/puma/plugin_fork_test.rb b/test/integration/puma/plugin_fork_test.rb new file mode 100644 index 000000000..40f1fd18e --- /dev/null +++ b/test/integration/puma/plugin_fork_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "plugin_testing" + +class PluginForkTest < ActiveSupport::TestCase + include PluginTesting + + test "stop puma when solid queue's supervisor dies" do + supervisor = find_processes_registered_as("Supervisor(fork)").first + + signal_process(supervisor.pid, :KILL) + wait_for_process_termination_with_timeout(@pid) + + assert_not process_exists?(@pid) + + # When the supervisor is KILLed, the forked processes become orphans. + # Clean them up manually. + SolidQueue::Process.all.each do |process| + signal_process(process.pid, :KILL) if process_exists?(process.pid) + end + + wait_for_registered_processes 0, timeout: 3.second + end + + private + def solid_queue_mode + :fork + end +end diff --git a/test/integration/puma/plugin_test.rb b/test/integration/puma/plugin_test.rb deleted file mode 100644 index bac98a2bb..000000000 --- a/test/integration/puma/plugin_test.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class PluginTest < ActiveSupport::TestCase - self.use_transactional_tests = false - - setup do - FileUtils.mkdir_p Rails.root.join("tmp", "pids") - Dir.chdir("test/dummy") do - cmd = %W[ - bundle exec puma - -b tcp://127.0.0.1:9222 - -C config/puma.rb - -s - config.ru - ] - @pid = fork do - exec(*cmd) - end - end - wait_for_registered_processes 5, timeout: 3.second - end - - teardown do - terminate_process(@pid, signal: :INT) if process_exists?(@pid) - wait_for_registered_processes 0, timeout: 2.seconds - end - - test "perform jobs inside puma's process" do - StoreResultJob.perform_later(:puma_plugin) - - wait_for_jobs_to_finish_for(2.seconds) - assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :puma_plugin).count - end - - test "stop the queue on puma's restart" do - signal_process(@pid, :SIGUSR2) - # Ensure the restart finishes before we try to continue with the test - wait_for_registered_processes(0, timeout: 3.second) - wait_for_registered_processes(5, timeout: 3.second) - - StoreResultJob.perform_later(:puma_plugin) - wait_for_jobs_to_finish_for(2.seconds) - assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :puma_plugin).count - end - - test "stop puma when solid queue's supervisor dies" do - supervisor = find_processes_registered_as("Supervisor").first - - signal_process(supervisor.pid, :KILL) - wait_for_process_termination_with_timeout(@pid) - - assert_not process_exists?(@pid) - - # Make sure all supervised processes are also terminated - SolidQueue::Process.all.each do |process| - signal_process(process.pid, :KILL) if process_exists?(process.pid) - end - - wait_for_registered_processes 0, timeout: 3.second - end -end diff --git a/test/integration/puma/plugin_testing.rb b/test/integration/puma/plugin_testing.rb new file mode 100644 index 000000000..ec2198f8f --- /dev/null +++ b/test/integration/puma/plugin_testing.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "test_helper" + +module PluginTesting + extend ActiveSupport::Concern + extend ActiveSupport::Testing::Declarative + + included do + self.use_transactional_tests = false + + setup do + FileUtils.mkdir_p Rails.root.join("tmp", "pids") + + Dir.chdir("test/dummy") do + cmd = %W[ + bundle exec puma + -b tcp://127.0.0.1:9222 + -C config/puma_#{solid_queue_mode}.rb + -s + config.ru + ] + + @pid = fork do + exec(*cmd) + end + end + + wait_for_registered_processes(5, timeout: 3.second) + end + + teardown do + terminate_process(@pid, signal: :INT) if process_exists?(@pid) + + wait_for_registered_processes 0, timeout: 2.seconds + end + end + + test "perform jobs inside puma's process" do + StoreResultJob.perform_later(:puma_plugin) + + wait_for_jobs_to_finish_for(2.seconds) + assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :puma_plugin).count + end + + test "stop the queue on puma's restart" do + signal_process(@pid, :SIGUSR2) + # Ensure the restart finishes before we try to continue with the test + wait_for_registered_processes(0, timeout: 3.second) + wait_for_registered_processes(5, timeout: 3.second) + + StoreResultJob.perform_later(:puma_plugin) + wait_for_jobs_to_finish_for(2.seconds) + assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :puma_plugin).count + end + + private + def solid_queue_mode + raise NotImplementedError + end +end diff --git a/test/models/solid_queue/process_test.rb b/test/models/solid_queue/process_test.rb index b69a67ded..cd2430caf 100644 --- a/test/models/solid_queue/process_test.rb +++ b/test/models/solid_queue/process_test.rb @@ -34,7 +34,7 @@ class SolidQueue::ProcessTest < ActiveSupport::TestCase end test "prune processes including their supervisor with expired heartbeats and fail claimed executions" do - supervisor = SolidQueue::Process.register(kind: "Supervisor", pid: 42, name: "supervisor-42") + supervisor = SolidQueue::Process.register(kind: "Supervisor(fork)", pid: 42, name: "supervisor-42") process = SolidQueue::Process.register(kind: "Worker", pid: 43, name: "worker-43", supervisor_id: supervisor.id) 3.times { |i| StoreResultJob.set(queue: :new_queue).perform_later(i) } jobs = SolidQueue::Job.last(3) diff --git a/test/test_helper.rb b/test/test_helper.rb index 30caca0e6..60bab7a36 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,6 +38,8 @@ def destroy_records SolidQueue::Process.destroy_all SolidQueue::Semaphore.delete_all SolidQueue::RecurringTask.delete_all + SolidQueue::ScheduledExecution.delete_all + SolidQueue::ReadyExecution.delete_all JobResult.delete_all end diff --git a/test/unit/async_supervisor_test.rb b/test/unit/async_supervisor_test.rb new file mode 100644 index 000000000..65350c828 --- /dev/null +++ b/test/unit/async_supervisor_test.rb @@ -0,0 +1,115 @@ +require "test_helper" + +class AsyncSupervisorTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + test "start as non-standalone" do + supervisor, thread = run_supervisor_as_thread + wait_for_registered_processes(4) + + assert_registered_processes(kind: "Supervisor(async)") + assert_registered_processes(kind: "Worker", supervisor_id: supervisor.process_id, count: 2) + assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) + + supervisor.stop + thread.join + + assert_no_registered_processes + end + + test "start standalone" do + pid = run_supervisor_as_fork(mode: :async) + wait_for_registered_processes(4) + + assert_registered_processes(kind: "Supervisor(async)") + assert_registered_processes(kind: "Worker", supervisor_pid: pid, count: 2) + assert_registered_processes(kind: "Dispatcher", supervisor_pid: pid) + + terminate_process(pid) + assert_no_registered_processes + end + + test "start as non-standalone with provided configuration" do + supervisor, thread = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ]) + wait_for_registered_processes(2) # supervisor + dispatcher + + assert_registered_processes(kind: "Supervisor(async)") + assert_registered_processes(kind: "Worker", count: 0) + assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) + + supervisor.stop + thread.join + + assert_no_registered_processes + end + + test "failed orphaned executions as non-standalone" do + simulate_orphaned_executions 3 + + config = { + workers: [ { queues: "background", polling_interval: 10 } ], + dispatchers: [] + } + + supervisor, thread = run_supervisor_as_thread(**config) + wait_for_registered_processes(2) # supervisor + 1 worker + assert_registered_processes(kind: "Supervisor(async)") + + wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } + + supervisor.stop + thread.join + + skip_active_record_query_cache do + assert_equal 0, SolidQueue::ClaimedExecution.count + assert_equal 3, SolidQueue::FailedExecution.count + end + end + + test "failed orphaned executions as standalone" do + simulate_orphaned_executions 3 + + config = { + workers: [ { queues: "background", polling_interval: 10 } ], + dispatchers: [] + } + + pid = run_supervisor_as_fork(mode: :async, **config) + wait_for_registered_processes(2) # supervisor + 1 worker + assert_registered_processes(kind: "Supervisor(async)") + + wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } + + terminate_process(pid) + + skip_active_record_query_cache do + assert_equal 0, SolidQueue::ClaimedExecution.count + assert_equal 3, SolidQueue::FailedExecution.count + end + end + + private + def run_supervisor_as_thread(**kwargs) + configuration = SolidQueue::Configuration.new(mode: :async, standalone: false, **kwargs) + supervisor = SolidQueue::AsyncSupervisor.new(configuration) + + thread = Thread.new { supervisor.start } + + [ supervisor, thread ] + end + + def simulate_orphaned_executions(count) + count.times { |i| StoreResultJob.set(queue: :new_queue).perform_later(i) } + process = SolidQueue::Process.register(kind: "Worker", pid: 42, name: "worker-123") + + SolidQueue::ReadyExecution.claim("*", count + 1, process.id) + + assert_equal count, SolidQueue::ClaimedExecution.count + assert_equal 0, SolidQueue::ReadyExecution.count + + assert_equal [ process.id ], SolidQueue::ClaimedExecution.last(3).pluck(:process_id).uniq + + # Simulate orphaned executions by just wiping the claiming process + process.delete + end +end diff --git a/test/unit/dispatcher_test.rb b/test/unit/dispatcher_test.rb index 359bb5046..531e2d721 100644 --- a/test/unit/dispatcher_test.rb +++ b/test/unit/dispatcher_test.rb @@ -12,6 +12,7 @@ class DispatcherTest < ActiveSupport::TestCase teardown do @dispatcher.stop + wait_for_registered_processes(0, timeout: 2.seconds) end test "dispatcher is registered as process" do @@ -75,55 +76,52 @@ class DispatcherTest < ActiveSupport::TestCase assert_no_registered_processes end - test "run more than one instance of the dispatcher" do - 15.times do - AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") - end - sleep 0.5.second - assert_equal 15, SolidQueue::ScheduledExecution.count - - another_dispatcher = SolidQueue::Dispatcher.new(polling_interval: 0.1, batch_size: 10) + test "dispatch scheduled executions" do + skip_active_record_query_cache do + 15.times do + AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") + end + sleep 0.5.second + assert_equal 15, SolidQueue::ScheduledExecution.count - @dispatcher.start - another_dispatcher.start + @dispatcher.start + wait_for_registered_processes(1, timeout: 2.seconds) - wait_while_with_timeout(1.second) { SolidQueue::ScheduledExecution.any? } + wait_while_with_timeout(3.seconds) { SolidQueue::ScheduledExecution.any? } - assert_equal 0, SolidQueue::ScheduledExecution.count - assert_equal 15, SolidQueue::ReadyExecution.count - ensure - another_dispatcher&.stop + assert_equal 0, SolidQueue::ScheduledExecution.count + assert_equal 15, SolidQueue::ReadyExecution.count + end end test "sleeps `0.seconds` between polls if there are ready to dispatch jobs" do - dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) - dispatcher.expects(:interruptible_sleep).with(0.seconds).at_least(3) - dispatcher.expects(:interruptible_sleep).with(dispatcher.polling_interval).at_least_once - dispatcher.expects(:handle_thread_error).never - - 3.times { AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") } - sleep 0.5.second - assert_equal 3, SolidQueue::ScheduledExecution.count - - dispatcher.start - wait_while_with_timeout(1.second) { SolidQueue::ScheduledExecution.any? } - - assert_equal 0, SolidQueue::ScheduledExecution.count - assert_equal 3, SolidQueue::ReadyExecution.count - ensure - dispatcher.stop + skip_active_record_query_cache do + @dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) + @dispatcher.expects(:interruptible_sleep).with(0.seconds).at_least(3) + @dispatcher.expects(:interruptible_sleep).with(@dispatcher.polling_interval).at_least_once + @dispatcher.expects(:handle_thread_error).never + + 3.times { AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") } + sleep 0.5.second + assert_equal 3, SolidQueue::ScheduledExecution.count + + @dispatcher.start + wait_for_registered_processes(1, timeout: 2.seconds) + wait_while_with_timeout(3.seconds) { SolidQueue::ScheduledExecution.any? } + + assert_equal 0, SolidQueue::ScheduledExecution.count + assert_equal 3, SolidQueue::ReadyExecution.count + end end test "sleeps `polling_interval` between polls if there are no un-dispatched jobs" do - dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) - dispatcher.expects(:interruptible_sleep).with(0.seconds).never - dispatcher.expects(:interruptible_sleep).with(dispatcher.polling_interval).at_least_once - dispatcher.expects(:handle_thread_error).never + @dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) + @dispatcher.expects(:interruptible_sleep).with(0.seconds).never + @dispatcher.expects(:interruptible_sleep).with(@dispatcher.polling_interval).at_least_once + @dispatcher.expects(:handle_thread_error).never - dispatcher.start + @dispatcher.start wait_while_with_timeout(1.second) { !SolidQueue::ScheduledExecution.exists? } - ensure - dispatcher.stop end private diff --git a/test/unit/supervisor_test.rb b/test/unit/fork_supervisor_test.rb similarity index 97% rename from test/unit/supervisor_test.rb rename to test/unit/fork_supervisor_test.rb index 7a531ad2b..2759260c9 100644 --- a/test/unit/supervisor_test.rb +++ b/test/unit/fork_supervisor_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class SupervisorTest < ActiveSupport::TestCase +class ForkSupervisorTest < ActiveSupport::TestCase self.use_transactional_tests = false setup do @@ -203,7 +203,7 @@ def termsig = nil end status = DummyStatus.new(worker_process.pid, 1) - supervisor = SolidQueue::Supervisor.allocate + supervisor = SolidQueue::ForkSupervisor.allocate supervisor.send(:handle_claimed_jobs_by, terminated_fork, status) @@ -223,7 +223,7 @@ def assert_registered_dispatcher(supervisor_pid: nil) def assert_registered_supervisor(pid) skip_active_record_query_cache do - processes = find_processes_registered_as("Supervisor") + processes = find_processes_registered_as("Supervisor(fork)") assert_equal 1, processes.count assert_nil processes.first.supervisor assert_equal pid, processes.first.pid diff --git a/test/unit/process_recovery_test.rb b/test/unit/process_recovery_test.rb index 620fbd515..e3eccdcff 100644 --- a/test/unit/process_recovery_test.rb +++ b/test/unit/process_recovery_test.rb @@ -20,7 +20,7 @@ class ProcessRecoveryTest < ActiveSupport::TestCase @pid = run_supervisor_as_fork(workers: [ { queues: "*", polling_interval: 0.1, processes: 1 } ]) wait_for_registered_processes(2, timeout: 1.second) # Supervisor + 1 worker - supervisor_process = SolidQueue::Process.find_by(kind: "Supervisor", pid: @pid) + supervisor_process = SolidQueue::Process.find_by(kind: "Supervisor(fork)", pid: @pid) assert supervisor_process worker_process = SolidQueue::Process.find_by(kind: "Worker") diff --git a/test/unit/worker_test.rb b/test/unit/worker_test.rb index 8db67912b..3d692404b 100644 --- a/test/unit/worker_test.rb +++ b/test/unit/worker_test.rb @@ -35,6 +35,7 @@ class WorkerTest < ActiveSupport::TestCase worker = SolidQueue::Worker.new(queues: "background", threads: 3, polling_interval: 0.2).tap(&:start) sleep(1) + # stop calls join internally when not supervised, which re-raises the error assert_raises ExpectedTestError do worker.stop end From 6e785eb33f38d5076675f3d637deb8c65ecf500f Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 30 Dec 2025 21:08:09 +0100 Subject: [PATCH 33/85] DRY a bit releasing claimed executions by both supervisors --- lib/solid_queue/async_supervisor.rb | 52 ++++++++++------------- lib/solid_queue/fork_supervisor.rb | 18 ++------ lib/solid_queue/supervisor.rb | 2 +- lib/solid_queue/supervisor/maintenance.rb | 11 +++++ test/unit/fork_supervisor_test.rb | 16 +++---- 5 files changed, 43 insertions(+), 56 deletions(-) diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index f1a8aed28..8a7a7f6b2 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -3,43 +3,35 @@ module SolidQueue class AsyncSupervisor < Supervisor private - def check_and_replace_terminated_processes - terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } - terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } - end - def replace_thread(thread_id, instance) - SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| - payload[:thread] = instance - handle_claimed_jobs_by(terminated_instance, thread) + def check_and_replace_terminated_processes + terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } + terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } + end - start_process(configured_processes.delete(thread_id)) - end - end + def replace_thread(thread_id, instance) + SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| + payload[:thread] = instance - def perform_graceful_termination - process_instances.values.each(&:stop) + error = Processes::ThreadTerminatedError.new(terminated_instance.name) + release_claimed_jobs_by(terminated_instance, with_error: error) - Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) + start_process(configured_processes.delete(thread_id)) end + end - def perform_immediate_termination - exit! - end + def perform_graceful_termination + process_instances.values.each(&:stop) - def all_processes_terminated? - process_instances.values.none?(&:alive?) - end + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) + end - # When a supervised thread terminates unexpectedly, mark all executions - # it had claimed as failed so they can be retried by another worker. - def handle_claimed_jobs_by(terminated_instance, thread) - wrap_in_app_executor do - if registered_process = SolidQueue::Process.find_by(name: terminated_instance.name) - error = Processes::ThreadTerminatedError.new(terminated_instance.name) - registered_process.fail_all_claimed_executions_with(error) - end - end - end + def perform_immediate_termination + exit! + end + + def all_processes_terminated? + process_instances.values.none?(&:alive?) + end end end diff --git a/lib/solid_queue/fork_supervisor.rb b/lib/solid_queue/fork_supervisor.rb index 126b21710..c3c87dbec 100644 --- a/lib/solid_queue/fork_supervisor.rb +++ b/lib/solid_queue/fork_supervisor.rb @@ -39,7 +39,8 @@ def reap_terminated_forks break unless pid if (terminated_fork = process_instances.delete(pid)) && !status.exited? || status.exitstatus > 0 - handle_claimed_jobs_by(terminated_fork, status) + error = Processes::ProcessExitError.new(status) + release_claimed_jobs_by(terminated_fork, with_error: error) end configured_processes.delete(pid) @@ -52,25 +53,14 @@ def replace_fork(pid, status) SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload| if terminated_fork = process_instances.delete(pid) payload[:fork] = terminated_fork - handle_claimed_jobs_by(terminated_fork, status) + error = Processes::ProcessExitError.new(status) + release_claimed_jobs_by(terminated_fork, with_error: error) start_process(configured_processes.delete(pid)) end end end - # When a supervised fork crashes or exits we need to mark all the - # executions it had claimed as failed so that they can be retried - # by some other worker. - def handle_claimed_jobs_by(terminated_fork, status) - wrap_in_app_executor do - if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name) - error = Processes::ProcessExitError.new(status) - registered_process.fail_all_claimed_executions_with(error) - end - end - end - def all_processes_terminated? process_instances.empty? end diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index 89696eba5..ae17ec95c 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -144,4 +144,4 @@ def sync_std_streams STDOUT.sync = STDERR.sync = true end end -end \ No newline at end of file +end diff --git a/lib/solid_queue/supervisor/maintenance.rb b/lib/solid_queue/supervisor/maintenance.rb index 1b6b52048..d92569d58 100644 --- a/lib/solid_queue/supervisor/maintenance.rb +++ b/lib/solid_queue/supervisor/maintenance.rb @@ -32,5 +32,16 @@ def fail_orphaned_executions ClaimedExecution.orphaned.fail_all_with(Processes::ProcessMissingError.new) end end + + # When a supervised process crashes or exits we need to mark all the + # executions it had claimed as failed so that they can be retried + # by some other worker. + def release_claimed_jobs_by(terminated_process, with_error:) + wrap_in_app_executor do + if registered_process = SolidQueue::Process.find_by(name: terminated_process.name) + registered_process.fail_all_claimed_executions_with(with_error) + end + end + end end end diff --git a/test/unit/fork_supervisor_test.rb b/test/unit/fork_supervisor_test.rb index 2759260c9..9ec81b510 100644 --- a/test/unit/fork_supervisor_test.rb +++ b/test/unit/fork_supervisor_test.rb @@ -186,8 +186,8 @@ class ForkSupervisorTest < ActiveSupport::TestCase end # Regression test for supervisor failing to handle claimed jobs when its own - # process record has been pruned (NoMethodError in #handle_claimed_jobs_by). - test "handle_claimed_jobs_by fails claimed executions even if supervisor record is missing" do + # process record has been pruned (NoMethodError in #release_claimed_jobs_by). + test "release_claimed_jobs_by fails claimed executions even if supervisor record is missing" do worker_name = "worker-test-#{SecureRandom.hex(4)}" worker_process = SolidQueue::Process.register(kind: "Worker", pid: 999_999, name: worker_name) @@ -196,20 +196,14 @@ class ForkSupervisorTest < ActiveSupport::TestCase claimed_execution = SolidQueue::ReadyExecution.claim("*", 1, worker_process.id).first terminated_fork = Struct.new(:name).new(worker_name) - - DummyStatus = Struct.new(:pid, :exitstatus) do - def signaled? = false - def termsig = nil - end - status = DummyStatus.new(worker_process.pid, 1) - supervisor = SolidQueue::ForkSupervisor.allocate + error = RuntimeError.new - supervisor.send(:handle_claimed_jobs_by, terminated_fork, status) + supervisor.send(:release_claimed_jobs_by, terminated_fork, with_error: error) failed = SolidQueue::FailedExecution.find_by(job_id: claimed_execution.job_id) assert failed.present? - assert_equal "SolidQueue::Processes::ProcessExitError", failed.exception_class + assert_equal "RuntimeError", failed.exception_class end private From e7c219f7ae4e09e1c5407d93b70c082325c03f00 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 16:17:41 +0100 Subject: [PATCH 34/85] Document `async` mode and allow configuring via environment variable For consistency with the other `bin/jobs` options. --- README.md | 41 ++++++++++++++++++++++++++------ lib/puma/plugin/solid_queue.rb | 18 ++++++++------ lib/solid_queue/cli.rb | 4 +++- lib/solid_queue/configuration.rb | 2 +- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7528905d8..cee99f859 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite, - [Dashboard UI Setup](#dashboard-ui-setup) - [Incremental adoption](#incremental-adoption) - [High performance requirements](#high-performance-requirements) +- [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler) + - [Fork vs. async mode](#fork-vs-async-mode) - [Configuration](#configuration) - - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler) - [Queue order and priorities](#queue-order-and-priorities) - [Queues specification and performance](#queues-specification-and-performance) - [Threads, processes, and signals](#threads-processes-and-signals) @@ -179,9 +180,7 @@ end Solid Queue was designed for the highest throughput when used with MySQL 8+, MariaDB 10.6+, or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications. -## Configuration - -### Workers, dispatchers, and scheduler +## Workers, dispatchers, and scheduler We have several types of actors in Solid Queue: @@ -190,7 +189,19 @@ We have several types of actors in Solid Queue: - The _scheduler_ manages [recurring tasks](#recurring-tasks), enqueuing jobs for them when they're due. - The _supervisor_ runs workers and dispatchers according to the configuration, controls their heartbeats, and stops and starts them when needed. -Solid Queue's supervisor will fork a separate process for each supervised worker/dispatcher/scheduler. +### Fork vs. async mode + +By default, Solid Queue runs in `fork` mode. This means the supervisor will fork a separate process for each supervised worker/dispatcher/scheduler. This provides the best isolation and performance, but can have additional memory usage and might not work with some Ruby implementations. As an alternative, you can run all workers, dispatchers and schedulers in the same process as the supervisor, in different threads, with an `async` mode. You can choose this mode by running `bin/jobs` as: + +``` +bin/jobs --mode async +``` + +Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `async`. If you use the `async` mode, the `processes` option in the configuration described below will be ignored. + +**The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to** + +## Configuration By default, Solid Queue will try to find your configuration under `config/queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG` or by using the `-c/--config_file` option with `bin/jobs`, like this: @@ -254,7 +265,7 @@ Here's an overview of the different options: - `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting. It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker thread uses one connection, and two additional connections are reserved for polling and heartbeat. -- `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. +- `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode). - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else. @@ -334,7 +345,7 @@ queues: back* Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling. -The supervisor is in charge of managing these processes, and it responds to the following signals: +The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode: - `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit. - `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately. @@ -603,6 +614,22 @@ that you set in production only. This is what Rails 8's default Puma config look **Note**: phased restarts are not supported currently because the plugin requires [app preloading](https://github.com/puma/puma?tab=readme-ov-file#cluster-mode) to work. +### Running as a fork or asynchronously + +By default, the Puma plugin will fork additional processes for each worker and dispatcher so that they run in different processes. This provides the best isolation and performance, but can have additional memory usage. + +Alternatively, workers and dispatchers can be run within the same Puma process(s). To do so just configure the plugin as: + +```ruby +plugin :solid_queue +solid_queue_mode :async +``` + +Note that in this case, the `processes` configuration option will be ignored. See also [Fork vs. async mode](#fork-vs-async-mode). + +**The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to** + + ## Jobs and transactional integrity :warning: Having your jobs in the same ACID-compliant database as your application data enables a powerful yet sharp tool: taking advantage of transactional integrity to ensure some action in your app is not committed unless your job is also committed and vice versa, and ensuring that your job won't be enqueued until the transaction within which you're enqueuing it is committed. This can be very powerful and useful, but it can also backfire if you base some of your logic on this behaviour, and in the future, you move to another active job backend, or if you simply move Solid Queue to its own database, and suddenly the behaviour changes under you. Because this can be quite tricky and many people shouldn't need to worry about it, by default Solid Queue is configured in a different database as the main app. diff --git a/lib/puma/plugin/solid_queue.rb b/lib/puma/plugin/solid_queue.rb index 70acfb9a9..388062770 100644 --- a/lib/puma/plugin/solid_queue.rb +++ b/lib/puma/plugin/solid_queue.rb @@ -42,7 +42,7 @@ def start_forked(launcher) launcher.events.after_booted do @solid_queue_pid = fork do Thread.new { monitor_puma } - SolidQueue::Supervisor.start(mode: :fork) + start_solid_queue(mode: :fork) end end @@ -54,29 +54,33 @@ def start_forked(launcher) def start_async(launcher) if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7") launcher.events.on_booted do - @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + start_solid_queue(mode: :async, standalone: false) end - launcher.events.on_stopped { @solid_queue_supervisor&.stop } + launcher.events.on_stopped { solid_queue_supervisor&.stop } launcher.events.on_restart do solid_queue_supervisor&.stop - @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + start_solid_queue(mode: :async, standalone: false) end else launcher.events.after_booted do - @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + start_solid_queue(mode: :async, standalone: false) end - launcher.events.after_stopped { @solid_queue_supervisor&.stop } + launcher.events.after_stopped { solid_queue_supervisor&.stop } launcher.events.before_restart do solid_queue_supervisor&.stop - @solid_queue_supervisor = SolidQueue::Supervisor.start(mode: :async, standalone: false) + start_solid_queue(mode: :async, standalone: false) end end end + def start_solid_queue(**options) + @solid_queue_supervisor = SolidQueue::Supervisor.start(**options) + end + def stop_solid_queue_fork Process.waitpid(solid_queue_pid, Process::WNOHANG) log "Stopping Solid Queue..." diff --git a/lib/solid_queue/cli.rb b/lib/solid_queue/cli.rb index 930ddaab4..a2b5ba5e9 100644 --- a/lib/solid_queue/cli.rb +++ b/lib/solid_queue/cli.rb @@ -8,7 +8,9 @@ class Cli < Thor desc: "Path to config file (default: #{Configuration::DEFAULT_CONFIG_FILE_PATH}).", banner: "SOLID_QUEUE_CONFIG" - class_option :mode, type: :string, default: "fork", enum: %w[ fork async ], desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async)" + class_option :mode, type: :string, default: "fork", enum: %w[ fork async ], + desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async) (default: fork).", + banner: "SOLID_QUEUE_SUPERVISOR_MODE" class_option :recurring_schedule_file, type: :string, desc: "Path to recurring schedule definition (default: #{Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH}).", diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index c62d61419..94169ca7b 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -92,7 +92,7 @@ def ensure_correctly_sized_thread_pool def default_options { - mode: :fork, + mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork, standalone: true, config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH), recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH), From cca44d2c195602fd4cf4b9d411b825caf6e506ec Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 18:41:12 +0100 Subject: [PATCH 35/85] Run non-standalone supervisor in a separate thread Otherwise `supervise` will block and we won't be able to stop it externally (from the Puma plugin). --- lib/solid_queue/app_executor.rb | 10 ++++ lib/solid_queue/async_supervisor.rb | 53 ++++++++++++--------- lib/solid_queue/dispatcher.rb | 1 + lib/solid_queue/processes/runnable.rb | 11 ----- test/integration/instrumentation_test.rb | 1 + test/unit/async_supervisor_test.rb | 18 ++----- test/unit/dispatcher_test.rb | 60 +++++++++++++----------- 7 files changed, 82 insertions(+), 72 deletions(-) diff --git a/lib/solid_queue/app_executor.rb b/lib/solid_queue/app_executor.rb index 0580213f7..f315f61a2 100644 --- a/lib/solid_queue/app_executor.rb +++ b/lib/solid_queue/app_executor.rb @@ -17,5 +17,15 @@ def handle_thread_error(error) SolidQueue.on_thread_error.call(error) end end + + def create_thread(&block) + Thread.new do + Thread.current.name = name + block.call + rescue Exception => exception + handle_thread_error(exception) + raise + end + end end end diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index 8a7a7f6b2..c24ec5d1e 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -2,36 +2,47 @@ module SolidQueue class AsyncSupervisor < Supervisor + def stop + super + @thread&.join + end + private + def supervise + if standalone? then super + else + @thread = create_thread { super } + end + end - def check_and_replace_terminated_processes - terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } - terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } - end + def check_and_replace_terminated_processes + terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } + terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } + end - def replace_thread(thread_id, instance) - SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| - payload[:thread] = instance + def replace_thread(thread_id, instance) + SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| + payload[:thread] = instance - error = Processes::ThreadTerminatedError.new(terminated_instance.name) - release_claimed_jobs_by(terminated_instance, with_error: error) + error = Processes::ThreadTerminatedError.new(terminated_instance.name) + release_claimed_jobs_by(terminated_instance, with_error: error) - start_process(configured_processes.delete(thread_id)) + start_process(configured_processes.delete(thread_id)) + end end - end - def perform_graceful_termination - process_instances.values.each(&:stop) + def perform_graceful_termination + process_instances.values.each(&:stop) - Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) - end + Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) + end - def perform_immediate_termination - exit! - end + def perform_immediate_termination + exit! + end - def all_processes_terminated? - process_instances.values.none?(&:alive?) - end + def all_processes_terminated? + process_instances.values.none?(&:alive?) + end end end diff --git a/lib/solid_queue/dispatcher.rb b/lib/solid_queue/dispatcher.rb index 1583e1dd5..461ce8036 100644 --- a/lib/solid_queue/dispatcher.rb +++ b/lib/solid_queue/dispatcher.rb @@ -3,6 +3,7 @@ module SolidQueue class Dispatcher < Processes::Poller include LifecycleHooks + attr_reader :batch_size after_boot :run_start_hooks diff --git a/lib/solid_queue/processes/runnable.rb b/lib/solid_queue/processes/runnable.rb index a3aa4fab1..c6e002e4f 100644 --- a/lib/solid_queue/processes/runnable.rb +++ b/lib/solid_queue/processes/runnable.rb @@ -92,16 +92,5 @@ def running_async? def running_as_fork? mode.fork? end - - - def create_thread(&block) - Thread.new do - Thread.current.name = name - block.call - rescue Exception => exception - handle_thread_error(exception) - raise - end - end end end diff --git a/test/integration/instrumentation_test.rb b/test/integration/instrumentation_test.rb index 7e2a51ca1..fcdf448d8 100644 --- a/test/integration/instrumentation_test.rb +++ b/test/integration/instrumentation_test.rb @@ -171,6 +171,7 @@ class InstrumentationTest < ActiveSupport::TestCase wait_for_registered_processes(1, timeout: 1.second) worker.stop + wait_for_registered_processes(0, timeout: 1.second) end end diff --git a/test/unit/async_supervisor_test.rb b/test/unit/async_supervisor_test.rb index 65350c828..46948417a 100644 --- a/test/unit/async_supervisor_test.rb +++ b/test/unit/async_supervisor_test.rb @@ -4,7 +4,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase self.use_transactional_tests = false test "start as non-standalone" do - supervisor, thread = run_supervisor_as_thread + supervisor = run_supervisor_as_thread wait_for_registered_processes(4) assert_registered_processes(kind: "Supervisor(async)") @@ -12,7 +12,6 @@ class AsyncSupervisorTest < ActiveSupport::TestCase assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) supervisor.stop - thread.join assert_no_registered_processes end @@ -30,7 +29,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase end test "start as non-standalone with provided configuration" do - supervisor, thread = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ]) + supervisor = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ]) wait_for_registered_processes(2) # supervisor + dispatcher assert_registered_processes(kind: "Supervisor(async)") @@ -38,7 +37,6 @@ class AsyncSupervisorTest < ActiveSupport::TestCase assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) supervisor.stop - thread.join assert_no_registered_processes end @@ -51,14 +49,13 @@ class AsyncSupervisorTest < ActiveSupport::TestCase dispatchers: [] } - supervisor, thread = run_supervisor_as_thread(**config) + supervisor = run_supervisor_as_thread(**config) wait_for_registered_processes(2) # supervisor + 1 worker assert_registered_processes(kind: "Supervisor(async)") wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } supervisor.stop - thread.join skip_active_record_query_cache do assert_equal 0, SolidQueue::ClaimedExecution.count @@ -89,13 +86,8 @@ class AsyncSupervisorTest < ActiveSupport::TestCase end private - def run_supervisor_as_thread(**kwargs) - configuration = SolidQueue::Configuration.new(mode: :async, standalone: false, **kwargs) - supervisor = SolidQueue::AsyncSupervisor.new(configuration) - - thread = Thread.new { supervisor.start } - - [ supervisor, thread ] + def run_supervisor_as_thread(**options) + SolidQueue::Supervisor.start(mode: :async, standalone: false, **options) end def simulate_orphaned_executions(count) diff --git a/test/unit/dispatcher_test.rb b/test/unit/dispatcher_test.rb index 531e2d721..7df0591f5 100644 --- a/test/unit/dispatcher_test.rb +++ b/test/unit/dispatcher_test.rb @@ -12,7 +12,6 @@ class DispatcherTest < ActiveSupport::TestCase teardown do @dispatcher.stop - wait_for_registered_processes(0, timeout: 2.seconds) end test "dispatcher is registered as process" do @@ -76,52 +75,59 @@ class DispatcherTest < ActiveSupport::TestCase assert_no_registered_processes end - test "dispatch scheduled executions" do - skip_active_record_query_cache do - 15.times do - AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") - end - sleep 0.5.second - assert_equal 15, SolidQueue::ScheduledExecution.count + test "run more than one instance of the dispatcher" do + 15.times do + AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") + end + sleep 0.5.second + assert_equal 15, SolidQueue::ScheduledExecution.count - @dispatcher.start - wait_for_registered_processes(1, timeout: 2.seconds) + another_dispatcher = SolidQueue::Dispatcher.new(polling_interval: 0.1, batch_size: 10) - wait_while_with_timeout(3.seconds) { SolidQueue::ScheduledExecution.any? } + @dispatcher.start + another_dispatcher.start + + wait_while_with_timeout(1.second) { SolidQueue::ScheduledExecution.any? } + skip_active_record_query_cache do assert_equal 0, SolidQueue::ScheduledExecution.count assert_equal 15, SolidQueue::ReadyExecution.count end + ensure + another_dispatcher&.stop end test "sleeps `0.seconds` between polls if there are ready to dispatch jobs" do - skip_active_record_query_cache do - @dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) - @dispatcher.expects(:interruptible_sleep).with(0.seconds).at_least(3) - @dispatcher.expects(:interruptible_sleep).with(@dispatcher.polling_interval).at_least_once - @dispatcher.expects(:handle_thread_error).never + dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) + dispatcher.expects(:interruptible_sleep).with(0.seconds).at_least(3) + dispatcher.expects(:interruptible_sleep).with(dispatcher.polling_interval).at_least_once + dispatcher.expects(:handle_thread_error).never - 3.times { AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") } - sleep 0.5.second - assert_equal 3, SolidQueue::ScheduledExecution.count + 3.times { AddToBufferJob.set(wait: 0.5.second).perform_later("I'm scheduled") } + sleep 0.5.second + assert_equal 3, SolidQueue::ScheduledExecution.count - @dispatcher.start - wait_for_registered_processes(1, timeout: 2.seconds) - wait_while_with_timeout(3.seconds) { SolidQueue::ScheduledExecution.any? } + dispatcher.start + wait_while_with_timeout(1.second) { SolidQueue::ScheduledExecution.any? } + skip_active_record_query_cache do assert_equal 0, SolidQueue::ScheduledExecution.count assert_equal 3, SolidQueue::ReadyExecution.count end + ensure + dispatcher.stop end test "sleeps `polling_interval` between polls if there are no un-dispatched jobs" do - @dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) - @dispatcher.expects(:interruptible_sleep).with(0.seconds).never - @dispatcher.expects(:interruptible_sleep).with(@dispatcher.polling_interval).at_least_once - @dispatcher.expects(:handle_thread_error).never + dispatcher = SolidQueue::Dispatcher.new(polling_interval: 10, batch_size: 1) + dispatcher.expects(:interruptible_sleep).with(0.seconds).never + dispatcher.expects(:interruptible_sleep).with(dispatcher.polling_interval).at_least_once + dispatcher.expects(:handle_thread_error).never - @dispatcher.start + dispatcher.start wait_while_with_timeout(1.second) { !SolidQueue::ScheduledExecution.exists? } + ensure + dispatcher.stop end private From f7feb485de3021c72310ea8a4ed16ffaa132484a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 5 Jan 2026 20:42:18 +0100 Subject: [PATCH 36/85] Ensure that non-standalone supervisor terminates gracefully Standalone supervisors run this from the signal handler, so for non-standalone we need to make sure this is run after shutting down. --- lib/solid_queue/async_supervisor.rb | 2 ++ test/integration/async_processes_lifecycle_test.rb | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index c24ec5d1e..0484c9ec2 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -2,6 +2,8 @@ module SolidQueue class AsyncSupervisor < Supervisor + after_shutdown :terminate_gracefully, unless: :standalone? + def stop super @thread&.join diff --git a/test/integration/async_processes_lifecycle_test.rb b/test/integration/async_processes_lifecycle_test.rb index 1d22a2c9a..bc9b8f59c 100644 --- a/test/integration/async_processes_lifecycle_test.rb +++ b/test/integration/async_processes_lifecycle_test.rb @@ -32,11 +32,11 @@ class AsyncProcessesLifecycleTest < ActiveSupport::TestCase test "kill supervisor while there are jobs in-flight" do no_pause = enqueue_store_result_job("no pause") - pause = enqueue_store_result_job("pause", pause: 1.second) + pause = enqueue_store_result_job("pause", pause: 3.second) signal_process(@pid, :KILL, wait: 0.2.seconds) wait_for_jobs_to_finish_for(2.seconds) - wait_for_registered_processes(1, timeout: 3.second) + wait_for_registered_processes(1, timeout: 2.second) assert_not process_exists?(@pid) From 6c54dc033eea32779093e169ddaab14b5e3e0097 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 9 Jan 2026 08:03:19 +0100 Subject: [PATCH 37/85] Bump solid_queue to 1.3.0 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 28252d35e..a028d1c88 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.2.4) + solid_queue (1.3.0) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index 83e67ca87..04cb75a58 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.2.4" + VERSION = "1.3.0" end From 73b4dacff34c8b7ff2f5012c17751e3f8eb08e30 Mon Sep 17 00:00:00 2001 From: Ricky Chilcott Date: Sat, 10 Jan 2026 15:17:42 -0500 Subject: [PATCH 38/85] Fix NoMethodError when status.exitstatus is nil When a worker process is terminated by certain signals (e.g., SIGKILL), status.exitstatus returns nil, causing a NoMethodError due to operator precedence in the conditional. This fix: 1. Adds parentheses to ensure proper evaluation order 2. Uses .to_i to safely convert nil to 0, preventing the NoMethodError Fixes the error: NoMethodError: undefined method '>' for nil --- lib/solid_queue/fork_supervisor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solid_queue/fork_supervisor.rb b/lib/solid_queue/fork_supervisor.rb index c3c87dbec..b8bb7f3ff 100644 --- a/lib/solid_queue/fork_supervisor.rb +++ b/lib/solid_queue/fork_supervisor.rb @@ -38,7 +38,7 @@ def reap_terminated_forks pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG) break unless pid - if (terminated_fork = process_instances.delete(pid)) && !status.exited? || status.exitstatus > 0 + if (terminated_fork = process_instances.delete(pid)) && (!status.exited? || status.exitstatus.to_i > 0) error = Processes::ProcessExitError.new(status) release_claimed_jobs_by(terminated_fork, with_error: error) end From 695683fd6c79ee5fdb4f6ba9fd1a24b9a8e9e02d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 15:30:16 +0100 Subject: [PATCH 39/85] Fix typo in error message --- app/models/solid_queue/recurring_task.rb | 2 +- test/models/solid_queue/recurring_task_test.rb | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index e6d4db573..2a776c8af 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -110,7 +110,7 @@ def supported_schedule def ensure_command_or_class_present unless command.present? || class_name.present? - errors.add :base, :command_and_class_blank, message: "either command or class_name must be present" + errors.add :base, :command_and_class_blank, message: "either command or class must be present" end end diff --git a/test/models/solid_queue/recurring_task_test.rb b/test/models/solid_queue/recurring_task_test.rb index ec31dd69d..29e946c57 100644 --- a/test/models/solid_queue/recurring_task_test.rb +++ b/test/models/solid_queue/recurring_task_test.rb @@ -154,7 +154,9 @@ def perform assert_not recurring_task_with(class_name: "UnknownJob").valid? # Empty class name and command - assert_not recurring_task_with(key: "task-id", schedule: "every minute").valid? + task = SolidQueue::RecurringTask.from_configuration("task-id", schedule: "every minute") + assert_not task.valid? + assert_includes task.errors[:base], "either command or class must be present" end test "task with custom queue and priority" do From 05b8c0b5d091f3142c5214bd6b3c4ce72b594bd0 Mon Sep 17 00:00:00 2001 From: Bonnie Simon <49012815+bonniesimon@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:13:24 +0530 Subject: [PATCH 40/85] Update README with wildcard queue explanation Clarify behavior of wildcard in queue configuration --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index cee99f859..609d9b4de 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,19 @@ instead of this: queues: back* ``` +Also, if a wildcard (*) is included alongside explicit queue names, Solid Queue treats the configuration as queues: *. + +This means that when * appears alongside explicit queue names, the worker uses the unfiltered polling query, ignores the specified queue order, and processes jobs from all queues. + +For example, this: +```yml +queues: [default, backend, incineration, *] +``` + +behaves the same as: +```yml +queues: * +``` ### Threads, processes, and signals From 567d2172e805fa2a28c11b8fc70eadbac757c87c Mon Sep 17 00:00:00 2001 From: Bonnie Simon <49012815+bonniesimon@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:34:11 +0530 Subject: [PATCH 41/85] Update README with wildcard queue behavior details Clarify behavior of wildcards in queue configuration --- README.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 609d9b4de..4afbd01c8 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ Here's an overview of the different options: ``` This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored. + + Also, if a wildcard (*) is included alongside explicit queue names, for example: `queues: [default, backend, *]`, then it would behave like `queues: *` Finally, you can combine prefixes with exact names, like `[ staging*, background ]`, and the behaviour with respect to order will be the same as with only exact names. @@ -340,20 +342,6 @@ instead of this: queues: back* ``` -Also, if a wildcard (*) is included alongside explicit queue names, Solid Queue treats the configuration as queues: *. - -This means that when * appears alongside explicit queue names, the worker uses the unfiltered polling query, ignores the specified queue order, and processes jobs from all queues. - -For example, this: -```yml -queues: [default, backend, incineration, *] -``` - -behaves the same as: -```yml -queues: * -``` - ### Threads, processes, and signals Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling. From 6a6f1c4e81c79a124edc3c0b8ea046a71e42145c Mon Sep 17 00:00:00 2001 From: Bonnie Simon <49012815+bonniesimon@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:36:59 +0530 Subject: [PATCH 42/85] Remove extra formating Add a blank line for better readability in README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4afbd01c8..b49f9c60d 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,7 @@ instead of this: queues: back* ``` + ### Threads, processes, and signals Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling. From 6a9928f661677de12b5a95e7cf2e39176bc2f3d4 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 14 Jan 2026 22:28:35 +0100 Subject: [PATCH 43/85] Improve flaky tests across the board --- .../async_processes_lifecycle_test.rb | 38 ++++++++++++------- .../forked_processes_lifecycle_test.rb | 9 +++-- test/integration/instrumentation_test.rb | 4 +- test/integration/jobs_lifecycle_test.rb | 3 +- test/integration/puma/plugin_testing.rb | 11 +++--- test/integration/recurring_tasks_test.rb | 6 ++- test/test_helper.rb | 2 + test/test_helpers/processes_test_helper.rb | 2 +- test/unit/async_supervisor_test.rb | 10 ++--- test/unit/process_recovery_test.rb | 3 +- test/unit/scheduler_test.rb | 14 ++++--- 11 files changed, 61 insertions(+), 41 deletions(-) diff --git a/test/integration/async_processes_lifecycle_test.rb b/test/integration/async_processes_lifecycle_test.rb index bc9b8f59c..fd284210e 100644 --- a/test/integration/async_processes_lifecycle_test.rb +++ b/test/integration/async_processes_lifecycle_test.rb @@ -34,8 +34,10 @@ class AsyncProcessesLifecycleTest < ActiveSupport::TestCase no_pause = enqueue_store_result_job("no pause") pause = enqueue_store_result_job("pause", pause: 3.second) - signal_process(@pid, :KILL, wait: 0.2.seconds) - wait_for_jobs_to_finish_for(2.seconds) + # Wait for the "no pause" job to complete before sending KILL + wait_for_jobs_to_finish_for(2.seconds, except: pause) + + signal_process(@pid, :KILL, wait: 0.1.seconds) wait_for_registered_processes(1, timeout: 2.second) assert_not process_exists?(@pid) @@ -123,27 +125,35 @@ class AsyncProcessesLifecycleTest < ActiveSupport::TestCase no_pause = enqueue_store_result_job("no pause") pause = enqueue_store_result_job("pause", pause: SolidQueue.shutdown_timeout + 10.second) - wait_while_with_timeout(1.second) { SolidQueue::ReadyExecution.count > 1 } + # Wait for the "no pause" job to complete and the pause job to be claimed. + # This ensures the pause job is actively being processed. + wait_for_jobs_to_finish_for(3.seconds, except: pause) + wait_for(timeout: 2.seconds) { SolidQueue::ClaimedExecution.exists?(job_id: SolidQueue::Job.find_by(active_job_id: pause.job_id)&.id) } - signal_process(@pid, :TERM, wait: 0.5.second) + signal_process(@pid, :TERM, wait: 0.2.second) wait_for_jobs_to_finish_for(2.seconds, except: pause) - # exit! exits with status 1 by default - wait_for_process_termination_with_timeout(@pid, timeout: SolidQueue.shutdown_timeout + 5.seconds, exitstatus: 1) + # Wait for process to terminate. In async mode, shutdown_timeout is used by both + # the supervisor and workers, creating a race: exit status may be 0 (graceful) or + # 1 (exit!) depending on which timeout check happens first. + wait_for_process_termination_with_timeout(@pid, timeout: SolidQueue.shutdown_timeout + 5.seconds, exitstatus: nil) assert_not process_exists?(@pid) assert_completed_job_results("no pause") assert_job_status(no_pause, :finished) - # When timeout is exceeded, exit! is called without cleanup. - # The in-flight job stays claimed and processes stay registered. - # A future supervisor will need to prune and fail these orphaned executions. + # The pause job should have started but not completed assert_started_job_result("pause") - assert_job_status(pause, :claimed) - - assert_registered_supervisor - assert find_processes_registered_as("Worker").any? { |w| w.metadata["queues"].include?("background") } - assert_claimed_jobs + assert_not_equal "completed", skip_active_record_query_cache { JobResult.find_by(value: "pause")&.status } + + # After shutdown, the pause job may be either: + # - claimed (exit! called, no cleanup) OR + # - ready (graceful exit, job released back to queue) + # Both are valid outcomes depending on the timing race between supervisor and worker timeouts. + skip_active_record_query_cache do + job = SolidQueue::Job.find_by(active_job_id: pause.job_id) + assert job.claimed? || job.ready?, "Expected pause job to be claimed or ready, but was neither" + end end test "process some jobs that raise errors" do diff --git a/test/integration/forked_processes_lifecycle_test.rb b/test/integration/forked_processes_lifecycle_test.rb index 561166c50..40495c20a 100644 --- a/test/integration/forked_processes_lifecycle_test.rb +++ b/test/integration/forked_processes_lifecycle_test.rb @@ -64,7 +64,8 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase test "quit supervisor while there are jobs in-flight" do no_pause = enqueue_store_result_job("no pause") - pause = enqueue_store_result_job("pause", pause: 1.second) + # long enough pause to make sure it doesn't finish + pause = enqueue_store_result_job("pause", pause: 60.second) wait_while_with_timeout(1.second) { SolidQueue::ReadyExecution.count > 0 } @@ -87,7 +88,7 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase test "term supervisor while there are jobs in-flight" do no_pause = enqueue_store_result_job("no pause") - pause = enqueue_store_result_job("pause", pause: 0.2.seconds) + pause = enqueue_store_result_job("pause", pause: 1.second) signal_process(@pid, :TERM, wait: 0.3.second) wait_for_jobs_to_finish_for(3.seconds) @@ -104,10 +105,10 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase test "int supervisor while there are jobs in-flight" do no_pause = enqueue_store_result_job("no pause") - pause = enqueue_store_result_job("pause", pause: 0.2.seconds) + pause = enqueue_store_result_job("pause", pause: 1.second) signal_process(@pid, :INT, wait: 0.3.second) - wait_for_jobs_to_finish_for(2.second) + wait_for_jobs_to_finish_for(3.second) assert_completed_job_results("no pause") assert_completed_job_results("pause") diff --git a/test/integration/instrumentation_test.rb b/test/integration/instrumentation_test.rb index fcdf448d8..1822cf159 100644 --- a/test/integration/instrumentation_test.rb +++ b/test/integration/instrumentation_test.rb @@ -353,7 +353,7 @@ class InstrumentationTest < ActiveSupport::TestCase events = subscribed("enqueue_recurring_task.solid_queue") do scheduler.start - sleep(1.01) + sleep(1.5) scheduler.stop end @@ -375,7 +375,7 @@ class InstrumentationTest < ActiveSupport::TestCase events = subscribed("enqueue_recurring_task.solid_queue") do scheduler.start - sleep(1.01) + sleep(1.5) scheduler.stop end diff --git a/test/integration/jobs_lifecycle_test.rb b/test/integration/jobs_lifecycle_test.rb index decab5b09..8444c3759 100644 --- a/test/integration/jobs_lifecycle_test.rb +++ b/test/integration/jobs_lifecycle_test.rb @@ -3,6 +3,8 @@ require "test_helper" class JobsLifecycleTest < ActiveSupport::TestCase + self.use_transactional_tests = false + setup do @_on_thread_error = SolidQueue.on_thread_error SolidQueue.on_thread_error = silent_on_thread_error_for([ ExpectedTestError, RaisingJob::DefaultError ], @_on_thread_error) @@ -34,7 +36,6 @@ class JobsLifecycleTest < ActiveSupport::TestCase test "enqueue and run jobs that fail without retries" do RaisingJob.perform_later(ExpectedTestError, "A") RaisingJob.perform_later(ExpectedTestError, "B") - jobs = SolidQueue::Job.last(2) @dispatcher.start @worker.start diff --git a/test/integration/puma/plugin_testing.rb b/test/integration/puma/plugin_testing.rb index ec2198f8f..14165c9b8 100644 --- a/test/integration/puma/plugin_testing.rb +++ b/test/integration/puma/plugin_testing.rb @@ -26,28 +26,27 @@ module PluginTesting end end - wait_for_registered_processes(5, timeout: 3.second) + wait_for_registered_processes(5, timeout: 5.second) end teardown do terminate_process(@pid, signal: :INT) if process_exists?(@pid) - - wait_for_registered_processes 0, timeout: 2.seconds + wait_for_registered_processes(0, timeout: 5.seconds) end end test "perform jobs inside puma's process" do StoreResultJob.perform_later(:puma_plugin) - wait_for_jobs_to_finish_for(2.seconds) + wait_for_jobs_to_finish_for(5.seconds) assert_equal 1, JobResult.where(queue_name: :background, status: "completed", value: :puma_plugin).count end test "stop the queue on puma's restart" do signal_process(@pid, :SIGUSR2) # Ensure the restart finishes before we try to continue with the test - wait_for_registered_processes(0, timeout: 3.second) - wait_for_registered_processes(5, timeout: 3.second) + wait_for_registered_processes(0, timeout: 5.second) + wait_for_registered_processes(5, timeout: 5.second) StoreResultJob.perform_later(:puma_plugin) wait_for_jobs_to_finish_for(2.seconds) diff --git a/test/integration/recurring_tasks_test.rb b/test/integration/recurring_tasks_test.rb index b50e61c3f..f2fc7145f 100644 --- a/test/integration/recurring_tasks_test.rb +++ b/test/integration/recurring_tasks_test.rb @@ -47,13 +47,15 @@ class RecurringTasksTest < ActiveSupport::TestCase another_task = { example_task: { class: "AddToBufferJob", schedule: "every hour", args: [ 42 ] } } scheduler1 = SolidQueue::Scheduler.new(recurring_tasks: another_task).tap(&:start) - wait_for_registered_processes(6, timeout: 1.second) + wait_for_registered_processes(6, timeout: 2.seconds) + wait_for { SolidQueue::RecurringTask.find_by(key: "example_task").present? } assert_recurring_tasks another_task updated_task = { example_task: { class: "AddToBufferJob", schedule: "every minute" } } scheduler2 = SolidQueue::Scheduler.new(recurring_tasks: updated_task).tap(&:start) - wait_for_registered_processes(7, timeout: 1.second) + wait_for_registered_processes(7, timeout: 2.seconds) + wait_for { SolidQueue::RecurringTask.find_by(key: "example_task")&.schedule == "every minute" } assert_recurring_tasks updated_task diff --git a/test/test_helper.rb b/test/test_helper.rb index 60bab7a36..db5bd5c34 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -40,6 +40,8 @@ def destroy_records SolidQueue::RecurringTask.delete_all SolidQueue::ScheduledExecution.delete_all SolidQueue::ReadyExecution.delete_all + SolidQueue::ClaimedExecution.delete_all + SolidQueue::FailedExecution.delete_all JobResult.delete_all end diff --git a/test/test_helpers/processes_test_helper.rb b/test/test_helpers/processes_test_helper.rb index 927ddfded..01cec796c 100644 --- a/test/test_helpers/processes_test_helper.rb +++ b/test/test_helpers/processes_test_helper.rb @@ -70,7 +70,7 @@ def wait_for_process_termination_with_timeout(pid, timeout: 10, exitstatus: 0, s if process_exists?(pid) begin status = Process.waitpid2(pid).last - assert_equal exitstatus, status.exitstatus, "Expected pid #{pid} to exit with status #{exitstatus}" if status.exitstatus + assert_equal exitstatus, status.exitstatus, "Expected pid #{pid} to exit with status #{exitstatus}" if status.exitstatus && !exitstatus.nil? assert_equal signaled, Signal.list.key(status.termsig).to_sym, "Expected pid #{pid} to be terminated with signal #{signaled}" if status.termsig rescue Errno::ECHILD # Child pid already reaped diff --git a/test/unit/async_supervisor_test.rb b/test/unit/async_supervisor_test.rb index 46948417a..dd5946266 100644 --- a/test/unit/async_supervisor_test.rb +++ b/test/unit/async_supervisor_test.rb @@ -5,7 +5,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase test "start as non-standalone" do supervisor = run_supervisor_as_thread - wait_for_registered_processes(4) + wait_for_registered_processes(4, timeout: 10.seconds) assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", supervisor_id: supervisor.process_id, count: 2) @@ -18,7 +18,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase test "start standalone" do pid = run_supervisor_as_fork(mode: :async) - wait_for_registered_processes(4) + wait_for_registered_processes(4, timeout: 10.seconds) assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", supervisor_pid: pid, count: 2) @@ -30,7 +30,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase test "start as non-standalone with provided configuration" do supervisor = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ]) - wait_for_registered_processes(2) # supervisor + dispatcher + wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + dispatcher assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", count: 0) @@ -50,7 +50,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase } supervisor = run_supervisor_as_thread(**config) - wait_for_registered_processes(2) # supervisor + 1 worker + wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + 1 worker assert_registered_processes(kind: "Supervisor(async)") wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } @@ -72,7 +72,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase } pid = run_supervisor_as_fork(mode: :async, **config) - wait_for_registered_processes(2) # supervisor + 1 worker + wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + 1 worker assert_registered_processes(kind: "Supervisor(async)") wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } diff --git a/test/unit/process_recovery_test.rb b/test/unit/process_recovery_test.rb index e3eccdcff..296d6b958 100644 --- a/test/unit/process_recovery_test.rb +++ b/test/unit/process_recovery_test.rb @@ -23,7 +23,8 @@ class ProcessRecoveryTest < ActiveSupport::TestCase supervisor_process = SolidQueue::Process.find_by(kind: "Supervisor(fork)", pid: @pid) assert supervisor_process - worker_process = SolidQueue::Process.find_by(kind: "Worker") + # Find the worker supervised by this specific supervisor to avoid interference from other tests + worker_process = SolidQueue::Process.find_by(kind: "Worker", supervisor_id: supervisor_process.id) assert worker_process # Enqueue a job and wait for it to be claimed diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 6608b31e3..3e838c504 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -24,13 +24,17 @@ class SchedulerTest < ActiveSupport::TestCase end schedulers.each(&:start) - wait_while_with_timeout(2.5.seconds) { SolidQueue::RecurringExecution.count != 2 } + wait_while_with_timeout(3.seconds) { SolidQueue::RecurringExecution.count < 2 } schedulers.each(&:stop) - assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count - run_at_times = SolidQueue::RecurringExecution.all.map(&:run_at).sort - 0.upto(run_at_times.length - 2) do |i| - assert_equal 1, run_at_times[i + 1] - run_at_times[i] + skip_active_record_query_cache do + assert SolidQueue::RecurringExecution.count >= 2, "Expected at least 2 recurring executions, got #{SolidQueue::RecurringExecution.count}" + assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count + run_at_times = SolidQueue::RecurringExecution.all.map(&:run_at).sort + 0.upto(run_at_times.length - 2) do |i| + time_diff = run_at_times[i + 1] - run_at_times[i] + assert_in_delta 1, time_diff, 0.001, "Expected run_at times to be 1 second apart, got #{time_diff}. All run_at times: #{run_at_times.inspect}" + end end end end From 9fec81b1bacd2b6094980498c924cc8e31de6ffb Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 15 Jan 2026 15:34:09 +0100 Subject: [PATCH 44/85] Fix undefined variable when replacing a failed thread And fix also the async supervisor tests, that were failing due to not taking the scheduler into account. --- lib/solid_queue/async_supervisor.rb | 4 ++-- test/unit/async_supervisor_test.rb | 25 ++++++++++++------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index 0484c9ec2..02e82eec8 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -26,8 +26,8 @@ def replace_thread(thread_id, instance) SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| payload[:thread] = instance - error = Processes::ThreadTerminatedError.new(terminated_instance.name) - release_claimed_jobs_by(terminated_instance, with_error: error) + error = Processes::ThreadTerminatedError.new(instance.name) + release_claimed_jobs_by(instance, with_error: error) start_process(configured_processes.delete(thread_id)) end diff --git a/test/unit/async_supervisor_test.rb b/test/unit/async_supervisor_test.rb index dd5946266..6a0f3553b 100644 --- a/test/unit/async_supervisor_test.rb +++ b/test/unit/async_supervisor_test.rb @@ -5,20 +5,19 @@ class AsyncSupervisorTest < ActiveSupport::TestCase test "start as non-standalone" do supervisor = run_supervisor_as_thread - wait_for_registered_processes(4, timeout: 10.seconds) + wait_for_registered_processes(4, timeout: 3.seconds) # supervisor + dispatcher + 2 workers assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", supervisor_id: supervisor.process_id, count: 2) assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) - + ensure supervisor.stop - assert_no_registered_processes end test "start standalone" do pid = run_supervisor_as_fork(mode: :async) - wait_for_registered_processes(4, timeout: 10.seconds) + wait_for_registered_processes(4, timeout: 5.seconds) # supervisor + dispatcher + 2 workers assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", supervisor_pid: pid, count: 2) @@ -29,15 +28,15 @@ class AsyncSupervisorTest < ActiveSupport::TestCase end test "start as non-standalone with provided configuration" do - supervisor = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ]) - wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + dispatcher + supervisor = run_supervisor_as_thread(workers: [], dispatchers: [ { batch_size: 100 } ], skip_recurring: false) + wait_for_registered_processes(3, timeout: 3.seconds) # supervisor + dispatcher + scheduler assert_registered_processes(kind: "Supervisor(async)") assert_registered_processes(kind: "Worker", count: 0) assert_registered_processes(kind: "Dispatcher", supervisor_id: supervisor.process_id) - + assert_registered_processes(kind: "Scheduler", supervisor_id: supervisor.process_id) + ensure supervisor.stop - assert_no_registered_processes end @@ -50,17 +49,17 @@ class AsyncSupervisorTest < ActiveSupport::TestCase } supervisor = run_supervisor_as_thread(**config) - wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + 1 worker + wait_for_registered_processes(2, timeout: 3.seconds) # supervisor + 1 worker assert_registered_processes(kind: "Supervisor(async)") wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } - supervisor.stop - skip_active_record_query_cache do assert_equal 0, SolidQueue::ClaimedExecution.count assert_equal 3, SolidQueue::FailedExecution.count end + ensure + supervisor.stop end test "failed orphaned executions as standalone" do @@ -72,7 +71,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase } pid = run_supervisor_as_fork(mode: :async, **config) - wait_for_registered_processes(2, timeout: 10.seconds) # supervisor + 1 worker + wait_for_registered_processes(2, timeout: 3.seconds) # supervisor + 1 worker assert_registered_processes(kind: "Supervisor(async)") wait_while_with_timeout(1.second) { SolidQueue::ClaimedExecution.count > 0 } @@ -87,7 +86,7 @@ class AsyncSupervisorTest < ActiveSupport::TestCase private def run_supervisor_as_thread(**options) - SolidQueue::Supervisor.start(mode: :async, standalone: false, **options) + SolidQueue::Supervisor.start(mode: :async, standalone: false, **options.with_defaults(skip_recurring: true)) end def simulate_orphaned_executions(count) From 552f7d0a213143e4b910d4f6f3a2deb6eaf1af27 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 15 Jan 2026 16:31:26 +0100 Subject: [PATCH 45/85] Bump solid_queue to 1.3.1 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a028d1c88..ecb7ccf8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.3.0) + solid_queue (1.3.1) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index 04cb75a58..54a11ee0c 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.3.0" + VERSION = "1.3.1" end From 8d90eab8150d932a8bf9d7440fc1ff810df9d565 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 13 Feb 2026 12:41:19 +0100 Subject: [PATCH 46/85] Fix race condition between job enqueue and concurrency unblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses #456. There is a race condition in the concurrency control mechanism where a job that finishes and tries to unblock the next blocked execution can miss a `BlockedExecution` that is being created concurrently. This causes the blocked job to remain stuck until the `ConcurrencyMaintenance` periodic task runs (potentially minutes later). It happens as follows: 1. Job A is running (semaphore value=0) 2. Job B enqueue starts: reads semaphore (value=0, no row lock) → decides to block 3. Job A finishes: `Semaphore.signal` → `UPDATE` value to 1 (succeeds immediately since no lock held) 4. Job A: `BlockedExecution.release_one` → `SELECT` finds nothing (Job B's `BlockedExecution` not committed yet) 5. Job B enqueue commits: `BlockedExecution` now exists but nobody will unblock it The root cause is that `Semaphore::Proxy#wait` doesn't lock the semaphore row when checking the semaphore. This allows the concurrent `signal` to complete before the enqueue transaction commits, creating a window where the `BlockedExecution` is invisible. To fix, we lock the semaphore row with `FOR UPDATE` during the wait check so that the enqueue transaction holds the lock from the check through `BlockedExecution` creation and commit. This forces a concurrent signal `UPDATE` to wait, guaranteeing the `BlockedExecution` is visible when release_one runs. This shouldn't introduce any dead locks, as there's no new circular dependencies introduced by these two: - Enqueue path: locks `Semaphore` row → `INSERT`s `BlockedExecution` (no lock on existing rows) - `release_one` path: locks `BlockedExecution` row (`SKIP LOCKED`) → locks `Semaphore` row (via wait in release) Co-Authored-By: Claude Opus 4.6 --- app/models/solid_queue/semaphore.rb | 2 +- test/models/solid_queue/semaphore_test.rb | 119 ++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 test/models/solid_queue/semaphore_test.rb diff --git a/app/models/solid_queue/semaphore.rb b/app/models/solid_queue/semaphore.rb index e93b24195..d8caa64ea 100644 --- a/app/models/solid_queue/semaphore.rb +++ b/app/models/solid_queue/semaphore.rb @@ -40,7 +40,7 @@ def initialize(job) end def wait - if semaphore = Semaphore.find_by(key: key) + if semaphore = Semaphore.lock.find_by(key: key) semaphore.value > 0 && attempt_decrement else attempt_creation diff --git a/test/models/solid_queue/semaphore_test.rb b/test/models/solid_queue/semaphore_test.rb new file mode 100644 index 000000000..432b97afd --- /dev/null +++ b/test/models/solid_queue/semaphore_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "test_helper" + +class SolidQueue::SemaphoreTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + setup do + @result = JobResult.create!(queue_name: "default") + end + + test "wait acquires a row lock that blocks concurrent signal" do + skip_on_sqlite + + # Enqueue first job to create semaphore with value=0 + NonOverlappingUpdateResultJob.perform_later(@result) + concurrency_key = SolidQueue::Job.last.concurrency_key + assert_equal 0, SolidQueue::Semaphore.find_by(key: concurrency_key).value + + lock_held = Concurrent::Event.new + + # Background thread: holds a FOR UPDATE lock on the semaphore row + locker = Thread.new do + SolidQueue::Record.connection_pool.with_connection do + SolidQueue::Record.transaction do + SolidQueue::Semaphore.where(key: concurrency_key).lock.first + lock_held.set + sleep 1 + end + end + end + + lock_held.wait(5) + sleep 0.1 + + # Main thread: this UPDATE should block until the locker's transaction commits + start = monotonic_now + SolidQueue::Semaphore.where(key: concurrency_key).update_all("value = value + 1") + elapsed = monotonic_now - start + + locker.join(5) + + assert elapsed >= 0.5, "UPDATE should have been blocked by FOR UPDATE lock (took #{elapsed.round(3)}s)" + assert_equal 1, SolidQueue::Semaphore.find_by(key: concurrency_key).value + end + + test "blocked execution created during enqueue is visible to release_one after signal" do + skip_on_sqlite + + # Enqueue first job to create semaphore with value=0 + NonOverlappingUpdateResultJob.perform_later(@result) + job_a = SolidQueue::Job.last + concurrency_key = job_a.concurrency_key + assert_equal 0, SolidQueue::Semaphore.find_by(key: concurrency_key).value + + lock_held = Concurrent::Event.new + + # Background thread: simulates the enqueue path for a second job. + # Locks the semaphore row (as the code does), creates a BlockedExecution, + # then holds the transaction open to simulate the window where the race occurs. + enqueue_thread = Thread.new do + SolidQueue::Record.connection_pool.with_connection do + SolidQueue::Record.transaction do + # Lock the semaphore (same as Semaphore::Proxy#wait) + SolidQueue::Semaphore.where(key: concurrency_key).lock.first + + # Create job and blocked execution bypassing after_create callbacks + # to avoid re-entering Semaphore.wait + uuid = SecureRandom.uuid + SolidQueue::Job.insert({ + queue_name: "default", + class_name: "NonOverlappingUpdateResultJob", + concurrency_key: concurrency_key, + active_job_id: uuid, + arguments: "{}", + scheduled_at: Time.current + }) + job_b_id = SolidQueue::Job.where(active_job_id: uuid).pick(:id) + + SolidQueue::BlockedExecution.insert({ + job_id: job_b_id, + queue_name: "default", + concurrency_key: concurrency_key, + expires_at: SolidQueue.default_concurrency_control_period.from_now, + priority: 0 + }) + + lock_held.set + + # Hold the transaction open so the signal path must wait + sleep 1 + end + end + end + + lock_held.wait(5) + sleep 0.1 + + # Main thread: simulates job_a finishing — signal + release_one. + # The signal UPDATE will block until the enqueue transaction commits, + # guaranteeing the BlockedExecution is visible to release_one. + assert SolidQueue::Semaphore.signal(job_a) + assert SolidQueue::BlockedExecution.release_one(concurrency_key), + "release_one should find the BlockedExecution created by the concurrent enqueue" + + enqueue_thread.join(5) + + assert_equal 0, SolidQueue::BlockedExecution.where(concurrency_key: concurrency_key).count + end + + private + def skip_on_sqlite + skip "Row-level locking not supported on SQLite" if SolidQueue::Record.connection.adapter_name.downcase.include?("sqlite") + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end +end From 97a3a5488b7a7165b594b0d4f55dc0cb592ce373 Mon Sep 17 00:00:00 2001 From: Lovro Bikic Date: Tue, 3 Feb 2026 10:41:05 +0100 Subject: [PATCH 47/85] Remove default value for 'mode' in CLI options Setting a default value will override the defaults in Configuration, which reads from the SOLID_QUEUE_SUPERVISOR_MODE environment variable, effectively making it useless --- lib/solid_queue/cli.rb | 2 +- test/unit/cli_test.rb | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/unit/cli_test.rb diff --git a/lib/solid_queue/cli.rb b/lib/solid_queue/cli.rb index a2b5ba5e9..2f3f3e13b 100644 --- a/lib/solid_queue/cli.rb +++ b/lib/solid_queue/cli.rb @@ -8,7 +8,7 @@ class Cli < Thor desc: "Path to config file (default: #{Configuration::DEFAULT_CONFIG_FILE_PATH}).", banner: "SOLID_QUEUE_CONFIG" - class_option :mode, type: :string, default: "fork", enum: %w[ fork async ], + class_option :mode, type: :string, enum: %w[ fork async ], desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async) (default: fork).", banner: "SOLID_QUEUE_SUPERVISOR_MODE" diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb new file mode 100644 index 000000000..9ee0e233a --- /dev/null +++ b/test/unit/cli_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" +require "solid_queue/cli" + +class CliTest < ActiveSupport::TestCase + test "mode defaults to fork when no env var or option" do + with_env("SOLID_QUEUE_SUPERVISOR_MODE" => nil) do + config = configuration_from_cli + + assert config.mode.fork? + end + end + + test "mode respects SOLID_QUEUE_SUPERVISOR_MODE env var" do + with_env("SOLID_QUEUE_SUPERVISOR_MODE" => "async") do + config = configuration_from_cli + + assert config.mode.async? + end + end + + test "mode option overrides env var" do + with_env("SOLID_QUEUE_SUPERVISOR_MODE" => "async") do + config = configuration_from_cli(mode: "fork") + + assert config.mode.fork? + end + end + + test "mode option works without env var" do + with_env("SOLID_QUEUE_SUPERVISOR_MODE" => nil) do + config = configuration_from_cli(mode: "async") + + assert config.mode.async? + end + end + + private + def configuration_from_cli(**cli_options) + cli = SolidQueue::Cli.new([], cli_options) + options = cli.options.symbolize_keys.compact + + SolidQueue::Configuration.new(**options) + end +end From 5518a4da35eed2b6c0dc3a7bde3a693c29f9718b Mon Sep 17 00:00:00 2001 From: Himanshu Kale <108363864+himanshukale36@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:48:15 +0530 Subject: [PATCH 48/85] Fix: Detect and warn when schedules generate multiple CRONs (#707) * Fix: Detect and warn when schedules generate multiple CRONs Fixes #703 - Use Fugit::Nat.parse with multi: :fail to detect schedules that generate multiple CRONs - Raise validation error with clear message when multiple CRONs detected - Add tests for single CRON, multiple times (same/different minutes) - Prevents silent failures where only first CRON was used * Tidy up schedule parsing and error handling --------- Co-authored-by: Rosa Gutierrez --- app/models/solid_queue/recurring_task.rb | 19 ++++++++++++++----- .../models/solid_queue/recurring_task_test.rb | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index 2a776c8af..d248f9924 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -6,9 +6,9 @@ module SolidQueue class RecurringTask < Record serialize :arguments, coder: Arguments, default: [] - validate :supported_schedule + validate :ensure_schedule_supported validate :ensure_command_or_class_present - validate :existing_job_class + validate :ensure_existing_job_class scope :static, -> { where(static: true) } @@ -102,10 +102,19 @@ def attributes_for_upsert end private - def supported_schedule + def ensure_schedule_supported unless parsed_schedule.instance_of?(Fugit::Cron) errors.add :schedule, :unsupported, message: "is not a supported recurring schedule" end + rescue ArgumentError => error + message = if error.message.include?("multiple crons") + "generates multiple cron schedules. Please use separate recurring tasks for each schedule, " + + "or use explicit cron syntax (e.g., '40 0,15 * * *' for multiple times with the same minutes)" + else + error.message + end + + errors.add :schedule, :unsupported, message: message end def ensure_command_or_class_present @@ -114,7 +123,7 @@ def ensure_command_or_class_present end end - def existing_job_class + def ensure_existing_job_class if class_name.present? && job_class.nil? errors.add :class_name, :undefined, message: "doesn't correspond to an existing class" end @@ -152,7 +161,7 @@ def arguments_with_kwargs def parsed_schedule - @parsed_schedule ||= Fugit.parse(schedule) + @parsed_schedule ||= Fugit.parse(schedule, multi: :fail) end def job_class diff --git a/test/models/solid_queue/recurring_task_test.rb b/test/models/solid_queue/recurring_task_test.rb index 29e946c57..52312f12c 100644 --- a/test/models/solid_queue/recurring_task_test.rb +++ b/test/models/solid_queue/recurring_task_test.rb @@ -142,6 +142,25 @@ def perform # Empty schedule assert_not SolidQueue::RecurringTask.new(key: "task-id", class_name: "SolidQueue::RecurringTaskTest::JobWithoutArguments").valid? + + task = recurring_task_with(class_name: "JobWithoutArguments", schedule: "every day at 00:40") + assert task.valid? + assert task.to_s.ends_with? "[ 40 0 * * * ]" + + task = recurring_task_with(class_name: "JobWithoutArguments", schedule: "40 0 * * *") + assert task.valid? + + # Single cron to represent both hours, as it's the same minute + task = recurring_task_with(class_name: "JobWithoutArguments", schedule: "every day at 00:40 and 15:40") + assert task.valid? + assert task.to_s.ends_with? "[ 40 0,15 * * * ]" + + # Would need two cron tabs to reprenset the different hours and minutes + task = recurring_task_with(class_name: "JobWithoutArguments", schedule: "every day at 00:40 and 15:20") + assert_not task.valid? + error_message = task.errors[:schedule].first + assert_includes error_message, "generates multiple cron schedules" + assert_includes error_message, "Please use separate recurring tasks" end test "valid and invalid job class and command" do From b6825d08b66b3f27003d974c918b13cf7081ea1e Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 18 Feb 2026 20:22:15 +0100 Subject: [PATCH 49/85] Clean up dead thread from process_instances when replacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a thread dies, `replace_thread` deleted its entry from `configured_processes` but not from `process_instances`. On the next supervise loop, `check_and_replace_terminated_processes` would find the same dead thread again, call `replace_thread`, and `configured_processes.delete(thread_id)` would return nil — crashing with `undefined method 'instantiate' for nil`. Mirror the pattern already used in `ForkSupervisor#replace_fork`: delete from `process_instances` first and guard against the entry already being gone. Fixes #710 Co-Authored-By: Claude Opus 4.6 --- lib/solid_queue/async_supervisor.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/solid_queue/async_supervisor.rb b/lib/solid_queue/async_supervisor.rb index 02e82eec8..4a7f4aeec 100644 --- a/lib/solid_queue/async_supervisor.rb +++ b/lib/solid_queue/async_supervisor.rb @@ -19,17 +19,19 @@ def supervise def check_and_replace_terminated_processes terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? } - terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) } + terminated_threads.each { |thread_id, _| replace_thread(thread_id) } end - def replace_thread(thread_id, instance) + def replace_thread(thread_id) SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload| - payload[:thread] = instance + if (instance = process_instances.delete(thread_id)) + payload[:thread] = instance - error = Processes::ThreadTerminatedError.new(instance.name) - release_claimed_jobs_by(instance, with_error: error) + error = Processes::ThreadTerminatedError.new(instance.name) + release_claimed_jobs_by(instance, with_error: error) - start_process(configured_processes.delete(thread_id)) + start_process(configured_processes.delete(thread_id)) + end end end From 629ae5559f7700084900b61eec3edb5db467f945 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 18 Feb 2026 20:11:00 +0100 Subject: [PATCH 50/85] Fix crash when recording a failed execution for a job that already has one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous code used `create_or_find_by!` in `Job#failed_with`, passing an `exception` object as an attribute. When a FailedExecution already existed for the job (due to a unique constraint violation), the `find_by!` fallback tried to use the exception object as a SQL bind parameter, causing `TypeError: can't cast ProcessMissingError`. This state — a job having both a ClaimedExecution and a FailedExecution — shouldn't be possible given the transactional guarantees in `ClaimedExecution#failed_with`, but has been observed in practice by a couple of people . When it happens, `fail_orphaned_executions` crashes on startup, preventing Solid Queue from starting at all. I wonder if Mission Control is to blame here. There might be a bug or race condition in how it retries jobs that results in this apparently impossible situation. Regardless of whether the scenario is possible while using Solid Queue only via the public exposed API or not, the fix here is harmless and would allow the system to recover in any case. The fix replace `create_or_find_by!` with `create!`, rescuing RecordNotUnique to find and update the existing FailedExecution with the new error details. If the record disappears between the failed create and the find (race with a concurrent retry), retry the create. The create is wrapped in a savepoint (`requires_new: true`) so that the RecordNotUnique error doesn't abort the outer transaction on PostgreSQL. Also change the `expand_error_details_from_exception` callback from `before_create` to `before_save` (guarded by `if: :exception`) so that updating an existing FailedExecution with a new exception properly serializes the error details. Fixes #699 Co-Authored-By: Claude Opus 4.6 --- app/models/solid_queue/failed_execution.rb | 2 +- app/models/solid_queue/job/retryable.rb | 11 ++++++++++- .../solid_queue/claimed_execution_test.rb | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/models/solid_queue/failed_execution.rb b/app/models/solid_queue/failed_execution.rb index 8bcdc92f0..09a9596ef 100644 --- a/app/models/solid_queue/failed_execution.rb +++ b/app/models/solid_queue/failed_execution.rb @@ -6,7 +6,7 @@ class FailedExecution < Execution serialize :error, coder: JSON - before_create :expand_error_details_from_exception + before_save :expand_error_details_from_exception, if: :exception attr_accessor :exception diff --git a/app/models/solid_queue/job/retryable.rb b/app/models/solid_queue/job/retryable.rb index 34eb9e4df..e2e2a2d07 100644 --- a/app/models/solid_queue/job/retryable.rb +++ b/app/models/solid_queue/job/retryable.rb @@ -16,7 +16,16 @@ def retry end def failed_with(exception) - FailedExecution.create_or_find_by!(job_id: id, exception: exception) + FailedExecution.transaction(requires_new: true) do + FailedExecution.create!(job_id: id, exception: exception) + end + rescue ActiveRecord::RecordNotUnique + if (failed_execution = FailedExecution.find_by(job_id: id)) + failed_execution.exception = exception + failed_execution.save! + else + retry + end end def reset_execution_counters diff --git a/test/models/solid_queue/claimed_execution_test.rb b/test/models/solid_queue/claimed_execution_test.rb index 98513c94e..fb4f0335e 100644 --- a/test/models/solid_queue/claimed_execution_test.rb +++ b/test/models/solid_queue/claimed_execution_test.rb @@ -73,6 +73,23 @@ class SolidQueue::ClaimedExecutionTest < ActiveSupport::TestCase assert job.reload.failed? end + test "fail with error when a failed execution already exists updates the existing one" do + claimed_execution = prepare_and_claim_job AddToBufferJob.perform_later(42) + job = claimed_execution.job + + # Simulate corrupted state: a failed execution already exists for this job + SolidQueue::FailedExecution.create!(job_id: job.id, exception: RuntimeError.new("old error")) + + assert_no_difference -> { SolidQueue::FailedExecution.count } do + assert_difference -> { SolidQueue::ClaimedExecution.count }, -1 do + claimed_execution.failed_with(RuntimeError.new("new error")) + end + end + + assert job.reload.failed? + assert_equal "new error", job.failed_execution.message + end + test "provider_job_id is available within job execution" do job = ProviderJobIdJob.perform_later claimed_execution = prepare_and_claim_job job From 67a91dfcd5ec8ee1607b78d4c26d966d2d32465e Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 18 Feb 2026 21:33:45 +0100 Subject: [PATCH 51/85] Guard against nil pid in Puma plugin stop When fork fails silently in restricted container environments, `solid_queue_pid` remains `nil`. On shutdown, Puma fires the stopped event and `stop_solid_queue_fork` is called, where `Process.waitpid(nil)` raises `TypeError`. Add an early return when `solid_queue_pid` is nil, and remove the now-redundant conditional on `Process.kill`. Related to #628 and #617 Co-Authored-By: Claude Opus 4.6 --- lib/puma/plugin/solid_queue.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/puma/plugin/solid_queue.rb b/lib/puma/plugin/solid_queue.rb index 388062770..8a7aea28b 100644 --- a/lib/puma/plugin/solid_queue.rb +++ b/lib/puma/plugin/solid_queue.rb @@ -82,9 +82,11 @@ def start_solid_queue(**options) end def stop_solid_queue_fork + return unless solid_queue_pid + Process.waitpid(solid_queue_pid, Process::WNOHANG) log "Stopping Solid Queue..." - Process.kill(:INT, solid_queue_pid) if solid_queue_pid + Process.kill(:INT, solid_queue_pid) Process.wait(solid_queue_pid) rescue Errno::ECHILD, Errno::ESRCH end From 1017e0ac74e25cbad93be71f40fd7a52cca1a592 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 19 Feb 2026 11:40:49 +0100 Subject: [PATCH 52/85] Handle nil process in heartbeat When a process record is pruned from the database by another supervisor, the heartbeat rescues RecordNotFound and sets process to nil to trigger re-registration. But the heartbeat timer can fire again before re-registration completes, calling heartbeat on nil. Use safe navigation to make the heartbeat a no-op when process is nil. Fixes #693 Co-Authored-By: Claude Opus 4.6 --- lib/solid_queue/processes/registrable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solid_queue/processes/registrable.rb b/lib/solid_queue/processes/registrable.rb index c74280104..cd7769da8 100644 --- a/lib/solid_queue/processes/registrable.rb +++ b/lib/solid_queue/processes/registrable.rb @@ -54,7 +54,7 @@ def stop_heartbeat end def heartbeat - process.heartbeat + process&.heartbeat rescue ActiveRecord::RecordNotFound self.process = nil wake_up From 4031642891e600c842a7e9b64c9a88798e5acd12 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Thu, 19 Feb 2026 12:03:08 +0100 Subject: [PATCH 53/85] Skip concurrency controls for jobs whose class has been removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a job's class is removed from the codebase (e.g. during a deploy), any enqueued jobs with concurrency controls would crash the worker because `concurrency_limited?` returned true based solely on the presence of a `concurrency_key`, then delegates like `concurrency_limit` and `concurrency_duration` would blow up on the nil `job_class`. By also checking that `job_class` is present, we let these jobs bypass concurrency controls entirely. They get dispatched normally, fail at `ActiveJob::Base.execute` with a NameError, and become FailedExecutions — instead of blocking the entire queue. Fixes #522 Co-Authored-By: Claude Opus 4.6 --- .../solid_queue/job/concurrency_controls.rb | 2 +- .../solid_queue/claimed_execution_test.rb | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/models/solid_queue/job/concurrency_controls.rb b/app/models/solid_queue/job/concurrency_controls.rb index b7410b085..30d4399ed 100644 --- a/app/models/solid_queue/job/concurrency_controls.rb +++ b/app/models/solid_queue/job/concurrency_controls.rb @@ -26,7 +26,7 @@ def unblock_next_blocked_job end def concurrency_limited? - concurrency_key.present? + concurrency_key.present? && job_class.present? end def blocked? diff --git a/test/models/solid_queue/claimed_execution_test.rb b/test/models/solid_queue/claimed_execution_test.rb index fb4f0335e..dd1088c90 100644 --- a/test/models/solid_queue/claimed_execution_test.rb +++ b/test/models/solid_queue/claimed_execution_test.rb @@ -86,10 +86,47 @@ class SolidQueue::ClaimedExecutionTest < ActiveSupport::TestCase end end - assert job.reload.failed? assert_equal "new error", job.failed_execution.message end + test "perform job with missing class fails gracefully" do + job = create_job_with_missing_class + claimed_execution = claim_job(job) + + assert_difference -> { SolidQueue::ClaimedExecution.count } => -1, -> { SolidQueue::FailedExecution.count } => 1 do + assert_raises NameError do + claimed_execution.perform + end + end + + assert job.reload.failed? + end + + test "perform concurrency-controlled job with missing class fails gracefully" do + job = create_job_with_missing_class(concurrency_key: "test_key") + claimed_execution = claim_job(job) + + assert_difference -> { SolidQueue::ClaimedExecution.count } => -1, -> { SolidQueue::FailedExecution.count } => 1 do + assert_raises NameError do + claimed_execution.perform + end + end + + assert job.reload.failed? + end + + test "dispatch job with missing class and concurrency key skips concurrency controls" do + job = create_job_with_missing_class(concurrency_key: "test_key") + + assert_not job.concurrency_limited? + + job.prepare_for_execution + + assert job.reload.ready? + assert_equal 0, SolidQueue::BlockedExecution.where(job_id: job.id).count + assert_equal 0, SolidQueue::Semaphore.where(key: "test_key").count + end + test "provider_job_id is available within job execution" do job = ProviderJobIdJob.perform_later claimed_execution = prepare_and_claim_job job @@ -101,8 +138,22 @@ class SolidQueue::ClaimedExecutionTest < ActiveSupport::TestCase private def prepare_and_claim_job(active_job, process: @process) job = SolidQueue::Job.find_by(active_job_id: active_job.job_id) - job.prepare_for_execution + claim_job(job, process: process) + end + + def create_job_with_missing_class(concurrency_key: nil) + SolidQueue::Job.create!( + queue_name: "background", + class_name: "RemovedJobClass", + active_job_id: SecureRandom.uuid, + arguments: { "job_class" => "RemovedJobClass", "arguments" => [] }, + concurrency_key: concurrency_key, + scheduled_at: Time.current + ) + end + + def claim_job(job, process: @process) assert_difference -> { SolidQueue::ClaimedExecution.count } => +1 do SolidQueue::ReadyExecution.claim(job.queue_name, 1, process.id) end From 0b132025e106890506b95a7c10cc338367a4d857 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Feb 2026 10:11:42 +0100 Subject: [PATCH 54/85] Bump solid_queue to 1.3.2 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ecb7ccf8a..772a99da7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.3.1) + solid_queue (1.3.2) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index 54a11ee0c..4f0b806b9 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.3.1" + VERSION = "1.3.2" end From 2c4bbd30aa5c21773b4e74d160f91a80d833dd51 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Sun, 22 Feb 2026 11:12:30 +0100 Subject: [PATCH 55/85] Add index hint for releasing blocked executions Use Rails' `optimizer_hints` to hint MySQL to use the `index_solid_queue_blocked_executions_for_release` composite index when releasing blocked executions, avoiding query planner misses and unnecessary row locks. The hint is rendered as a `/*+ ... */` SQL comment, which MySQL's optimizer reads and SQLite and PostgreSQL ignore as a regular comment. Fixes #694 --- app/models/solid_queue/blocked_execution.rb | 4 +++- app/models/solid_queue/record.rb | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/solid_queue/blocked_execution.rb b/app/models/solid_queue/blocked_execution.rb index 4ad4d2397..68551a5f5 100644 --- a/app/models/solid_queue/blocked_execution.rb +++ b/app/models/solid_queue/blocked_execution.rb @@ -26,7 +26,9 @@ def release_many(concurrency_keys) def release_one(concurrency_key) transaction do - if execution = ordered.where(concurrency_key: concurrency_key).limit(1).non_blocking_lock.first + if execution = ordered.where(concurrency_key: concurrency_key).limit(1) + .use_index(:index_solid_queue_blocked_executions_for_release) + .non_blocking_lock.first execution.release end end diff --git a/app/models/solid_queue/record.rb b/app/models/solid_queue/record.rb index 0a704d2c8..8c7000bfe 100644 --- a/app/models/solid_queue/record.rb +++ b/app/models/solid_queue/record.rb @@ -20,6 +20,13 @@ def supports_insert_conflict_target? connection.supports_insert_conflict_target? end end + + # Pass index hints to the query optimizer using SQL comment hints. + # Uses MySQL 8 optimizer hint query comments, which SQLite and + # PostgreSQL ignore. + def use_index(*indexes) + optimizer_hints "INDEX(#{quoted_table_name} #{indexes.join(', ')})" + end end end end From 9347dbd0573bb28821ccf7b58b8ae670ac6e7569 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 24 Apr 2025 11:54:31 +0300 Subject: [PATCH 56/85] Feature: Dynamic scheduled tasks --- README.md | 2 + app/models/solid_queue/recurring_task.rb | 1 + lib/solid_queue/configuration.rb | 10 +- lib/solid_queue/scheduler.rb | 6 ++ .../scheduler/recurring_schedule.rb | 46 ++++++-- test/unit/configuration_test.rb | 6 +- test/unit/scheduler_test.rb | 100 +++++++++++++++++- 7 files changed, 152 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b49f9c60d..7c13bda3d 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,8 @@ Rails.application.config.after_initialize do # or to_prepare end ``` +You can also dynamically add or remove recurring tasks by creating or deleting SolidQueue::RecurringTask records. It works the same way as with static tasks, except you must set the static field to false. Changes won’t be picked up immediately — they take effect after about a one-minute delay. + It's possible to run multiple schedulers with the same `recurring_tasks` configuration, for example, if you have multiple servers for redundancy, and you run the `scheduler` in more than one of them. To avoid enqueuing duplicate tasks at the same time, an entry in a new `solid_queue_recurring_executions` table is created in the same transaction as the job is enqueued. This table has a unique index on `task_key` and `run_at`, ensuring only one entry per task per time will be created. This only works if you have `preserve_finished_jobs` set to `true` (the default), and the guarantee applies as long as you keep the jobs around. **Note**: a single recurring schedule is supported, so you can have multiple schedulers using the same schedule, but not multiple schedulers using different configurations. diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index d248f9924..d073a277f 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -11,6 +11,7 @@ class RecurringTask < Record validate :ensure_existing_job_class scope :static, -> { where(static: true) } + scope :dynamic, -> { where(static: false) } has_many :recurring_executions, foreign_key: :task_key, primary_key: :key diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 94169ca7b..da6b0df40 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -103,7 +103,7 @@ def default_options end def invalid_tasks - recurring_tasks.select(&:invalid?) + static_recurring_tasks.select(&:invalid?) end def only_work? @@ -137,8 +137,8 @@ def dispatchers end def schedulers - if !skip_recurring_tasks? && recurring_tasks.any? - [ Process.new(:scheduler, recurring_tasks: recurring_tasks) ] + if !skip_recurring_tasks? + [ Process.new(:scheduler, recurring_tasks: static_recurring_tasks) ] else [] end @@ -154,8 +154,8 @@ def dispatchers_options .map { |options| options.dup.symbolize_keys } end - def recurring_tasks - @recurring_tasks ||= recurring_tasks_config.map do |id, options| + def static_recurring_tasks + @static_recurring_tasks ||= recurring_tasks_config.map do |id, options| RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule) end.compact end diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index 3cec90fa7..3ac78d748 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -30,6 +30,12 @@ def run loop do break if shutting_down? + recurring_schedule.update_scheduled_tasks.tap do |updated_tasks| + if updated_tasks.any? + process.update_columns(metadata: metadata.compact) + end + end + interruptible_sleep(SLEEP_INTERVAL) end ensure diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index b765edf14..ea55f9f84 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -4,10 +4,11 @@ module SolidQueue class Scheduler::RecurringSchedule include AppExecutor - attr_reader :configured_tasks, :scheduled_tasks + attr_reader :static_tasks, :configured_tasks, :scheduled_tasks def initialize(tasks) - @configured_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) + @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) + @configured_tasks = @static_tasks + dynamic_tasks @scheduled_tasks = Concurrent::Hash.new end @@ -17,8 +18,8 @@ def empty? def schedule_tasks wrap_in_app_executor do - persist_tasks - reload_tasks + persist_static_tasks + reload_static_tasks end configured_tasks.each do |task| @@ -26,6 +27,27 @@ def schedule_tasks end end + def dynamic_tasks + SolidQueue::RecurringTask.dynamic + end + + def schedule_new_dynamic_tasks + dynamic_tasks.where.not(key: scheduled_tasks.keys).each do |task| + schedule_task(task) + end + end + + def unschedule_old_dynamic_tasks + (scheduled_tasks.keys - SolidQueue::RecurringTask.pluck(:key)).each do |key| + scheduled_tasks[key].cancel + scheduled_tasks.delete(key) + end + end + + def update_scheduled_tasks + schedule_new_dynamic_tasks + unschedule_old_dynamic_tasks + end + def schedule_task(task) scheduled_tasks[task.key] = schedule(task) end @@ -35,18 +57,22 @@ def unschedule_tasks scheduled_tasks.clear end + def static_task_keys + static_tasks.map(&:key) + end + def task_keys - configured_tasks.map(&:key) + static_task_keys + dynamic_tasks.map(&:key) end private - def persist_tasks - SolidQueue::RecurringTask.static.where.not(key: task_keys).delete_all - SolidQueue::RecurringTask.create_or_update_all configured_tasks + def persist_static_tasks + SolidQueue::RecurringTask.static.where.not(key: static_task_keys).delete_all + SolidQueue::RecurringTask.create_or_update_all static_tasks end - def reload_tasks - @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a + def reload_static_tasks + @static_tasks = SolidQueue::RecurringTask.static.where(key: static_task_keys).to_a end def schedule(task) diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index 11c2a5ff2..14db95cb9 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -21,7 +21,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "default configuration when config given is empty" do configuration = SolidQueue::Configuration.new(config_file: config_file_path(:empty_configuration), recurring_schedule_file: config_file_path(:empty_configuration)) - assert_equal 2, configuration.configured_processes.count + assert_equal 3, configuration.configured_processes.count # includes scheduler for dynamic tasks assert_processes configuration, :worker, 1, queues: "*" assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size] end @@ -134,12 +134,12 @@ class ConfigurationTest < ActiveSupport::TestCase configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_production_only)) assert configuration.valid? - assert_processes configuration, :scheduler, 0 + assert_processes configuration, :scheduler, 1 # Starts in case of dynamic tasks assert_output(/Provided configuration file '[^']+' does not exist\./) do configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_empty)) assert configuration.valid? - assert_processes configuration, :scheduler, 0 + assert_processes configuration, :scheduler, 1 end # No processes diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 3e838c504..21f50f344 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -3,7 +3,7 @@ class SchedulerTest < ActiveSupport::TestCase self.use_transactional_tests = false - test "recurring schedule" do + test "recurring schedule (only static)" do recurring_tasks = { example_task: { class: "AddToBufferJob", schedule: "every hour", args: 42 } } scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks).tap(&:start) @@ -17,6 +17,41 @@ class SchedulerTest < ActiveSupport::TestCase scheduler.stop end + test "recurring schedule (only dynamic)" do + SolidQueue::RecurringTask.create( + key: "dynamic_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] + ) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap(&:start) + + wait_for_registered_processes(1, timeout: 1.second) + + process = SolidQueue::Process.first + assert_equal "Scheduler", process.kind + + assert_metadata process, recurring_schedule: [ "dynamic_task" ] + ensure + scheduler.stop + end + + test "recurring schedule (static + dynamic)" do + SolidQueue::RecurringTask.create( + key: "dynamic_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] + ) + + recurring_tasks = { static_task: { class: "AddToBufferJob", schedule: "every hour", args: 42 } } + + scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks).tap(&:start) + + wait_for_registered_processes(1, timeout: 1.second) + + process = SolidQueue::Process.first + assert_equal "Scheduler", process.kind + + assert_metadata process, recurring_schedule: [ "static_task", "dynamic_task" ] + ensure + scheduler.stop + end + test "run more than one instance of the scheduler with recurring tasks" do recurring_tasks = { example_task: { class: "AddToBufferJob", schedule: "every second", args: 42 } } schedulers = 2.times.collect do @@ -37,4 +72,67 @@ class SchedulerTest < ActiveSupport::TestCase end end end + + test "updates metadata after adding dynamic task post-start" do + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap do |s| + s.define_singleton_method(:interruptible_sleep) { |interval| sleep 0.1 } + s.start + end + + wait_for_registered_processes(1, timeout: 1.second) + + process = SolidQueue::Process.first + # initially there are no recurring_schedule keys + assert process.metadata, {} + + # now create a dynamic task after the scheduler has booted + SolidQueue::RecurringTask.create( + key: "new_dynamic_task", + static: false, + class_name: "AddToBufferJob", + schedule: "every second", + arguments: [ 42 ] + ) + + sleep 1 + + process.reload + + # metadata should now include the new key + assert_metadata process, recurring_schedule: [ "new_dynamic_task" ] + ensure + scheduler&.stop + end + + test "updates metadata after removing dynamic task post-start" do + old_dynamic_task = SolidQueue::RecurringTask.create( + key: "old_dynamic_task", + static: false, + class_name: "AddToBufferJob", + schedule: "every second", + arguments: [ 42 ] + ) + + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap do |s| + s.define_singleton_method(:interruptible_sleep) { |interval| sleep 0.1 } + s.start + end + + wait_for_registered_processes(1, timeout: 1.second) + + process = SolidQueue::Process.first + # initially there is one recurring_schedule key + assert_metadata process, recurring_schedule: [ "old_dynamic_task" ] + + old_dynamic_task.destroy + + sleep 1 + + process.reload + + # The task is unschedule after it's being removed, and it's reflected in the metadata + assert process.metadata, {} + ensure + scheduler&.stop + end end From 3a230875bf16409244abc1ebe01277d63ac3d144 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 19 Jun 2025 15:27:44 +0300 Subject: [PATCH 57/85] Extract polling_interval to scheduler configuration --- .../install/templates/config/queue.yml | 2 ++ lib/solid_queue/configuration.rb | 32 ++++++++++++------- lib/solid_queue/scheduler.rb | 8 ++--- test/unit/scheduler_test.rb | 10 ++---- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/lib/generators/solid_queue/install/templates/config/queue.yml b/lib/generators/solid_queue/install/templates/config/queue.yml index 15691e9d2..d7b0e6b91 100644 --- a/lib/generators/solid_queue/install/templates/config/queue.yml +++ b/lib/generators/solid_queue/install/templates/config/queue.yml @@ -7,6 +7,8 @@ default: &default threads: 3 processes: <%%= ENV.fetch("JOB_CONCURRENCY", 1) %> polling_interval: 0.1 + scheduler: + polling_interval: 1 development: <<: *default diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index da6b0df40..244444bd5 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -28,6 +28,10 @@ def instantiate concurrency_maintenance_interval: 600 } + SCHEDULER_DEFAULTS = { + polling_interval: 1 + } + DEFAULT_CONFIG_FILE_PATH = "config/queue.yml" DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml" @@ -103,7 +107,7 @@ def default_options end def invalid_tasks - static_recurring_tasks.select(&:invalid?) + recurring_tasks.select(&:invalid?) end def only_work? @@ -137,11 +141,9 @@ def dispatchers end def schedulers - if !skip_recurring_tasks? - [ Process.new(:scheduler, recurring_tasks: static_recurring_tasks) ] - else - [] - end + return [] if skip_recurring_tasks? + + [ Process.new(:scheduler, { recurring_tasks:, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] end def workers_options @@ -154,17 +156,25 @@ def dispatchers_options .map { |options| options.dup.symbolize_keys } end - def static_recurring_tasks - @static_recurring_tasks ||= recurring_tasks_config.map do |id, options| + def scheduler_options + @scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys + end + + def recurring_tasks + @recurring_tasks ||= recurring_tasks_config.map do |id, options| RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule) end.compact end def processes_config @processes_config ||= config_from \ - options.slice(:workers, :dispatchers).presence || options[:config_file], - keys: [ :workers, :dispatchers ], - fallback: { workers: [ WORKER_DEFAULTS ], dispatchers: [ DISPATCHER_DEFAULTS ] } + options.slice(:workers, :dispatchers, :scheduler).presence || options[:config_file], + keys: [ :workers, :dispatchers, :scheduler ], + fallback: { + workers: [ WORKER_DEFAULTS ], + dispatchers: [ DISPATCHER_DEFAULTS ], + scheduler: SCHEDULER_DEFAULTS + } end def recurring_tasks_config diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index 3ac78d748..68a72d80b 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -5,7 +5,7 @@ class Scheduler < Processes::Base include Processes::Runnable include LifecycleHooks - attr_reader :recurring_schedule + attr_reader :recurring_schedule, :polling_interval after_boot :run_start_hooks after_boot :schedule_recurring_tasks @@ -15,6 +15,8 @@ class Scheduler < Processes::Base def initialize(recurring_tasks:, **options) @recurring_schedule = RecurringSchedule.new(recurring_tasks) + options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS) + @polling_interval = options[:polling_interval] super(**options) end @@ -24,8 +26,6 @@ def metadata end private - SLEEP_INTERVAL = 60 # Right now it doesn't matter, can be set to 1 in the future for dynamic tasks - def run loop do break if shutting_down? @@ -36,7 +36,7 @@ def run end end - interruptible_sleep(SLEEP_INTERVAL) + interruptible_sleep(polling_interval) end ensure SolidQueue.instrument(:shutdown_process, process: self) do diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 21f50f344..1e93681c8 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -74,10 +74,7 @@ class SchedulerTest < ActiveSupport::TestCase end test "updates metadata after adding dynamic task post-start" do - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap do |s| - s.define_singleton_method(:interruptible_sleep) { |interval| sleep 0.1 } - s.start - end + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -113,10 +110,7 @@ class SchedulerTest < ActiveSupport::TestCase arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap do |s| - s.define_singleton_method(:interruptible_sleep) { |interval| sleep 0.1 } - s.start - end + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) From 3a13962ab8aded3859e5ef928ce1e64c917df8c6 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 19 Jun 2025 16:06:01 +0300 Subject: [PATCH 58/85] Fix abstraction for RecurringSchedule and Process --- lib/solid_queue/processes/registrable.rb | 4 ++++ lib/solid_queue/scheduler.rb | 7 ++---- .../scheduler/recurring_schedule.rb | 22 ++++++++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/solid_queue/processes/registrable.rb b/lib/solid_queue/processes/registrable.rb index cd7769da8..7e43cff1d 100644 --- a/lib/solid_queue/processes/registrable.rb +++ b/lib/solid_queue/processes/registrable.rb @@ -59,5 +59,9 @@ def heartbeat self.process = nil wake_up end + + def refresh_registered_process + process.update_columns(metadata: metadata.compact) + end end end diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index 68a72d80b..f0464f2bb 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -30,11 +30,8 @@ def run loop do break if shutting_down? - recurring_schedule.update_scheduled_tasks.tap do |updated_tasks| - if updated_tasks.any? - process.update_columns(metadata: metadata.compact) - end - end + recurring_schedule.reload! + refresh_registered_process if recurring_schedule.changed? interruptible_sleep(polling_interval) end diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index ea55f9f84..65f3be2dc 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -4,12 +4,13 @@ module SolidQueue class Scheduler::RecurringSchedule include AppExecutor - attr_reader :static_tasks, :configured_tasks, :scheduled_tasks + attr_reader :static_tasks, :configured_tasks, :scheduled_tasks, :changes def initialize(tasks) @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) @configured_tasks = @static_tasks + dynamic_tasks @scheduled_tasks = Concurrent::Hash.new + @changes = Concurrent::Hash.new end def empty? @@ -44,10 +45,6 @@ def unschedule_old_dynamic_tasks end end - def update_scheduled_tasks - schedule_new_dynamic_tasks + unschedule_old_dynamic_tasks - end - def schedule_task(task) scheduled_tasks[task.key] = schedule(task) end @@ -65,6 +62,21 @@ def task_keys static_task_keys + dynamic_tasks.map(&:key) end + def reload! + { added_tasks: schedule_new_dynamic_tasks, + removed_tasks: unschedule_old_dynamic_tasks }.each do |key, values| + if values.any? + changes[key] = values + else + changes.delete(key) + end + end + end + + def changed? + @changes.any? + end + private def persist_static_tasks SolidQueue::RecurringTask.static.where.not(key: static_task_keys).delete_all From d1c0efcef99c048ed34587ee5129dfd6a4c6c4e5 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 19 Jun 2025 17:35:14 +0300 Subject: [PATCH 59/85] Add create and destroy recurring task helpers --- lib/solid_queue.rb | 9 +++++++++ test/solid_queue_test.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index e0d51c8cb..0b8ea4e34 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -43,6 +43,15 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor + + def create_recurring_task(key, **attributes) + RecurringTask.create!(**attributes, key:, static: false) + end + + def destroy_recurring_task(id) + RecurringTask.dynamic.find(id).destroy! + end + [ Dispatcher, Scheduler, Worker ].each do |process| define_singleton_method(:"on_#{process.name.demodulize.downcase}_start") do |&block| process.on_start(&block) diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index d6d61b576..2c7bd00be 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -4,4 +4,31 @@ class SolidQueueTest < ActiveSupport::TestCase test "it has a version number" do assert SolidQueue::VERSION end + + test "creates recurring tasks" do + SolidQueue.create_recurring_task("test 1", command: "puts 1", schedule: "every hour") + SolidQueue.create_recurring_task("test 2", command: "puts 2", schedule: "every minute", static: true) + + assert SolidQueue::RecurringTask.exists?(key: "test 1", command: "puts 1", schedule: "every hour", static: false) + assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) + end + + test "destroys recurring tasks" do + dynamic_task = SolidQueue::RecurringTask.create!( + key: "dynamic", command: "puts 'd'", schedule: "every day", static: false + ) + + static_task = SolidQueue::RecurringTask.create!( + key: "static", command: "puts 's'", schedule: "every week", static: true + ) + + SolidQueue.destroy_recurring_task(dynamic_task.id) + + assert_raises(ActiveRecord::RecordNotFound) do + SolidQueue.destroy_recurring_task(static_task.id) + end + + assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) + assert SolidQueue::RecurringTask.exists?(key: "static", static: true) + end end From 77ff5231a2a8850e7f052ff5f9a6644fdc36c0ce Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Sat, 12 Jul 2025 12:28:18 +0300 Subject: [PATCH 60/85] Update README with Recurring tasks info --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7c13bda3d..6329f3776 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,17 @@ It is recommended to set this value less than or equal to the queue database's c - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else. +### Scheduler polling interval + +The scheduler process checks for due recurring tasks and reloads dynamic tasks at a configurable interval. You can set this interval using the `polling_interval` key under the `scheduler` section in your `config/queue.yml`: + +```yaml +scheduler: + polling_interval: 5 # seconds +``` + +This controls how frequently the scheduler wakes up to enqueue due recurring jobs and reload dynamic tasks. + ### Queue order and priorities As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`. @@ -707,8 +718,6 @@ Rails.application.config.after_initialize do # or to_prepare end ``` -You can also dynamically add or remove recurring tasks by creating or deleting SolidQueue::RecurringTask records. It works the same way as with static tasks, except you must set the static field to false. Changes won’t be picked up immediately — they take effect after about a one-minute delay. - It's possible to run multiple schedulers with the same `recurring_tasks` configuration, for example, if you have multiple servers for redundancy, and you run the `scheduler` in more than one of them. To avoid enqueuing duplicate tasks at the same time, an entry in a new `solid_queue_recurring_executions` table is created in the same transaction as the job is enqueued. This table has a unique index on `task_key` and `run_at`, ensuring only one entry per task per time will be created. This only works if you have `preserve_finished_jobs` set to `true` (the default), and the guarantee applies as long as you keep the jobs around. **Note**: a single recurring schedule is supported, so you can have multiple schedulers using the same schedule, but not multiple schedulers using different configurations. @@ -734,6 +743,47 @@ my_periodic_resque_job: and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time. + +### Creating and Deleting Recurring Tasks Dynamically + +You can create and delete recurring tasks at runtime, without editing the configuration file. Use the following methods: + +#### Creating a recurring task + +```ruby +SolidQueue.schedule_recurring_task( + "my_dynamic_task", + command: "puts 'Hello from a dynamic task!'", + schedule: "every 10 minutes" +) +``` + +This will create a dynamic recurring task with the given key, command, and schedule. You can also use the `class` and `args` options as in the configuration file. + +#### Deleting a recurring task + +```ruby +SolidQueue.delete_recurring_task(task_id) +``` + +This will delete a dynamically scheduled recurring task by its ID. If you attempt to delete a static (configuration-defined) recurring task, an error will be raised. + +> **Note:** Static recurring tasks (those defined in `config/recurring.yml`) cannot be deleted at runtime. Attempting to do so will raise an error. + +#### Example: Creating and deleting a recurring task + +```ruby +# Create a new dynamic recurring task +recurring_task = SolidQueue.schedule_recurring_task( + "cleanup_temp_files", + command: "TempFileCleaner.clean!", + schedule: "every day at 2am" +) + +# Delete the task later by ID +SolidQueue.delete_recurring_task(recurring_task.id) +``` + ## Inspiration Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job). We recommend checking out these projects as they're great examples from which we've learnt a lot. From f1028a050715c95d94367efe3421d6d270b00f56 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 21 Aug 2025 19:06:33 +0300 Subject: [PATCH 61/85] Use task key instead of id --- lib/solid_queue.rb | 4 ++-- test/solid_queue_test.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index 0b8ea4e34..6d088de32 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -48,8 +48,8 @@ def create_recurring_task(key, **attributes) RecurringTask.create!(**attributes, key:, static: false) end - def destroy_recurring_task(id) - RecurringTask.dynamic.find(id).destroy! + def destroy_recurring_task(key) + RecurringTask.dynamic.find_by!(key:).destroy end [ Dispatcher, Scheduler, Worker ].each do |process| diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 2c7bd00be..316add468 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -22,10 +22,10 @@ class SolidQueueTest < ActiveSupport::TestCase key: "static", command: "puts 's'", schedule: "every week", static: true ) - SolidQueue.destroy_recurring_task(dynamic_task.id) + SolidQueue.destroy_recurring_task(dynamic_task.key) assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.destroy_recurring_task(static_task.id) + SolidQueue.destroy_recurring_task(static_task.key) end assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) From 7dd67c88bf8ee83eb916fb8c7c88b4cd60dc7daf Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 21 Aug 2025 19:07:03 +0300 Subject: [PATCH 62/85] Fix mismatches in readme --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6329f3776..adaf419c1 100644 --- a/README.md +++ b/README.md @@ -751,7 +751,7 @@ You can create and delete recurring tasks at runtime, without editing the config #### Creating a recurring task ```ruby -SolidQueue.schedule_recurring_task( +SolidQueue.create_recurring_task( "my_dynamic_task", command: "puts 'Hello from a dynamic task!'", schedule: "every 10 minutes" @@ -763,10 +763,10 @@ This will create a dynamic recurring task with the given key, command, and sched #### Deleting a recurring task ```ruby -SolidQueue.delete_recurring_task(task_id) +SolidQueue.destroy_recurring_task(key) ``` -This will delete a dynamically scheduled recurring task by its ID. If you attempt to delete a static (configuration-defined) recurring task, an error will be raised. +This will delete a dynamically scheduled recurring task by its key. If you attempt to delete a static (configuration-defined) recurring task, an error will be raised. > **Note:** Static recurring tasks (those defined in `config/recurring.yml`) cannot be deleted at runtime. Attempting to do so will raise an error. @@ -774,14 +774,14 @@ This will delete a dynamically scheduled recurring task by its ID. If you attemp ```ruby # Create a new dynamic recurring task -recurring_task = SolidQueue.schedule_recurring_task( +recurring_task = SolidQueue.create_recurring_task( "cleanup_temp_files", command: "TempFileCleaner.clean!", schedule: "every day at 2am" ) -# Delete the task later by ID -SolidQueue.delete_recurring_task(recurring_task.id) +# Delete the task later by key +SolidQueue.destroy_recurring_task("cleanup_temp_files") ``` ## Inspiration From eef747f64f672df7100a7c754dc0f11ac925a12e Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:04:23 +0200 Subject: [PATCH 63/85] Use correct asserts in tests Two assertions were using assert value, message instead of assert_equal/assert_empty, meaning they always passed regardless of the actual metadata content. Fixed to use assert_empty. Also fixed a typo ("unschedule" -> "unscheduled"). --- test/unit/scheduler_test.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 1e93681c8..aac9aa51c 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -80,7 +80,7 @@ class SchedulerTest < ActiveSupport::TestCase process = SolidQueue::Process.first # initially there are no recurring_schedule keys - assert process.metadata, {} + assert_empty process.metadata # now create a dynamic task after the scheduler has booted SolidQueue::RecurringTask.create( @@ -124,8 +124,8 @@ class SchedulerTest < ActiveSupport::TestCase process.reload - # The task is unschedule after it's being removed, and it's reflected in the metadata - assert process.metadata, {} + # The task is unscheduled after it's been removed, and it's reflected in the metadata + assert_empty process.metadata ensure scheduler&.stop end From 8e1be8b2b207f8441aff114b2d9da87bd0d82fa4 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:04:45 +0200 Subject: [PATCH 64/85] Add missing platform to Gemfile.lock --- Gemfile.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile.lock b/Gemfile.lock index 772a99da7..324821a29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -193,6 +193,7 @@ PLATFORMS arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 + arm64-darwin-25 x86_64-darwin-21 x86_64-darwin-23 x86_64-linux From 5ba7384312207703fad370f3b37e7ac660503ab6 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:08:29 +0200 Subject: [PATCH 65/85] Move some Scheduler::RecurringSchedule methods to private --- .../scheduler/recurring_schedule.rb | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 65f3be2dc..5b72d91a0 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -4,7 +4,7 @@ module SolidQueue class Scheduler::RecurringSchedule include AppExecutor - attr_reader :static_tasks, :configured_tasks, :scheduled_tasks, :changes + attr_reader :configured_tasks, :scheduled_tasks def initialize(tasks) @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) @@ -28,23 +28,6 @@ def schedule_tasks end end - def dynamic_tasks - SolidQueue::RecurringTask.dynamic - end - - def schedule_new_dynamic_tasks - dynamic_tasks.where.not(key: scheduled_tasks.keys).each do |task| - schedule_task(task) - end - end - - def unschedule_old_dynamic_tasks - (scheduled_tasks.keys - SolidQueue::RecurringTask.pluck(:key)).each do |key| - scheduled_tasks[key].cancel - scheduled_tasks.delete(key) - end - end - def schedule_task(task) scheduled_tasks[task.key] = schedule(task) end @@ -54,10 +37,6 @@ def unschedule_tasks scheduled_tasks.clear end - def static_task_keys - static_tasks.map(&:key) - end - def task_keys static_task_keys + dynamic_tasks.map(&:key) end @@ -78,6 +57,29 @@ def changed? end private + attr_reader :static_tasks + + def dynamic_tasks + SolidQueue::RecurringTask.dynamic + end + + def static_task_keys + static_tasks.map(&:key) + end + + def schedule_new_dynamic_tasks + dynamic_tasks.where.not(key: scheduled_tasks.keys).each do |task| + schedule_task(task) + end + end + + def unschedule_old_dynamic_tasks + (scheduled_tasks.keys - SolidQueue::RecurringTask.pluck(:key)).each do |key| + scheduled_tasks[key].cancel + scheduled_tasks.delete(key) + end + end + def persist_static_tasks SolidQueue::RecurringTask.static.where.not(key: static_task_keys).delete_all SolidQueue::RecurringTask.create_or_update_all static_tasks From dd67d58d133b28e2000825d3bcd66462131b4040 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:09:26 +0200 Subject: [PATCH 66/85] Wrap reload in app executor DB queries in reload! (dynamic_tasks.where.not(...), RecurringTask.pluck(:key)) were not wrapped in the app executor, which could cause connection management issues. Wrapped in wrap_in_app_executor. --- lib/solid_queue/scheduler/recurring_schedule.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 5b72d91a0..2dacada0d 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -42,12 +42,14 @@ def task_keys end def reload! - { added_tasks: schedule_new_dynamic_tasks, - removed_tasks: unschedule_old_dynamic_tasks }.each do |key, values| - if values.any? - changes[key] = values - else - changes.delete(key) + wrap_in_app_executor do + { added_tasks: schedule_new_dynamic_tasks, + removed_tasks: unschedule_old_dynamic_tasks }.each do |key, values| + if values.any? + @changes[key] = values + else + @changes.delete(key) + end end end end From b90cce501ee8a687228ebe51e6cf4994bb233718 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:10:39 +0200 Subject: [PATCH 67/85] Fix empty? method in Scheduler::RecurringSchedule empty? checked stale configured_tasks (lib/solid_queue/scheduler/recurring_schedule.rb) -- configured_tasks was set once in initialize and never updated with dynamic tasks. This meant empty? could return true even when dynamic tasks existed, causing the scheduler to exit prematurely in inline mode. Changed empty? to check scheduled_tasks.empty? && dynamic_tasks.none?. --- lib/solid_queue/scheduler/recurring_schedule.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 2dacada0d..ff7e7a7bf 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -14,7 +14,7 @@ def initialize(tasks) end def empty? - configured_tasks.empty? + scheduled_tasks.empty? && dynamic_tasks.none? end def schedule_tasks From 6b81c9571131d9c9aed59097ebdfe72718ac3cba Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:13:21 +0200 Subject: [PATCH 68/85] Prevent stale change by adding clear_changes in scheduler's loop Changes not cleared after consumption (lib/solid_queue/scheduler.rb, lib/solid_queue/scheduler/recurring_schedule.rb) -- Added clear_changes method and call it in the scheduler's run loop after refresh_registered_process, preventing stale change state from persisting. --- lib/solid_queue/scheduler.rb | 6 +++++- lib/solid_queue/scheduler/recurring_schedule.rb | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index f0464f2bb..920c5d3af 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -31,7 +31,11 @@ def run break if shutting_down? recurring_schedule.reload! - refresh_registered_process if recurring_schedule.changed? + + if recurring_schedule.changed? + refresh_registered_process + recurring_schedule.clear_changes + end interruptible_sleep(polling_interval) end diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index ff7e7a7bf..dfe192f7c 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -58,6 +58,10 @@ def changed? @changes.any? end + def clear_changes + @changes.clear + end + private attr_reader :static_tasks From 99ba202295a58f02e84fdc7ecf207a9119a664ef Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:13:43 +0200 Subject: [PATCH 69/85] Add test for enqueuing the job --- test/unit/scheduler_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index aac9aa51c..669afdcf9 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -73,6 +73,22 @@ class SchedulerTest < ActiveSupport::TestCase end end + test "dynamic task actually enqueues jobs" do + SolidQueue::RecurringTask.create!( + key: "dynamic_enqueue_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] + ) + + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) + + wait_for_registered_processes(1, timeout: 1.second) + sleep 2 + + assert SolidQueue::Job.count >= 1, "Expected at least one job to be enqueued by the dynamic task" + assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count + ensure + scheduler&.stop + end + test "updates metadata after adding dynamic task post-start" do scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) From 79fca8de657fb4862b13f5dbbe8501e7737d6ffb Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:18:54 +0200 Subject: [PATCH 70/85] Improve create_recurring_task - use RecurringTask .from_configuration --- README.md | 9 +++++---- lib/solid_queue.rb | 7 +++++-- test/solid_queue_test.rb | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index adaf419c1..ed00d7dcd 100644 --- a/README.md +++ b/README.md @@ -753,12 +753,13 @@ You can create and delete recurring tasks at runtime, without editing the config ```ruby SolidQueue.create_recurring_task( "my_dynamic_task", - command: "puts 'Hello from a dynamic task!'", + class: "MyJob", + args: [1, 2], schedule: "every 10 minutes" ) ``` -This will create a dynamic recurring task with the given key, command, and schedule. You can also use the `class` and `args` options as in the configuration file. +This will create a dynamic recurring task with the given key, class, and schedule. The API accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`. #### Deleting a recurring task @@ -774,9 +775,9 @@ This will delete a dynamically scheduled recurring task by its key. If you attem ```ruby # Create a new dynamic recurring task -recurring_task = SolidQueue.create_recurring_task( +SolidQueue.create_recurring_task( "cleanup_temp_files", - command: "TempFileCleaner.clean!", + class: "TempFileCleanerJob", schedule: "every day at 2am" ) diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index 6d088de32..7ca1fd036 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -44,8 +44,11 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor - def create_recurring_task(key, **attributes) - RecurringTask.create!(**attributes, key:, static: false) + def create_recurring_task(key, **options) + RecurringTask.from_configuration(key, **options).tap do |task| + task.static = false + task.save! + end end def destroy_recurring_task(key) diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 316add468..01c41a19d 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -13,6 +13,15 @@ class SolidQueueTest < ActiveSupport::TestCase assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) end + test "creates recurring tasks with class and args (same keys as YAML config)" do + SolidQueue.create_recurring_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") + + task = SolidQueue::RecurringTask.find_by!(key: "test 3") + assert_equal "AddToBufferJob", task.class_name + assert_equal [ 42 ], task.arguments + assert_equal false, task.static + end + test "destroys recurring tasks" do dynamic_task = SolidQueue::RecurringTask.create!( key: "dynamic", command: "puts 'd'", schedule: "every day", static: false From 7ca2eb7a54de1f7f6c5e66eef86e0e0d24c71036 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Thu, 12 Feb 2026 16:30:06 +0200 Subject: [PATCH 71/85] Fix names for public methods --- README.md | 26 +++++++++++++------------- lib/solid_queue.rb | 4 ++-- test/solid_queue_test.rb | 16 ++++++++-------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ed00d7dcd..63019c65f 100644 --- a/README.md +++ b/README.md @@ -744,14 +744,14 @@ my_periodic_resque_job: and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time. -### Creating and Deleting Recurring Tasks Dynamically +### Scheduling and Unscheduling Recurring Tasks Dynamically -You can create and delete recurring tasks at runtime, without editing the configuration file. Use the following methods: +You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. Use the following methods: -#### Creating a recurring task +#### Scheduling a recurring task ```ruby -SolidQueue.create_recurring_task( +SolidQueue.schedule_task( "my_dynamic_task", class: "MyJob", args: [1, 2], @@ -761,28 +761,28 @@ SolidQueue.create_recurring_task( This will create a dynamic recurring task with the given key, class, and schedule. The API accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`. -#### Deleting a recurring task +#### Unscheduling a recurring task ```ruby -SolidQueue.destroy_recurring_task(key) +SolidQueue.unschedule_task(key) ``` -This will delete a dynamically scheduled recurring task by its key. If you attempt to delete a static (configuration-defined) recurring task, an error will be raised. +This will delete a dynamically scheduled recurring task by its key. If you attempt to unschedule a static (configuration-defined) recurring task, an error will be raised. -> **Note:** Static recurring tasks (those defined in `config/recurring.yml`) cannot be deleted at runtime. Attempting to do so will raise an error. +> **Note:** Static recurring tasks (those defined in `config/recurring.yml`) cannot be unscheduled at runtime. Attempting to do so will raise an error. -#### Example: Creating and deleting a recurring task +#### Example: Scheduling and unscheduling a recurring task ```ruby -# Create a new dynamic recurring task -SolidQueue.create_recurring_task( +# Schedule a new dynamic recurring task +SolidQueue.schedule_task( "cleanup_temp_files", class: "TempFileCleanerJob", schedule: "every day at 2am" ) -# Delete the task later by key -SolidQueue.destroy_recurring_task("cleanup_temp_files") +# Unschedule the task later by key +SolidQueue.unschedule_task("cleanup_temp_files") ``` ## Inspiration diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index 7ca1fd036..11994b763 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -44,14 +44,14 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor - def create_recurring_task(key, **options) + def schedule_task(key, **options) RecurringTask.from_configuration(key, **options).tap do |task| task.static = false task.save! end end - def destroy_recurring_task(key) + def unschedule_task(key) RecurringTask.dynamic.find_by!(key:).destroy end diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 01c41a19d..091406545 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -5,16 +5,16 @@ class SolidQueueTest < ActiveSupport::TestCase assert SolidQueue::VERSION end - test "creates recurring tasks" do - SolidQueue.create_recurring_task("test 1", command: "puts 1", schedule: "every hour") - SolidQueue.create_recurring_task("test 2", command: "puts 2", schedule: "every minute", static: true) + test "schedules recurring tasks" do + SolidQueue.schedule_task("test 1", command: "puts 1", schedule: "every hour") + SolidQueue.schedule_task("test 2", command: "puts 2", schedule: "every minute", static: true) assert SolidQueue::RecurringTask.exists?(key: "test 1", command: "puts 1", schedule: "every hour", static: false) assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) end - test "creates recurring tasks with class and args (same keys as YAML config)" do - SolidQueue.create_recurring_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") + test "schedules recurring tasks with class and args (same keys as YAML config)" do + SolidQueue.schedule_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") task = SolidQueue::RecurringTask.find_by!(key: "test 3") assert_equal "AddToBufferJob", task.class_name @@ -22,7 +22,7 @@ class SolidQueueTest < ActiveSupport::TestCase assert_equal false, task.static end - test "destroys recurring tasks" do + test "unschedules recurring tasks" do dynamic_task = SolidQueue::RecurringTask.create!( key: "dynamic", command: "puts 'd'", schedule: "every day", static: false ) @@ -31,10 +31,10 @@ class SolidQueueTest < ActiveSupport::TestCase key: "static", command: "puts 's'", schedule: "every week", static: true ) - SolidQueue.destroy_recurring_task(dynamic_task.key) + SolidQueue.unschedule_task(dynamic_task.key) assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.destroy_recurring_task(static_task.key) + SolidQueue.unschedule_task(static_task.key) end assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) From 890dabe4f00ba3077c123ad95024077a70ab4b94 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Fri, 13 Feb 2026 00:29:02 +0200 Subject: [PATCH 72/85] Prevent stale configured_tasks value --- lib/solid_queue/scheduler/recurring_schedule.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index dfe192f7c..12c7af3dd 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -4,15 +4,18 @@ module SolidQueue class Scheduler::RecurringSchedule include AppExecutor - attr_reader :configured_tasks, :scheduled_tasks + attr_reader :scheduled_tasks def initialize(tasks) @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) - @configured_tasks = @static_tasks + dynamic_tasks @scheduled_tasks = Concurrent::Hash.new @changes = Concurrent::Hash.new end + def configured_tasks + static_tasks + dynamic_tasks.to_a + end + def empty? scheduled_tasks.empty? && dynamic_tasks.none? end @@ -38,7 +41,7 @@ def unschedule_tasks end def task_keys - static_task_keys + dynamic_tasks.map(&:key) + static_task_keys + dynamic_tasks.pluck(:key) end def reload! From abbf54ed7b25f3592ce069601f59c626e74960c9 Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Fri, 13 Feb 2026 00:29:40 +0200 Subject: [PATCH 73/85] Use wrap_in_app_executor for refresh_registered_process --- lib/solid_queue/processes/registrable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solid_queue/processes/registrable.rb b/lib/solid_queue/processes/registrable.rb index 7e43cff1d..94e9ed77f 100644 --- a/lib/solid_queue/processes/registrable.rb +++ b/lib/solid_queue/processes/registrable.rb @@ -61,7 +61,7 @@ def heartbeat end def refresh_registered_process - process.update_columns(metadata: metadata.compact) + wrap_in_app_executor { process&.update_columns(metadata: metadata.compact) } end end end From 4ec36d6dfd34cde414d09a105cf74a1c0b826cbc Mon Sep 17 00:00:00 2001 From: Vladyslav Davydenko Date: Fri, 13 Feb 2026 00:31:58 +0200 Subject: [PATCH 74/85] Update tests and README --- README.md | 2 + lib/solid_queue/configuration.rb | 3 ++ test/solid_queue_test.rb | 14 ++++++ test/unit/scheduler_test.rb | 74 ++++++++++++++++---------------- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 63019c65f..536c2f861 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ scheduler: This controls how frequently the scheduler wakes up to enqueue due recurring jobs and reload dynamic tasks. +> **Note:** The scheduler process always starts by default to support dynamic recurring tasks, even if no static tasks are configured in `config/recurring.yml`. If you don't use recurring tasks at all, you can disable the scheduler by setting `SOLID_QUEUE_SKIP_RECURRING=true` or passing `skip_recurring: true` in the configuration. + ### Queue order and priorities As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`. diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 244444bd5..21f7ad5d5 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -143,6 +143,9 @@ def dispatchers def schedulers return [] if skip_recurring_tasks? + # Always start a scheduler (even with no static recurring tasks) to support + # dynamic tasks that may be added at runtime via SolidQueue.schedule_task. + # Use skip_recurring: true or SOLID_QUEUE_SKIP_RECURRING=true to disable. [ Process.new(:scheduler, { recurring_tasks:, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] end diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 091406545..542c3d826 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -40,4 +40,18 @@ class SolidQueueTest < ActiveSupport::TestCase assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) assert SolidQueue::RecurringTask.exists?(key: "static", static: true) end + + test "schedule_task with duplicate key raises error" do + SolidQueue.schedule_task("duplicate_test", command: "puts 1", schedule: "every hour") + + assert_raises(ActiveRecord::RecordNotUnique) do + SolidQueue.schedule_task("duplicate_test", command: "puts 2", schedule: "every minute") + end + end + + test "unschedule_task with nonexistent key raises RecordNotFound" do + assert_raises(ActiveRecord::RecordNotFound) do + SolidQueue.unschedule_task("nonexistent_key") + end + end end diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 669afdcf9..882dcd729 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -81,10 +81,12 @@ class SchedulerTest < ActiveSupport::TestCase scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) - sleep 2 + wait_while_with_timeout(3.seconds) { SolidQueue::Job.count < 1 } - assert SolidQueue::Job.count >= 1, "Expected at least one job to be enqueued by the dynamic task" - assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count + skip_active_record_query_cache do + assert SolidQueue::Job.count >= 1, "Expected at least one job to be enqueued by the dynamic task" + assert_equal SolidQueue::Job.count, SolidQueue::RecurringExecution.count + end ensure scheduler&.stop end @@ -94,54 +96,54 @@ class SchedulerTest < ActiveSupport::TestCase wait_for_registered_processes(1, timeout: 1.second) - process = SolidQueue::Process.first - # initially there are no recurring_schedule keys - assert_empty process.metadata - - # now create a dynamic task after the scheduler has booted - SolidQueue::RecurringTask.create( - key: "new_dynamic_task", - static: false, - class_name: "AddToBufferJob", - schedule: "every second", - arguments: [ 42 ] - ) - - sleep 1 - - process.reload - - # metadata should now include the new key - assert_metadata process, recurring_schedule: [ "new_dynamic_task" ] + skip_active_record_query_cache do + process = SolidQueue::Process.first + # initially there are no recurring_schedule keys + assert_empty process.metadata + + # now create a dynamic task after the scheduler has booted + SolidQueue::RecurringTask.create!( + key: "new_dynamic_task", + static: false, + class_name: "AddToBufferJob", + schedule: "every second", + arguments: [ 42 ] + ) + + wait_while_with_timeout(3.seconds) { process.reload.metadata.empty? } + + # metadata should now include the new key + assert_metadata process, recurring_schedule: [ "new_dynamic_task" ] + end ensure scheduler&.stop end test "updates metadata after removing dynamic task post-start" do - old_dynamic_task = SolidQueue::RecurringTask.create( - key: "old_dynamic_task", - static: false, + old_dynamic_task = SolidQueue::RecurringTask.create!( + key: "old_dynamic_task", + static: false, class_name: "AddToBufferJob", - schedule: "every second", - arguments: [ 42 ] + schedule: "every second", + arguments: [ 42 ] ) scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) - process = SolidQueue::Process.first - # initially there is one recurring_schedule key - assert_metadata process, recurring_schedule: [ "old_dynamic_task" ] - - old_dynamic_task.destroy + skip_active_record_query_cache do + process = SolidQueue::Process.first + # initially there is one recurring_schedule key + assert_metadata process, recurring_schedule: [ "old_dynamic_task" ] - sleep 1 + old_dynamic_task.destroy - process.reload + wait_while_with_timeout(3.seconds) { process.reload.metadata.present? } - # The task is unscheduled after it's been removed, and it's reflected in the metadata - assert_empty process.metadata + # The task is unscheduled after it's been removed, and it's reflected in the metadata + assert_empty process.metadata + end ensure scheduler&.stop end From e1c1601051e7dbef026351d5824a85d46cf34f6e Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Tue, 24 Feb 2026 22:05:18 +0100 Subject: [PATCH 75/85] Fix style to match project conventions Remove extra blank lines, column-aligned hashes, and inline comments on test assertions. Revert unrelated Gemfile.lock platform addition. Filter :static from schedule_task options since dynamic tasks are always non-static by definition. Co-Authored-By: Claude Opus 4.6 --- Gemfile.lock | 1 - README.md | 2 -- lib/solid_queue.rb | 3 +-- lib/solid_queue/configuration.rb | 1 - test/unit/configuration_test.rb | 4 ++-- test/unit/scheduler_test.rb | 25 +++++++++---------------- 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 324821a29..772a99da7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -193,7 +193,6 @@ PLATFORMS arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 - arm64-darwin-25 x86_64-darwin-21 x86_64-darwin-23 x86_64-linux diff --git a/README.md b/README.md index 536c2f861..9238edd73 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,6 @@ It is recommended to set this value less than or equal to the queue database's c - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode). - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else. - ### Scheduler polling interval The scheduler process checks for due recurring tasks and reloads dynamic tasks at a configurable interval. You can set this interval using the `polling_interval` key under the `scheduler` section in your `config/queue.yml`: @@ -745,7 +744,6 @@ my_periodic_resque_job: and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time. - ### Scheduling and Unscheduling Recurring Tasks Dynamically You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. Use the following methods: diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index 11994b763..27c8345d5 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -43,9 +43,8 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor - def schedule_task(key, **options) - RecurringTask.from_configuration(key, **options).tap do |task| + RecurringTask.from_configuration(key, **options.except(:static)).tap do |task| task.static = false task.save! end diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 21f7ad5d5..4d0ce72e3 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -186,7 +186,6 @@ def recurring_tasks_config end end - def config_from(file_or_hash, keys: [], fallback: {}, env: Rails.env) load_config_from(file_or_hash).then do |config| config = config[env.to_sym] ? config[env.to_sym] : config diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index 14db95cb9..aed0c18c0 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -21,7 +21,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "default configuration when config given is empty" do configuration = SolidQueue::Configuration.new(config_file: config_file_path(:empty_configuration), recurring_schedule_file: config_file_path(:empty_configuration)) - assert_equal 3, configuration.configured_processes.count # includes scheduler for dynamic tasks + assert_equal 3, configuration.configured_processes.count assert_processes configuration, :worker, 1, queues: "*" assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size] end @@ -134,7 +134,7 @@ class ConfigurationTest < ActiveSupport::TestCase configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_production_only)) assert configuration.valid? - assert_processes configuration, :scheduler, 1 # Starts in case of dynamic tasks + assert_processes configuration, :scheduler, 1 assert_output(/Provided configuration file '[^']+' does not exist\./) do configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_empty)) diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 882dcd729..f164bc1b1 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -21,7 +21,7 @@ class SchedulerTest < ActiveSupport::TestCase SolidQueue::RecurringTask.create( key: "dynamic_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -98,21 +98,17 @@ class SchedulerTest < ActiveSupport::TestCase skip_active_record_query_cache do process = SolidQueue::Process.first - # initially there are no recurring_schedule keys assert_empty process.metadata - # now create a dynamic task after the scheduler has booted SolidQueue::RecurringTask.create!( - key: "new_dynamic_task", - static: false, + key: "new_dynamic_task", + static: false, class_name: "AddToBufferJob", - schedule: "every second", - arguments: [ 42 ] + schedule: "every second", + arguments: [ 42 ] ) wait_while_with_timeout(3.seconds) { process.reload.metadata.empty? } - - # metadata should now include the new key assert_metadata process, recurring_schedule: [ "new_dynamic_task" ] end ensure @@ -121,11 +117,11 @@ class SchedulerTest < ActiveSupport::TestCase test "updates metadata after removing dynamic task post-start" do old_dynamic_task = SolidQueue::RecurringTask.create!( - key: "old_dynamic_task", - static: false, + key: "old_dynamic_task", + static: false, class_name: "AddToBufferJob", - schedule: "every second", - arguments: [ 42 ] + schedule: "every second", + arguments: [ 42 ] ) scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) @@ -134,14 +130,11 @@ class SchedulerTest < ActiveSupport::TestCase skip_active_record_query_cache do process = SolidQueue::Process.first - # initially there is one recurring_schedule key assert_metadata process, recurring_schedule: [ "old_dynamic_task" ] old_dynamic_task.destroy wait_while_with_timeout(3.seconds) { process.reload.metadata.present? } - - # The task is unscheduled after it's been removed, and it's reflected in the metadata assert_empty process.metadata end ensure From 2f1655833968776b5c6f76fef8b29d5d295890c3 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 14:35:36 +0100 Subject: [PATCH 76/85] Make dynamic recurring tasks opt-in and rename public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic tasks require explicit `dynamic_tasks: true` in the scheduler config. Without it, the scheduler behaves as before — no extra DB queries, no polling for dynamic changes. Default polling interval changed from 1s to 5s. Rename schedule_task/unschedule_task to schedule_recurring_task/ unschedule_recurring_task so the API clearly communicates these are recurring tasks. Co-Authored-By: Claude Opus 4.6 --- README.md | 58 +++++++++---------- .../install/templates/config/queue.yml | 2 - lib/solid_queue.rb | 4 +- lib/solid_queue/configuration.rb | 16 +++-- lib/solid_queue/scheduler.rb | 24 ++++++-- .../scheduler/recurring_schedule.rb | 25 ++++++-- test/solid_queue_test.rb | 20 +++---- test/unit/configuration_test.rb | 6 +- test/unit/scheduler_test.rb | 10 ++-- 9 files changed, 99 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 9238edd73..ecb675011 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite, - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler) - [Fork vs. async mode](#fork-vs-async-mode) - [Configuration](#configuration) + - [Optional scheduler configuration](#optional-scheduler-configuration) - [Queue order and priorities](#queue-order-and-priorities) - [Queues specification and performance](#queues-specification-and-performance) - [Threads, processes, and signals](#threads-processes-and-signals) @@ -31,6 +32,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite, - [Puma plugin](#puma-plugin) - [Jobs and transactional integrity](#jobs-and-transactional-integrity) - [Recurring tasks](#recurring-tasks) + - [Scheduling and unscheduling recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically) - [Inspiration](#inspiration) - [License](#license) @@ -209,7 +211,7 @@ By default, Solid Queue will try to find your configuration under `config/queue. bin/jobs -c config/calendar.yml ``` -You can also skip all recurring tasks by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`. +You can also skip the scheduler process by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true`. This is useful for environments like staging, review apps, or development where you don't want any recurring jobs to run. This is equivalent to using the `--skip-recurring` option with `bin/jobs`. This is what this configuration looks like: @@ -227,6 +229,10 @@ production: threads: 5 polling_interval: 0.1 processes: 3 + scheduler: + dynamic_tasks_enabled: true + polling_interval: 5 + ``` Everything is optional. If no configuration at all is provided, Solid Queue will run with one dispatcher and one worker with default settings. If you want to run only dispatchers or workers, you just need to include that section alone in the configuration. For example, with the following configuration: @@ -270,18 +276,19 @@ It is recommended to set this value less than or equal to the queue database's c - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode). - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else. -### Scheduler polling interval -The scheduler process checks for due recurring tasks and reloads dynamic tasks at a configurable interval. You can set this interval using the `polling_interval` key under the `scheduler` section in your `config/queue.yml`: +### Optional scheduler configuration + +Optionally, you can configure the scheduler process under the `scheduler` section in your `config/queue.yml` if you'd like to [schedule recurring tasks dynamically](#scheduling-and-unscheduling-recurring-tasks-dynamically). ```yaml scheduler: - polling_interval: 5 # seconds + dynamic_tasks_enabled: true + polling_interval: 5 ``` -This controls how frequently the scheduler wakes up to enqueue due recurring jobs and reload dynamic tasks. - -> **Note:** The scheduler process always starts by default to support dynamic recurring tasks, even if no static tasks are configured in `config/recurring.yml`. If you don't use recurring tasks at all, you can disable the scheduler by setting `SOLID_QUEUE_SKIP_RECURRING=true` or passing `skip_recurring: true` in the configuration. +- `dynamic_tasks_enabled`: whether the scheduler should poll for [dynamically scheduled recurring tasks](#scheduling-and-unscheduling-recurring-tasks-dynamically). This is `false` by default. When enabled, the scheduler will poll the database at the given `polling_interval` to pick up tasks scheduled via `SolidQueue.schedule_recurring_task`. +- `polling_interval`: how frequently (in seconds) the scheduler checks for dynamic task changes. Defaults to `5`. ### Queue order and priorities @@ -744,14 +751,19 @@ my_periodic_resque_job: and the job will be enqueued via `perform_later` so it'll run in Resque. However, in this case we won't track any `solid_queue_recurring_execution` record for it and there won't be any guarantees that the job is enqueued only once each time. -### Scheduling and Unscheduling Recurring Tasks Dynamically +### Scheduling and unscheduling recurring tasks dynamically + +You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. To enable this, you need to set `dynamic_tasks_enabled: true` in the `scheduler` section of your `config/queue.yml`, [as explained earlier](#optional-scheduler-configuration). -You can schedule and unschedule recurring tasks at runtime, without editing the configuration file. Use the following methods: +```yaml +scheduler: + dynamic_tasks_enabled: true +``` -#### Scheduling a recurring task +Then you can use the following methods to add recurring tasks dynamically: ```ruby -SolidQueue.schedule_task( +SolidQueue.schedule_recurring_task( "my_dynamic_task", class: "MyJob", args: [1, 2], @@ -759,31 +771,17 @@ SolidQueue.schedule_task( ) ``` -This will create a dynamic recurring task with the given key, class, and schedule. The API accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`. +This accepts the same options as the YAML configuration: `class`, `args`, `command`, `schedule`, `queue`, `priority`, and `description`. -#### Unscheduling a recurring task +To remove a dynamically scheduled task: ```ruby -SolidQueue.unschedule_task(key) +SolidQueue.unschedule_recurring_task("my_dynamic_task") ``` -This will delete a dynamically scheduled recurring task by its key. If you attempt to unschedule a static (configuration-defined) recurring task, an error will be raised. +Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error. -> **Note:** Static recurring tasks (those defined in `config/recurring.yml`) cannot be unscheduled at runtime. Attempting to do so will raise an error. - -#### Example: Scheduling and unscheduling a recurring task - -```ruby -# Schedule a new dynamic recurring task -SolidQueue.schedule_task( - "cleanup_temp_files", - class: "TempFileCleanerJob", - schedule: "every day at 2am" -) - -# Unschedule the task later by key -SolidQueue.unschedule_task("cleanup_temp_files") -``` +Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them. ## Inspiration diff --git a/lib/generators/solid_queue/install/templates/config/queue.yml b/lib/generators/solid_queue/install/templates/config/queue.yml index d7b0e6b91..15691e9d2 100644 --- a/lib/generators/solid_queue/install/templates/config/queue.yml +++ b/lib/generators/solid_queue/install/templates/config/queue.yml @@ -7,8 +7,6 @@ default: &default threads: 3 processes: <%%= ENV.fetch("JOB_CONCURRENCY", 1) %> polling_interval: 0.1 - scheduler: - polling_interval: 1 development: <<: *default diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index 27c8345d5..af4b5a842 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -43,14 +43,14 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor - def schedule_task(key, **options) + def schedule_recurring_task(key, **options) RecurringTask.from_configuration(key, **options.except(:static)).tap do |task| task.static = false task.save! end end - def unschedule_task(key) + def unschedule_recurring_task(key) RecurringTask.dynamic.find_by!(key:).destroy end diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 4d0ce72e3..d0a462656 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -29,7 +29,8 @@ def instantiate } SCHEDULER_DEFAULTS = { - polling_interval: 1 + polling_interval: 5, + dynamic_tasks: false } DEFAULT_CONFIG_FILE_PATH = "config/queue.yml" @@ -143,10 +144,11 @@ def dispatchers def schedulers return [] if skip_recurring_tasks? - # Always start a scheduler (even with no static recurring tasks) to support - # dynamic tasks that may be added at runtime via SolidQueue.schedule_task. - # Use skip_recurring: true or SOLID_QUEUE_SKIP_RECURRING=true to disable. - [ Process.new(:scheduler, { recurring_tasks:, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] + if recurring_tasks.any? || dynamic_tasks_enabled? + [ Process.new(:scheduler, { recurring_tasks:, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] + else + [] + end end def workers_options @@ -163,6 +165,10 @@ def scheduler_options @scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys end + def dynamic_tasks_enabled? + scheduler_options.fetch(:dynamic_tasks, SCHEDULER_DEFAULTS[:dynamic_tasks]) + end + def recurring_tasks @recurring_tasks ||= recurring_tasks_config.map do |id, options| RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule) diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index 920c5d3af..c224f2b87 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -14,9 +14,10 @@ class Scheduler < Processes::Base after_shutdown :run_exit_hooks def initialize(recurring_tasks:, **options) - @recurring_schedule = RecurringSchedule.new(recurring_tasks) options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS) + @dynamic_tasks = options[:dynamic_tasks] @polling_interval = options[:polling_interval] + @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks: @dynamic_tasks) super(**options) end @@ -26,7 +27,24 @@ def metadata end private + attr_reader :dynamic_tasks + def run + if dynamic_tasks + poll_for_dynamic_tasks + else + loop do + break if shutting_down? + interruptible_sleep(polling_interval) + end + end + ensure + SolidQueue.instrument(:shutdown_process, process: self) do + run_callbacks(:shutdown) { shutdown } + end + end + + def poll_for_dynamic_tasks loop do break if shutting_down? @@ -39,10 +57,6 @@ def run interruptible_sleep(polling_interval) end - ensure - SolidQueue.instrument(:shutdown_process, process: self) do - run_callbacks(:shutdown) { shutdown } - end end def schedule_recurring_tasks diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 12c7af3dd..8b5582716 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -6,18 +6,27 @@ class Scheduler::RecurringSchedule attr_reader :scheduled_tasks - def initialize(tasks) + def initialize(tasks, dynamic_tasks: false) @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) @scheduled_tasks = Concurrent::Hash.new @changes = Concurrent::Hash.new + @dynamic_tasks_enabled = dynamic_tasks end def configured_tasks - static_tasks + dynamic_tasks.to_a + if dynamic_tasks_enabled? + static_tasks + dynamic_tasks.to_a + else + static_tasks + end end def empty? - scheduled_tasks.empty? && dynamic_tasks.none? + if dynamic_tasks_enabled? + scheduled_tasks.empty? && dynamic_tasks.none? + else + scheduled_tasks.empty? + end end def schedule_tasks @@ -41,7 +50,11 @@ def unschedule_tasks end def task_keys - static_task_keys + dynamic_tasks.pluck(:key) + if dynamic_tasks_enabled? + static_task_keys + dynamic_tasks.pluck(:key) + else + static_task_keys + end end def reload! @@ -68,6 +81,10 @@ def clear_changes private attr_reader :static_tasks + def dynamic_tasks_enabled? + @dynamic_tasks_enabled + end + def dynamic_tasks SolidQueue::RecurringTask.dynamic end diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 542c3d826..768b9c3c5 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -6,15 +6,15 @@ class SolidQueueTest < ActiveSupport::TestCase end test "schedules recurring tasks" do - SolidQueue.schedule_task("test 1", command: "puts 1", schedule: "every hour") - SolidQueue.schedule_task("test 2", command: "puts 2", schedule: "every minute", static: true) + SolidQueue.schedule_recurring_task("test 1", command: "puts 1", schedule: "every hour") + SolidQueue.schedule_recurring_task("test 2", command: "puts 2", schedule: "every minute", static: true) assert SolidQueue::RecurringTask.exists?(key: "test 1", command: "puts 1", schedule: "every hour", static: false) assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) end test "schedules recurring tasks with class and args (same keys as YAML config)" do - SolidQueue.schedule_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") + SolidQueue.schedule_recurring_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") task = SolidQueue::RecurringTask.find_by!(key: "test 3") assert_equal "AddToBufferJob", task.class_name @@ -31,27 +31,27 @@ class SolidQueueTest < ActiveSupport::TestCase key: "static", command: "puts 's'", schedule: "every week", static: true ) - SolidQueue.unschedule_task(dynamic_task.key) + SolidQueue.unschedule_recurring_task(dynamic_task.key) assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.unschedule_task(static_task.key) + SolidQueue.unschedule_recurring_task(static_task.key) end assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) assert SolidQueue::RecurringTask.exists?(key: "static", static: true) end - test "schedule_task with duplicate key raises error" do - SolidQueue.schedule_task("duplicate_test", command: "puts 1", schedule: "every hour") + test "schedule_recurring_task with duplicate key raises error" do + SolidQueue.schedule_recurring_task("duplicate_test", command: "puts 1", schedule: "every hour") assert_raises(ActiveRecord::RecordNotUnique) do - SolidQueue.schedule_task("duplicate_test", command: "puts 2", schedule: "every minute") + SolidQueue.schedule_recurring_task("duplicate_test", command: "puts 2", schedule: "every minute") end end - test "unschedule_task with nonexistent key raises RecordNotFound" do + test "unschedule_recurring_task with nonexistent key raises RecordNotFound" do assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.unschedule_task("nonexistent_key") + SolidQueue.unschedule_recurring_task("nonexistent_key") end end end diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index aed0c18c0..11c2a5ff2 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -21,7 +21,7 @@ class ConfigurationTest < ActiveSupport::TestCase test "default configuration when config given is empty" do configuration = SolidQueue::Configuration.new(config_file: config_file_path(:empty_configuration), recurring_schedule_file: config_file_path(:empty_configuration)) - assert_equal 3, configuration.configured_processes.count + assert_equal 2, configuration.configured_processes.count assert_processes configuration, :worker, 1, queues: "*" assert_processes configuration, :dispatcher, 1, batch_size: SolidQueue::Configuration::DISPATCHER_DEFAULTS[:batch_size] end @@ -134,12 +134,12 @@ class ConfigurationTest < ActiveSupport::TestCase configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_production_only)) assert configuration.valid? - assert_processes configuration, :scheduler, 1 + assert_processes configuration, :scheduler, 0 assert_output(/Provided configuration file '[^']+' does not exist\./) do configuration = SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_empty)) assert configuration.valid? - assert_processes configuration, :scheduler, 1 + assert_processes configuration, :scheduler, 0 end # No processes diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index f164bc1b1..b2572b18c 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -21,7 +21,7 @@ class SchedulerTest < ActiveSupport::TestCase SolidQueue::RecurringTask.create( key: "dynamic_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -40,7 +40,7 @@ class SchedulerTest < ActiveSupport::TestCase recurring_tasks = { static_task: { class: "AddToBufferJob", schedule: "every hour", args: 42 } } - scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks, dynamic_tasks: true).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -78,7 +78,7 @@ class SchedulerTest < ActiveSupport::TestCase key: "dynamic_enqueue_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) wait_while_with_timeout(3.seconds) { SolidQueue::Job.count < 1 } @@ -92,7 +92,7 @@ class SchedulerTest < ActiveSupport::TestCase end test "updates metadata after adding dynamic task post-start" do - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -124,7 +124,7 @@ class SchedulerTest < ActiveSupport::TestCase arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) From f7e39b5911cfe965a4d3b010138142763858fd69 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 14:54:52 +0100 Subject: [PATCH 77/85] Read static from options in from_configuration and rename to dynamic_tasks_enabled Have from_configuration read static from options instead of hardcoding it. Callers that create static tasks (YAML config) pass static: true via reverse_merge. schedule_recurring_task passes static: false directly. Rename the config key from dynamic_tasks to dynamic_tasks_enabled for clarity. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- app/models/solid_queue/recurring_task.rb | 4 ++-- lib/solid_queue.rb | 5 +---- lib/solid_queue/configuration.rb | 6 +++--- lib/solid_queue/scheduler.rb | 4 ++-- lib/solid_queue/scheduler/recurring_schedule.rb | 6 +++--- test/unit/scheduler_test.rb | 10 +++++----- 7 files changed, 17 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ecb675011..f4f28ba75 100644 --- a/README.md +++ b/README.md @@ -781,7 +781,7 @@ SolidQueue.unschedule_recurring_task("my_dynamic_task") Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error. -Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them. +Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them. ## Inspiration diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index d073a277f..79fbce8da 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -20,7 +20,7 @@ class RecurringTask < Record class << self def wrap(args) - args.is_a?(self) ? args : from_configuration(args.first, **args.second) + args.is_a?(self) ? args : from_configuration(args.first, **args.second.reverse_merge(static: true)) end def from_configuration(key, **options) @@ -33,7 +33,7 @@ def from_configuration(key, **options) queue_name: options[:queue].presence, priority: options[:priority].presence, description: options[:description], - static: true + static: !!options[:static] end def create_or_update_all(tasks) diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index af4b5a842..b46c1116e 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -44,10 +44,7 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor def schedule_recurring_task(key, **options) - RecurringTask.from_configuration(key, **options.except(:static)).tap do |task| - task.static = false - task.save! - end + RecurringTask.from_configuration(key, **options, static: false).tap(&:save!) end def unschedule_recurring_task(key) diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index d0a462656..c6dfbe777 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -30,7 +30,7 @@ def instantiate SCHEDULER_DEFAULTS = { polling_interval: 5, - dynamic_tasks: false + dynamic_tasks_enabled: false } DEFAULT_CONFIG_FILE_PATH = "config/queue.yml" @@ -166,12 +166,12 @@ def scheduler_options end def dynamic_tasks_enabled? - scheduler_options.fetch(:dynamic_tasks, SCHEDULER_DEFAULTS[:dynamic_tasks]) + scheduler_options.fetch(:dynamic_tasks_enabled, SCHEDULER_DEFAULTS[:dynamic_tasks_enabled]) end def recurring_tasks @recurring_tasks ||= recurring_tasks_config.map do |id, options| - RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule) + RecurringTask.from_configuration(id, **options.reverse_merge(static: true)) if options&.has_key?(:schedule) end.compact end diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index c224f2b87..d9ea5c929 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -15,9 +15,9 @@ class Scheduler < Processes::Base def initialize(recurring_tasks:, **options) options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS) - @dynamic_tasks = options[:dynamic_tasks] + @dynamic_tasks = options[:dynamic_tasks_enabled] @polling_interval = options[:polling_interval] - @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks: @dynamic_tasks) + @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks_enabled: @dynamic_tasks) super(**options) end diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 8b5582716..6db997ca8 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -6,11 +6,11 @@ class Scheduler::RecurringSchedule attr_reader :scheduled_tasks - def initialize(tasks, dynamic_tasks: false) - @static_tasks = Array(tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) + def initialize(static_tasks, dynamic_tasks_enabled: false) + @static_tasks = Array(static_tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) @scheduled_tasks = Concurrent::Hash.new @changes = Concurrent::Hash.new - @dynamic_tasks_enabled = dynamic_tasks + @dynamic_tasks_enabled = dynamic_tasks_enabled end def configured_tasks diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index b2572b18c..56aaeb2c6 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -21,7 +21,7 @@ class SchedulerTest < ActiveSupport::TestCase SolidQueue::RecurringTask.create( key: "dynamic_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks_enabled: true).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -40,7 +40,7 @@ class SchedulerTest < ActiveSupport::TestCase recurring_tasks = { static_task: { class: "AddToBufferJob", schedule: "every hour", args: 42 } } - scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks, dynamic_tasks: true).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks, dynamic_tasks_enabled: true).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -78,7 +78,7 @@ class SchedulerTest < ActiveSupport::TestCase key: "dynamic_enqueue_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks_enabled: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) wait_while_with_timeout(3.seconds) { SolidQueue::Job.count < 1 } @@ -92,7 +92,7 @@ class SchedulerTest < ActiveSupport::TestCase end test "updates metadata after adding dynamic task post-start" do - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks_enabled: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) @@ -124,7 +124,7 @@ class SchedulerTest < ActiveSupport::TestCase arguments: [ 42 ] ) - scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks: true, polling_interval: 0.1).tap(&:start) + scheduler = SolidQueue::Scheduler.new(recurring_tasks: {}, dynamic_tasks_enabled: true, polling_interval: 0.1).tap(&:start) wait_for_registered_processes(1, timeout: 1.second) From b012535ae46acba69b74be1a248a43d598e2f6d7 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 15:29:18 +0100 Subject: [PATCH 78/85] Refactor scheduler loop and add missing tests Keep the longer sleep interval when dynamic tasks are disabled. Add tests for `dynamic_tasks_enabled` opt-in in configuration and for verifying dynamic tasks are ignored when not enabled. --- app/models/solid_queue/recurring_task.rb | 4 +- lib/solid_queue/configuration.rb | 6 +-- lib/solid_queue/scheduler.rb | 52 ++++++++++++------------ test/unit/configuration_test.rb | 18 ++++++++ test/unit/scheduler_test.rb | 16 ++++++++ 5 files changed, 66 insertions(+), 30 deletions(-) diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index 79fbce8da..7d719d6be 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -20,7 +20,7 @@ class RecurringTask < Record class << self def wrap(args) - args.is_a?(self) ? args : from_configuration(args.first, **args.second.reverse_merge(static: true)) + args.is_a?(self) ? args : from_configuration(args.first, **args.second) end def from_configuration(key, **options) @@ -33,7 +33,7 @@ def from_configuration(key, **options) queue_name: options[:queue].presence, priority: options[:priority].presence, description: options[:description], - static: !!options[:static] + static: options.fetch(:static, true) end def create_or_update_all(tasks) diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index c6dfbe777..3971ef82d 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -144,8 +144,8 @@ def dispatchers def schedulers return [] if skip_recurring_tasks? - if recurring_tasks.any? || dynamic_tasks_enabled? - [ Process.new(:scheduler, { recurring_tasks:, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] + if recurring_tasks.any? || dynamic_recurring_tasks_enabled? + [ Process.new(:scheduler, { recurring_tasks: recurring_tasks, **scheduler_options.with_defaults(SCHEDULER_DEFAULTS) }) ] else [] end @@ -165,7 +165,7 @@ def scheduler_options @scheduler_options ||= processes_config.fetch(:scheduler, {}).dup.symbolize_keys end - def dynamic_tasks_enabled? + def dynamic_recurring_tasks_enabled? scheduler_options.fetch(:dynamic_tasks_enabled, SCHEDULER_DEFAULTS[:dynamic_tasks_enabled]) end diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index d9ea5c929..a7f7e085a 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -15,9 +15,9 @@ class Scheduler < Processes::Base def initialize(recurring_tasks:, **options) options = options.dup.with_defaults(SolidQueue::Configuration::SCHEDULER_DEFAULTS) - @dynamic_tasks = options[:dynamic_tasks_enabled] + @dynamic_tasks_enabled = options[:dynamic_tasks_enabled] @polling_interval = options[:polling_interval] - @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks_enabled: @dynamic_tasks) + @recurring_schedule = RecurringSchedule.new(recurring_tasks, dynamic_tasks_enabled: @dynamic_tasks_enabled) super(**options) end @@ -27,35 +27,20 @@ def metadata end private - attr_reader :dynamic_tasks - def run - if dynamic_tasks - poll_for_dynamic_tasks - else - loop do - break if shutting_down? - interruptible_sleep(polling_interval) - end - end - ensure - SolidQueue.instrument(:shutdown_process, process: self) do - run_callbacks(:shutdown) { shutdown } - end - end + STATIC_SLEEP_INTERVAL = 60 - def poll_for_dynamic_tasks + def run loop do break if shutting_down? - recurring_schedule.reload! - - if recurring_schedule.changed? - refresh_registered_process - recurring_schedule.clear_changes - end + reload_schedule if dynamic_tasks_enabled? - interruptible_sleep(polling_interval) + interruptible_sleep(sleep_interval) + end + ensure + SolidQueue.instrument(:shutdown_process, process: self) do + run_callbacks(:shutdown) { shutdown } end end @@ -67,10 +52,27 @@ def unschedule_recurring_tasks recurring_schedule.unschedule_tasks end + def reload_schedule + recurring_schedule.reload! + + if recurring_schedule.changed? + refresh_registered_process + recurring_schedule.clear_changes + end + end + + def dynamic_tasks_enabled? + @dynamic_tasks_enabled + end + def all_work_completed? recurring_schedule.empty? end + def sleep_interval + dynamic_tasks_enabled? ? polling_interval : STATIC_SLEEP_INTERVAL + end + def set_procline procline "scheduling #{recurring_schedule.task_keys.join(",")}" end diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index 11c2a5ff2..34f69658b 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -87,6 +87,24 @@ class ConfigurationTest < ActiveSupport::TestCase assert_has_recurring_task scheduler, key: "periodic_store_result", class_name: "StoreResultJob", schedule: "every second" end + test "scheduler starts with dynamic_tasks_enabled even without static tasks" do + configuration = SolidQueue::Configuration.new( + recurring_schedule_file: config_file_path(:empty_configuration), + scheduler: { dynamic_tasks_enabled: true } + ) + + assert_processes configuration, :scheduler, 1, dynamic_tasks_enabled: true + end + + test "no scheduler without static tasks or dynamic_tasks_enabled" do + configuration = SolidQueue::Configuration.new( + recurring_schedule_file: config_file_path(:empty_configuration), + scheduler: { dynamic_tasks_enabled: false } + ) + + assert_processes configuration, :scheduler, 0 + end + test "no recurring tasks configuration when explicitly excluded" do configuration = SolidQueue::Configuration.new(dispatchers: [ { polling_interval: 0.1 } ], skip_recurring: true) assert_processes configuration, :dispatcher, 1, polling_interval: 0.1, recurring_tasks: nil diff --git a/test/unit/scheduler_test.rb b/test/unit/scheduler_test.rb index 56aaeb2c6..e914a23ca 100644 --- a/test/unit/scheduler_test.rb +++ b/test/unit/scheduler_test.rb @@ -52,6 +52,22 @@ class SchedulerTest < ActiveSupport::TestCase scheduler.stop end + test "dynamic tasks in DB are ignored when dynamic_tasks_enabled is false" do + SolidQueue::RecurringTask.create!( + key: "ignored_task", static: false, class_name: "AddToBufferJob", schedule: "every second", arguments: [ 42 ] + ) + + recurring_tasks = { static_task: { class: "AddToBufferJob", schedule: "every hour", args: 42 } } + scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_tasks).tap(&:start) + + wait_for_registered_processes(1, timeout: 1.second) + + process = SolidQueue::Process.first + assert_metadata process, recurring_schedule: [ "static_task" ] + ensure + scheduler.stop + end + test "run more than one instance of the scheduler with recurring tasks" do recurring_tasks = { example_task: { class: "AddToBufferJob", schedule: "every second", args: 42 } } schedulers = 2.times.collect do From 3d36ca4acb327cc51dd5da5c57f4b2d2951c7c8a Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 16:03:56 +0100 Subject: [PATCH 79/85] Simplify recurring schedule with respect to dynamic task reloading We don't need to track dynamic task changes, we can just reload the metadata in every case. If it hasn't changed, Rails won't issue any new update to the process record. Also, we can just use `dynamic_tasks` everywhere, as an empty AR relation if dynamic tasks are disabled, avoiding extra queries but keeping the code simpler. --- lib/solid_queue/processes/registrable.rb | 4 +- lib/solid_queue/scheduler.rb | 12 ++-- .../scheduler/recurring_schedule.rb | 70 +++++++------------ 3 files changed, 30 insertions(+), 56 deletions(-) diff --git a/lib/solid_queue/processes/registrable.rb b/lib/solid_queue/processes/registrable.rb index 94e9ed77f..35b4e01bd 100644 --- a/lib/solid_queue/processes/registrable.rb +++ b/lib/solid_queue/processes/registrable.rb @@ -60,8 +60,8 @@ def heartbeat wake_up end - def refresh_registered_process - wrap_in_app_executor { process&.update_columns(metadata: metadata.compact) } + def reload_metadata + wrap_in_app_executor { process&.update(metadata: metadata.compact) } end end end diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index a7f7e085a..789a6c13c 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -34,7 +34,7 @@ def run loop do break if shutting_down? - reload_schedule if dynamic_tasks_enabled? + reload_dynamic_schedule if dynamic_tasks_enabled? interruptible_sleep(sleep_interval) end @@ -52,13 +52,9 @@ def unschedule_recurring_tasks recurring_schedule.unschedule_tasks end - def reload_schedule - recurring_schedule.reload! - - if recurring_schedule.changed? - refresh_registered_process - recurring_schedule.clear_changes - end + def reload_dynamic_schedule + recurring_schedule.reload_dynamic_tasks + reload_metadata end def dynamic_tasks_enabled? diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index 6db997ca8..ee6a174cd 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -7,26 +7,18 @@ class Scheduler::RecurringSchedule attr_reader :scheduled_tasks def initialize(static_tasks, dynamic_tasks_enabled: false) - @static_tasks = Array(static_tasks).map { |task| SolidQueue::RecurringTask.wrap(task) }.select(&:valid?) - @scheduled_tasks = Concurrent::Hash.new - @changes = Concurrent::Hash.new + @static_tasks = Array(static_tasks).map { |task| RecurringTask.wrap(task) }.select(&:valid?) @dynamic_tasks_enabled = dynamic_tasks_enabled + + @scheduled_tasks = Concurrent::Hash.new end def configured_tasks - if dynamic_tasks_enabled? - static_tasks + dynamic_tasks.to_a - else - static_tasks - end + static_tasks + dynamic_tasks end def empty? - if dynamic_tasks_enabled? - scheduled_tasks.empty? && dynamic_tasks.none? - else - scheduled_tasks.empty? - end + scheduled_tasks.empty? && dynamic_tasks.empty? end def schedule_tasks @@ -50,69 +42,55 @@ def unschedule_tasks end def task_keys - if dynamic_tasks_enabled? - static_task_keys + dynamic_tasks.pluck(:key) - else - static_task_keys - end + static_task_keys + dynamic_task_keys end - def reload! + def reload_dynamic_tasks wrap_in_app_executor do - { added_tasks: schedule_new_dynamic_tasks, - removed_tasks: unschedule_old_dynamic_tasks }.each do |key, values| - if values.any? - @changes[key] = values - else - @changes.delete(key) - end - end + schedule_created_dynamic_tasks + unschedule_deleted_dynamic_tasks end end - def changed? - @changes.any? - end - - def clear_changes - @changes.clear - end - private attr_reader :static_tasks - def dynamic_tasks_enabled? - @dynamic_tasks_enabled + def static_task_keys + static_tasks.map(&:key) end def dynamic_tasks - SolidQueue::RecurringTask.dynamic + dynamic_tasks_enabled? ? RecurringTask.dynamic : RecurringTask.none end - def static_task_keys - static_tasks.map(&:key) + def dynamic_task_keys + dynamic_tasks.pluck(:key) + end + + def dynamic_tasks_enabled? + @dynamic_tasks_enabled end - def schedule_new_dynamic_tasks + def schedule_created_dynamic_tasks dynamic_tasks.where.not(key: scheduled_tasks.keys).each do |task| schedule_task(task) end end - def unschedule_old_dynamic_tasks - (scheduled_tasks.keys - SolidQueue::RecurringTask.pluck(:key)).each do |key| + def unschedule_deleted_dynamic_tasks + (scheduled_tasks.keys - RecurringTask.pluck(:key)).each do |key| scheduled_tasks[key].cancel scheduled_tasks.delete(key) end end def persist_static_tasks - SolidQueue::RecurringTask.static.where.not(key: static_task_keys).delete_all - SolidQueue::RecurringTask.create_or_update_all static_tasks + RecurringTask.static.where.not(key: static_task_keys).delete_all + RecurringTask.create_or_update_all static_tasks end def reload_static_tasks - @static_tasks = SolidQueue::RecurringTask.static.where(key: static_task_keys).to_a + @static_tasks = RecurringTask.static.where(key: static_task_keys).to_a end def schedule(task) From b4f7c99ac3a1c2436e2accbad737eedeba94dbc3 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 16:13:02 +0100 Subject: [PATCH 80/85] Clean a bit top-level SolidQueue methods to manage dynamic tasks --- app/models/solid_queue/recurring_task.rb | 8 ++++++++ lib/solid_queue.rb | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index 7d719d6be..943f01b8b 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -36,6 +36,14 @@ def from_configuration(key, **options) static: options.fetch(:static, true) end + def create_dynamic_task(key, **options) + from_configuration(key, **options.reverse_merge(static: false)).save! + end + + def delete_dynamic_task(key) + RecurringTask.dynamic.find_by!(key: key).destroy + end + def create_or_update_all(tasks) if supports_insert_conflict_target? # PostgreSQL fails and aborts the current transaction when it hits a duplicate key conflict diff --git a/lib/solid_queue.rb b/lib/solid_queue.rb index b46c1116e..bd2269e5f 100644 --- a/lib/solid_queue.rb +++ b/lib/solid_queue.rb @@ -44,11 +44,11 @@ module SolidQueue delegate :on_start, :on_stop, :on_exit, to: Supervisor def schedule_recurring_task(key, **options) - RecurringTask.from_configuration(key, **options, static: false).tap(&:save!) + RecurringTask.create_dynamic_task(key, **options) end def unschedule_recurring_task(key) - RecurringTask.dynamic.find_by!(key:).destroy + RecurringTask.delete_dynamic_task(key) end [ Dispatcher, Scheduler, Worker ].each do |process| From 7ded7101b97b41552a241ba3b3b562b961250a96 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 16:44:48 +0100 Subject: [PATCH 81/85] Avoid extra queries for dynamic tasks for process metadata and procline We need the dynamic task keys there and were doing a new query every time. We only need to do a query when explicitly reloading them. --- lib/solid_queue/scheduler.rb | 2 +- .../scheduler/recurring_schedule.rb | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/solid_queue/scheduler.rb b/lib/solid_queue/scheduler.rb index 789a6c13c..6022b3382 100644 --- a/lib/solid_queue/scheduler.rb +++ b/lib/solid_queue/scheduler.rb @@ -53,7 +53,7 @@ def unschedule_recurring_tasks end def reload_dynamic_schedule - recurring_schedule.reload_dynamic_tasks + recurring_schedule.reschedule_dynamic_tasks reload_metadata end diff --git a/lib/solid_queue/scheduler/recurring_schedule.rb b/lib/solid_queue/scheduler/recurring_schedule.rb index ee6a174cd..a1e2409e0 100644 --- a/lib/solid_queue/scheduler/recurring_schedule.rb +++ b/lib/solid_queue/scheduler/recurring_schedule.rb @@ -25,6 +25,7 @@ def schedule_tasks wrap_in_app_executor do persist_static_tasks reload_static_tasks + reload_dynamic_tasks end configured_tasks.each do |task| @@ -42,11 +43,12 @@ def unschedule_tasks end def task_keys - static_task_keys + dynamic_task_keys + configured_tasks.map(&:key) end - def reload_dynamic_tasks + def reschedule_dynamic_tasks wrap_in_app_executor do + reload_dynamic_tasks schedule_created_dynamic_tasks unschedule_deleted_dynamic_tasks end @@ -60,11 +62,7 @@ def static_task_keys end def dynamic_tasks - dynamic_tasks_enabled? ? RecurringTask.dynamic : RecurringTask.none - end - - def dynamic_task_keys - dynamic_tasks.pluck(:key) + @dynamic_tasks ||= load_dynamic_tasks end def dynamic_tasks_enabled? @@ -72,7 +70,7 @@ def dynamic_tasks_enabled? end def schedule_created_dynamic_tasks - dynamic_tasks.where.not(key: scheduled_tasks.keys).each do |task| + RecurringTask.dynamic.where.not(key: scheduled_tasks.keys).each do |task| schedule_task(task) end end @@ -93,6 +91,14 @@ def reload_static_tasks @static_tasks = RecurringTask.static.where(key: static_task_keys).to_a end + def reload_dynamic_tasks + @dynamic_tasks = load_dynamic_tasks + end + + def load_dynamic_tasks + dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : [] + end + def schedule(task) scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at| thread_schedule.schedule_task(thread_task) From 3b490add2cd4de885269ff31682ec1519758ffb9 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 25 Feb 2026 18:48:26 +0100 Subject: [PATCH 82/85] Merge right value of `static` correctly We want to set that value overriding whatever we get in options, so it should be merged into options, not the other way around. Co-Authored-By: Vladyslav Davydenko --- app/models/solid_queue/recurring_task.rb | 2 +- lib/solid_queue/configuration.rb | 2 +- .../models/solid_queue/recurring_task_test.rb | 50 +++++++++++++++++++ test/solid_queue_test.rb | 50 ------------------- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/app/models/solid_queue/recurring_task.rb b/app/models/solid_queue/recurring_task.rb index 943f01b8b..9bb634e6f 100644 --- a/app/models/solid_queue/recurring_task.rb +++ b/app/models/solid_queue/recurring_task.rb @@ -37,7 +37,7 @@ def from_configuration(key, **options) end def create_dynamic_task(key, **options) - from_configuration(key, **options.reverse_merge(static: false)).save! + from_configuration(key, **options.merge(static: false)).save! end def delete_dynamic_task(key) diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 3971ef82d..e63a000ca 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -171,7 +171,7 @@ def dynamic_recurring_tasks_enabled? def recurring_tasks @recurring_tasks ||= recurring_tasks_config.map do |id, options| - RecurringTask.from_configuration(id, **options.reverse_merge(static: true)) if options&.has_key?(:schedule) + RecurringTask.from_configuration(id, **options.merge(static: true)) if options&.has_key?(:schedule) end.compact end diff --git a/test/models/solid_queue/recurring_task_test.rb b/test/models/solid_queue/recurring_task_test.rb index 52312f12c..dba9d6b91 100644 --- a/test/models/solid_queue/recurring_task_test.rb +++ b/test/models/solid_queue/recurring_task_test.rb @@ -210,6 +210,56 @@ def perform assert_equal "SolidQueue::RecurringJob", SolidQueue::Job.last.class_name end + test "schedule recurring tasks dynamically" do + SolidQueue::RecurringTask.create_dynamic_task("test 1", command: "puts 1", schedule: "every hour") + SolidQueue::RecurringTask.create_dynamic_task("test 2", command: "puts 2", schedule: "every minute", static: true) + + assert SolidQueue::RecurringTask.exists?(key: "test 1", command: "puts 1", schedule: "every hour", static: false) + assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) + end + + test "schedule recurring tasks dynamically with class and args (same keys as YAML config)" do + SolidQueue::RecurringTask.create_dynamic_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") + + task = SolidQueue::RecurringTask.find_by!(key: "test 3") + assert_equal "AddToBufferJob", task.class_name + assert_equal [ 42 ], task.arguments + assert_not task.static + end + + test "unschedule recurring tasks dynamically" do + dynamic_task = SolidQueue::RecurringTask.create!( + key: "dynamic", command: "puts 'd'", schedule: "every day", static: false + ) + + static_task = SolidQueue::RecurringTask.create!( + key: "static", command: "puts 's'", schedule: "every week", static: true + ) + + SolidQueue::RecurringTask.delete_dynamic_task(dynamic_task.key) + + assert_raises(ActiveRecord::RecordNotFound) do + SolidQueue::RecurringTask.delete_dynamic_task(static_task.key) + end + + assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) + assert SolidQueue::RecurringTask.exists?(key: "static", static: true) + end + + test "scheduling dynamic recurring task with duplicate key raises error" do + SolidQueue::RecurringTask.create_dynamic_task("duplicate_test", command: "puts 1", schedule: "every hour") + + assert_raises(ActiveRecord::RecordNotUnique) do + SolidQueue::RecurringTask.create_dynamic_task("duplicate_test", command: "puts 2", schedule: "every minute") + end + end + + test "unscheduling dynamic recurring task with nonexistent key raises RecordNotFound" do + assert_raises(ActiveRecord::RecordNotFound) do + SolidQueue::RecurringTask.delete_dynamic_task("nonexistent_key") + end + end + private def enqueue_and_assert_performed_with_result(task, result) assert_difference [ -> { SolidQueue::Job.count }, -> { SolidQueue::ReadyExecution.count } ], +1 do diff --git a/test/solid_queue_test.rb b/test/solid_queue_test.rb index 768b9c3c5..d6d61b576 100644 --- a/test/solid_queue_test.rb +++ b/test/solid_queue_test.rb @@ -4,54 +4,4 @@ class SolidQueueTest < ActiveSupport::TestCase test "it has a version number" do assert SolidQueue::VERSION end - - test "schedules recurring tasks" do - SolidQueue.schedule_recurring_task("test 1", command: "puts 1", schedule: "every hour") - SolidQueue.schedule_recurring_task("test 2", command: "puts 2", schedule: "every minute", static: true) - - assert SolidQueue::RecurringTask.exists?(key: "test 1", command: "puts 1", schedule: "every hour", static: false) - assert SolidQueue::RecurringTask.exists?(key: "test 2", command: "puts 2", schedule: "every minute", static: false) - end - - test "schedules recurring tasks with class and args (same keys as YAML config)" do - SolidQueue.schedule_recurring_task("test 3", class: "AddToBufferJob", args: [ 42 ], schedule: "every hour") - - task = SolidQueue::RecurringTask.find_by!(key: "test 3") - assert_equal "AddToBufferJob", task.class_name - assert_equal [ 42 ], task.arguments - assert_equal false, task.static - end - - test "unschedules recurring tasks" do - dynamic_task = SolidQueue::RecurringTask.create!( - key: "dynamic", command: "puts 'd'", schedule: "every day", static: false - ) - - static_task = SolidQueue::RecurringTask.create!( - key: "static", command: "puts 's'", schedule: "every week", static: true - ) - - SolidQueue.unschedule_recurring_task(dynamic_task.key) - - assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.unschedule_recurring_task(static_task.key) - end - - assert_not SolidQueue::RecurringTask.exists?(key: "dynamic", static: false) - assert SolidQueue::RecurringTask.exists?(key: "static", static: true) - end - - test "schedule_recurring_task with duplicate key raises error" do - SolidQueue.schedule_recurring_task("duplicate_test", command: "puts 1", schedule: "every hour") - - assert_raises(ActiveRecord::RecordNotUnique) do - SolidQueue.schedule_recurring_task("duplicate_test", command: "puts 2", schedule: "every minute") - end - end - - test "unschedule_recurring_task with nonexistent key raises RecordNotFound" do - assert_raises(ActiveRecord::RecordNotFound) do - SolidQueue.unschedule_recurring_task("nonexistent_key") - end - end end From 50defe25e298e42a2acc429d294b62cb0f3b3cf7 Mon Sep 17 00:00:00 2001 From: genya0407 Date: Mon, 2 Mar 2026 13:02:42 +0900 Subject: [PATCH 83/85] Avoid unintended FOR UPDATE query execution. Without `#to_a`, `select_candidates` returns an `ActiveRecord::Relation`. Consequently, `executeions.none?` inside the `lock_candidates` method triggers an unintended query: ``` SELECT 1 AS one FROM `solid_queue_ready_executions` LIMIT 1 FOR UPDATE SKIP LOCKED; ``` --- app/models/solid_queue/ready_execution.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/solid_queue/ready_execution.rb b/app/models/solid_queue/ready_execution.rb index 35a11292c..ea14c193a 100644 --- a/app/models/solid_queue/ready_execution.rb +++ b/app/models/solid_queue/ready_execution.rb @@ -30,7 +30,8 @@ def select_and_lock(queue_relation, process_id, limit) end def select_candidates(queue_relation, limit) - queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id) + # Force query execution here with #to_a to avoid unintended FOR UPDATE query executions + queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id).to_a end def lock_candidates(executions, process_id) From 2f142ed9aa72f77289876e323cc30d6a943ca437 Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:47:53 +1300 Subject: [PATCH 84/85] docs: improve grammar a little --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b49f9c60d..fb37f3360 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,7 @@ class MyJob < ApplicationJob - `group` is used to control the concurrency of different job classes together. It defaults to the job class name. - `on_conflict` controls behaviour when enqueuing a job that conflicts with the concurrency limits configured. It can be set to one of the following: - (default) `:block`: the job is blocked and is dispatched when another job completes and unblocks it, or when the duration expires. - - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded up to the interval defined by `duration` has elapsed. + - `:discard`: the job is discarded. When you choose this option, bear in mind that if a job runs and fails to remove the concurrency lock (or _semaphore_, read below to know more about this), all jobs conflicting with it will be discarded until the interval defined by `duration` has elapsed. When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping). @@ -472,7 +472,7 @@ Since something can happen that prevents the first job from releasing the semaph It's important to note that after one or more candidate jobs are unblocked (either because a job finishes or because `duration` expires and a semaphore is released), the `duration` timer for the still blocked jobs is reset. This happens indirectly via the expiration time of the semaphore, which is updated. -When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for up to the `duration` interval if something happens and a running job fails to release the semaphore. +When using `discard` as the behaviour to handle conflicts, you might have jobs discarded for until the `duration` interval if something happens and a running job fails to release the semaphore. For example: From 176721e33f542e07923fe02964cd55d2c18b4389 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 20 Mar 2026 19:07:34 +0100 Subject: [PATCH 85/85] Bump solid_queue to 1.4.0 --- Gemfile.lock | 2 +- lib/solid_queue/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 772a99da7..7c4662de5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_queue (1.3.2) + solid_queue (1.4.0) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) diff --git a/lib/solid_queue/version.rb b/lib/solid_queue/version.rb index 4f0b806b9..8514496c3 100644 --- a/lib/solid_queue/version.rb +++ b/lib/solid_queue/version.rb @@ -1,3 +1,3 @@ module SolidQueue - VERSION = "1.3.2" + VERSION = "1.4.0" end