From 3f2539ade6b3762062231c8e38e274bf50e1f785 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Fri, 17 Jul 2026 17:09:33 +0200 Subject: [PATCH] add bin/jobs check to validate config before starting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets you catch broken recurring.yml etc before the supervisor boots and silently exits. Works as `bin/jobs check` or the `solid_queue:check` rake task. Skips the DB-pool check when DB creds aren't around (for isntance on CI). See #722 for more info PS: 🐶 <- this is mochi! --- README.md | 17 +++++++++++ lib/solid_queue/cli.rb | 6 ++++ lib/solid_queue/configuration.rb | 32 +++++++++++++++++--- lib/solid_queue/supervisor.rb | 2 +- lib/solid_queue/tasks.rb | 5 ++++ test/unit/cli_test.rb | 35 ++++++++++++++++++++++ test/unit/configuration_test.rb | 48 ++++++++++++++++++++++-------- test/unit/rake_tasks_test.rb | 50 ++++++++++++++++++++++++++++++++ 8 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 test/unit/rake_tasks_test.rb diff --git a/README.md b/README.md index 6ab58ecef..30e4bf35c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite, - [Threads, processes, and signals](#threads-processes-and-signals) - [Database configuration](#database-configuration) - [Other configuration settings](#other-configuration-settings) + - [Validating the configuration](#validating-the-configuration) - [Lifecycle hooks](#lifecycle-hooks) - [Errors when enqueuing](#errors-when-enqueuing) - [Concurrency controls](#concurrency-controls) @@ -408,6 +409,22 @@ There are several settings that control how Solid Queue works that you can set a - `clear_finished_jobs_after`: period to keep finished jobs around, in case `preserve_finished_jobs` is true — defaults to 1 day. When installing Solid Queue, [a recurring job](#recurring-tasks) is automatically configured to clear finished jobs every hour on the 12th minute in batches. You can edit the `recurring.yml` configuration to change this as you see fit. - `default_concurrency_control_period`: the value to be used as the default for the `duration` parameter in [concurrency controls](#concurrency-controls). It defaults to 3 minutes. +### Validating the configuration + +You can validate the Solid Queue configuration ahead of time, without starting any process. This is handy in deploy scripts or CI to catch mistakes—a typo in `recurring.yml`, no processes configured, and so on—before they cause a supervisor to boot into a broken state: + +```bash +# Using the bin/jobs binstub +bin/jobs check + +# Or via rake +bin/rails solid_queue:check +``` + +Both commands validate the configuration for the current Rails environment. On success they print `Solid Queue configuration is valid.` and exit `0`; otherwise they print the errors and exit non-zero. When the number of threads is larger than the [database connection pool](#database-configuration), they also print an advisory warning about it—the same one the supervisor logs on boot. They're tolerant of a missing database connection, so they can run on CI or deploy hosts without database credentials. + +`bin/jobs check` accepts the same options as `bin/jobs start` (e.g. `--config_file`, `--recurring_schedule_file`, `--skip-recurring`). The rake task honors the same environment variables Solid Queue already uses: `SOLID_QUEUE_CONFIG`, `SOLID_QUEUE_RECURRING_SCHEDULE`, and `SOLID_QUEUE_SKIP_RECURRING`. To validate a specific environment's configuration, set `RAILS_ENV`, for example `RAILS_ENV=production bin/jobs check`. + ## Lifecycle hooks diff --git a/lib/solid_queue/cli.rb b/lib/solid_queue/cli.rb index 2f3f3e13b..11be9d12a 100644 --- a/lib/solid_queue/cli.rb +++ b/lib/solid_queue/cli.rb @@ -30,5 +30,11 @@ def self.exit_on_failure? def start SolidQueue::Supervisor.start(**options.symbolize_keys) end + + desc :check, "Validates the Solid Queue configuration for the current Rails env without starting anything. Exits non-zero on errors." + def check + configuration = SolidQueue::Configuration.new(**options.symbolize_keys) + exit 1 unless configuration.check + end end end diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 300bfd100..b547d12c5 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -68,16 +68,40 @@ def standalone? mode.fork? || @options[:standalone] end - def warn_about_undersized_thread_pool - if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads - SolidQueue.logger.warn "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " + - "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`" + def warnings + @warnings ||= [ undersized_thread_pool_message ].compact + end + + def check + warnings.each { |warning| $stdout.puts "Warning: #{warning}" } + + if valid? + $stdout.puts "Solid Queue configuration is valid." + true + else + $stderr.puts "Invalid Solid Queue configuration:" + errors.full_messages.each do |message| + message.each_line { |line| $stderr.puts " #{line.chomp}" } + end + false end end private attr_reader :options + def undersized_thread_pool_message + db_pool_size = SolidQueue::Record.connection_pool&.size + + if db_pool_size && db_pool_size < estimated_number_of_threads + "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " \ + "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`" + end + rescue ActiveRecord::ActiveRecordError + # No usable database connection. Skip the pool-size warning in that case. + nil + end + def ensure_configured_processes unless configured_processes.any? errors.add(:base, "No processes configured") diff --git a/lib/solid_queue/supervisor.rb b/lib/solid_queue/supervisor.rb index 695a066a4..ffd4d3bd9 100644 --- a/lib/solid_queue/supervisor.rb +++ b/lib/solid_queue/supervisor.rb @@ -13,7 +13,7 @@ def start(**options) configuration = Configuration.new(**options) if configuration.valid? - configuration.warn_about_undersized_thread_pool + configuration.warnings.each { |warning| SolidQueue.logger.warn(warning) } klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor klass.new(configuration).tap(&:start) diff --git a/lib/solid_queue/tasks.rb b/lib/solid_queue/tasks.rb index 91cd778bf..026cb42e2 100644 --- a/lib/solid_queue/tasks.rb +++ b/lib/solid_queue/tasks.rb @@ -8,4 +8,9 @@ task start: :environment do SolidQueue::Supervisor.start end + + desc "validate the Solid Queue configuration for the current Rails env without starting any process" + task check: :environment do + exit 1 unless SolidQueue::Configuration.new.check + end end diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb index 9ee0e233a..12e97dcf6 100644 --- a/test/unit/cli_test.rb +++ b/test/unit/cli_test.rb @@ -36,6 +36,27 @@ class CliTest < ActiveSupport::TestCase end end + test "check exits 0 and prints OK message for a valid configuration" do + out, err = capture_io do + assert_nothing_raised { SolidQueue::Cli.start([ "check", "--skip-recurring" ]) } + end + + assert_match "Solid Queue configuration is valid.", out + assert_empty err + end + + test "check exits 1 and prints invalid recurring task errors" do + out, err, exit_status = capture_check_run( + "--recurring_schedule_file", config_file_path(:recurring_with_invalid).to_s + ) + + assert_equal 1, exit_status + assert_match "Invalid Solid Queue configuration", err + assert_match "periodic_invalid_class", err + assert_match "periodic_incorrect_schedule", err + assert_empty out + end + private def configuration_from_cli(**cli_options) cli = SolidQueue::Cli.new([], cli_options) @@ -43,4 +64,18 @@ def configuration_from_cli(**cli_options) SolidQueue::Configuration.new(**options) end + + # capture_io re-raises SystemExit before returning the captured strings, so we + # swallow it inside the block and return its status alongside the captured IO. + def capture_check_run(*args) + status = nil + out, err = capture_io do + begin + SolidQueue::Cli.start([ "check", *args ]) + rescue SystemExit => e + status = e.status + end + end + [ out, err, status ] + end end diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index bf2c6b8ec..aeec5e5b2 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -170,24 +170,48 @@ class ConfigurationTest < ActiveSupport::TestCase assert configuration.valid? end - test "warns on boot when the database pool is smaller than the thread pool" do - log = StringIO.new - with_solid_queue_logger(ActiveSupport::Logger.new(log)) do - configuration = SolidQueue::Configuration.new(workers: [ { queues: "background", threads: 50, polling_interval: 10 } ]) - configuration.warn_about_undersized_thread_pool + test "reports an undersized thread pool as a warning rather than an error" do + configuration = SolidQueue::Configuration.new(workers: [ { queues: "background", threads: 50, polling_interval: 10 } ], skip_recurring: true) + + assert configuration.valid? + assert_equal 1, configuration.warnings.size + assert_match /Solid Queue is configured to use \d+ threads but the database connection pool is \d+\. Increase it in `config\/database.yml`/, configuration.warnings.first + end + + test "has no warnings when the database connection pool is large enough" do + configuration = SolidQueue::Configuration.new(workers: [ { queues: "background", threads: 1, polling_interval: 10 } ], skip_recurring: true) + + assert_empty configuration.warnings + end + + test "check prints a success message and returns true for a valid configuration" do + out, err = capture_io do + assert SolidQueue::Configuration.new(skip_recurring: true).check end - assert_match /Solid Queue is configured to use \d+ threads but the database connection pool is \d+\. Increase it in `config\/database.yml`/, log.string + assert_match "Solid Queue configuration is valid.", out + assert_empty err end - private - def with_solid_queue_logger(logger) - old_logger, SolidQueue.logger = SolidQueue.logger, logger - yield - ensure - SolidQueue.logger = old_logger + test "check prints warnings to stdout on the valid path" do + out, _err = capture_io do + assert SolidQueue::Configuration.new(workers: [ { queues: "background", threads: 50, polling_interval: 10 } ], skip_recurring: true).check end + assert_match "Solid Queue configuration is valid.", out + assert_match /Warning: Solid Queue is configured to use \d+ threads but the database connection pool is \d+/, out + end + + test "check prints errors to stderr and returns false for an invalid configuration" do + out, err = capture_io do + assert_not SolidQueue::Configuration.new(recurring_schedule_file: config_file_path(:recurring_with_invalid)).check + end + + assert_match "Invalid Solid Queue configuration:", err + assert_match "periodic_invalid_class", err + end + + private def assert_processes(configuration, kind, count, **attributes) processes = configuration.configured_processes.select { |p| p.kind == kind } assert_equal count, processes.size diff --git a/test/unit/rake_tasks_test.rb b/test/unit/rake_tasks_test.rb new file mode 100644 index 000000000..8b2de1433 --- /dev/null +++ b/test/unit/rake_tasks_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "test_helper" +require "rake" + +class RakeTasksTest < ActiveSupport::TestCase + setup do + @previous_rake_application = Rake.application + @rake = Rake::Application.new + Rake.application = @rake + Rake::Task.define_task(:environment) + load File.expand_path("../../lib/solid_queue/tasks.rb", __dir__) + end + + teardown do + Rake.application = @previous_rake_application + end + + test "solid_queue:check exits 0 and prints OK message for a valid configuration" do + SolidQueue::Configuration.any_instance.stubs(:skip_recurring_tasks?).returns(true) + + out, err = capture_io do + assert_nothing_raised { @rake["solid_queue:check"].invoke } + end + + assert_match "Solid Queue configuration is valid.", out + assert_empty err + end + + test "solid_queue:check exits 1 and prints errors for an invalid configuration" do + SolidQueue::Configuration.any_instance.stubs(:invalid_tasks).returns( + [ stub(key: "broken", errors: stub(full_messages: [ "is invalid" ])) ] + ) + SolidQueue::Configuration.any_instance.stubs(:skip_recurring_tasks?).returns(false) + + status = nil + out, err = capture_io do + begin + @rake["solid_queue:check"].invoke + rescue SystemExit => e + status = e.status + end + end + + assert_equal 1, status + assert_empty out + assert_match "Invalid Solid Queue configuration:", err + assert_match "broken", err + end +end