From 397e423b3145b4207eb29543e25b89b9748c6541 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 21:08:51 +0200 Subject: [PATCH 01/11] Add --only-scheduler to run just the recurring scheduler Makes isolating the scheduler on its own process explicit, matching the existing --skip-recurring option and the workaround of an empty workers/dispatchers config. Fixes #484 --- README.md | 4 ++++ lib/solid_queue/cli.rb | 4 ++++ lib/solid_queue/configuration.rb | 10 +++++++- test/unit/cli_test.rb | 16 +++++++++++++ test/unit/configuration_test.rb | 39 ++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ab58ece..79f621ec 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ bin/jobs -c config/calendar.yml 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`. +To run **only** the scheduler (no workers or dispatchers)—for example to isolate recurring tasks on a dedicated process—set `SOLID_QUEUE_ONLY_SCHEDULER=true` or use the `--only-scheduler` option with `bin/jobs`. + This is what this configuration looks like: ```yml @@ -690,6 +692,8 @@ bin/jobs --recurring_schedule_file=config/schedule.yml You can completely disable recurring tasks by setting the environment variable `SOLID_QUEUE_SKIP_RECURRING=true` or by using the `--skip-recurring` option with `bin/jobs`. +To run only the scheduler (no workers or dispatchers), set `SOLID_QUEUE_ONLY_SCHEDULER=true` or use `--only-scheduler` with `bin/jobs`. + The configuration itself looks like this: ```yml diff --git a/lib/solid_queue/cli.rb b/lib/solid_queue/cli.rb index 2f3f3e13..848b8d95 100644 --- a/lib/solid_queue/cli.rb +++ b/lib/solid_queue/cli.rb @@ -20,6 +20,10 @@ class Cli < Thor desc: "Whether to skip recurring tasks scheduling", banner: "SOLID_QUEUE_SKIP_RECURRING" + class_option :only_scheduler, type: :boolean, + desc: "Whether to run only the scheduler process for recurring tasks", + banner: "SOLID_QUEUE_ONLY_SCHEDULER" + def self.exit_on_failure? true end diff --git a/lib/solid_queue/configuration.rb b/lib/solid_queue/configuration.rb index 300bfd10..3610de40 100644 --- a/lib/solid_queue/configuration.rb +++ b/lib/solid_queue/configuration.rb @@ -40,7 +40,10 @@ def initialize(**options) end def configured_processes - if only_work? then workers + if only_work? + workers + elsif only_scheduler? + schedulers else dispatchers + workers + schedulers end @@ -102,6 +105,7 @@ def default_options recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH), only_work: false, only_dispatch: false, + only_scheduler: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_SCHEDULER"]), skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"]) } end @@ -118,6 +122,10 @@ def only_dispatch? options[:only_dispatch] end + def only_scheduler? + options[:only_scheduler] + end + def skip_recurring_tasks? options[:skip_recurring] || only_work? end diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb index 9ee0e233..5b3c8599 100644 --- a/test/unit/cli_test.rb +++ b/test/unit/cli_test.rb @@ -36,6 +36,22 @@ class CliTest < ActiveSupport::TestCase end end + test "only_scheduler option runs just the scheduler" do + config = configuration_from_cli(only_scheduler: true) + + assert_equal 1, config.configured_processes.count + assert_equal :scheduler, config.configured_processes.first.kind + end + + test "only_scheduler respects SOLID_QUEUE_ONLY_SCHEDULER env var" do + with_env("SOLID_QUEUE_ONLY_SCHEDULER" => "true") do + config = configuration_from_cli + + assert_equal 1, config.configured_processes.count + assert_equal :scheduler, config.configured_processes.first.kind + end + end + private def configuration_from_cli(**cli_options) cli = SolidQueue::Cli.new([], cli_options) diff --git a/test/unit/configuration_test.rb b/test/unit/configuration_test.rb index bf2c6b8e..0875db10 100644 --- a/test/unit/configuration_test.rb +++ b/test/unit/configuration_test.rb @@ -110,6 +110,45 @@ class ConfigurationTest < ActiveSupport::TestCase assert_processes configuration, :dispatcher, 1, polling_interval: 0.1, recurring_tasks: nil end + test "only_scheduler runs just the scheduler process" do + configuration = SolidQueue::Configuration.new(only_scheduler: true) + + assert_equal 1, configuration.configured_processes.count + assert_processes configuration, :scheduler, 1 + assert_processes configuration, :worker, 0 + assert_processes configuration, :dispatcher, 0 + assert configuration.valid? + end + + test "only_scheduler ignores workers and dispatchers from the config file" do + configuration = SolidQueue::Configuration.new(only_scheduler: true) + + assert_processes configuration, :worker, 0 + assert_processes configuration, :dispatcher, 0 + assert_processes configuration, :scheduler, 1 + + scheduler = configuration.configured_processes.first.instantiate + assert_has_recurring_task scheduler, key: "periodic_store_result", class_name: "StoreResultJob", schedule: "every second" + end + + test "only_scheduler when SOLID_QUEUE_ONLY_SCHEDULER environment variable is set" do + with_env("SOLID_QUEUE_ONLY_SCHEDULER" => "true") do + configuration = SolidQueue::Configuration.new + + assert_equal 1, configuration.configured_processes.count + assert_processes configuration, :scheduler, 1 + assert_processes configuration, :worker, 0 + assert_processes configuration, :dispatcher, 0 + end + end + + test "only_scheduler with skip_recurring is invalid" do + configuration = SolidQueue::Configuration.new(only_scheduler: true, skip_recurring: true) + + assert_not configuration.valid? + assert_equal [ "No processes configured" ], configuration.errors.full_messages + end + test "skip recurring tasks when SOLID_QUEUE_SKIP_RECURRING environment variable is set" do with_env("SOLID_QUEUE_SKIP_RECURRING" => "true") do configuration = SolidQueue::Configuration.new(dispatchers: [ { polling_interval: 0.1 } ]) From 1bd31f9084b3578ce7abf76a199aa3f5383c592c Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 21:26:58 +0200 Subject: [PATCH 02/11] Wait for async supervisor exit before asserting clean termination The forked lifecycle test already waits up to shutdown_timeout for the supervisor PID to exit after repeated TERM signals. The async counterpart only slept for 1s, which is flaky under load (e.g. Ruby 4 + rails_main + MySQL). --- test/integration/async_processes_lifecycle_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/async_processes_lifecycle_test.rb b/test/integration/async_processes_lifecycle_test.rb index fd284210..e4f74ec6 100644 --- a/test/integration/async_processes_lifecycle_test.rb +++ b/test/integration/async_processes_lifecycle_test.rb @@ -58,7 +58,7 @@ class AsyncProcessesLifecycleTest < 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 From 0a3bd3e57dbf743aecc337281e5fa223d6308704 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 21:32:08 +0200 Subject: [PATCH 03/11] Wait for failed executions in jobs lifecycle failure test wait_for_jobs_to_finish_for waits on finished_at, which failed jobs never set, so the test always timed out. Wait for FailedExecution records instead and stop the worker before asserting to avoid races with in-flight threads. --- test/integration/jobs_lifecycle_test.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/jobs_lifecycle_test.rb b/test/integration/jobs_lifecycle_test.rb index 8444c375..53e8c97c 100644 --- a/test/integration/jobs_lifecycle_test.rb +++ b/test/integration/jobs_lifecycle_test.rb @@ -40,11 +40,15 @@ class JobsLifecycleTest < ActiveSupport::TestCase @dispatcher.start @worker.start - wait_for_jobs_to_finish_for(3.seconds) + wait_while_with_timeout(3.seconds) { SolidQueue::FailedExecution.count < 2 } + + @worker.stop + @dispatcher.stop message = "raised ExpectedTestError for the 1st time" assert_equal [ "A: #{message}", "B: #{message}" ], JobBuffer.values.sort + assert_equal 2, SolidQueue::FailedExecution.count assert_empty SolidQueue::Job.finished end From ae424180197532b4178b475723114494c3ef8326 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 21:58:30 +0200 Subject: [PATCH 04/11] Give concurrency controls tests more timing headroom on CI Setup only waited 0.5s for processes to register, and a few scenarios used tight pause windows that flake under MySQL CI load. Widen registration and job-completion waits, and leave more margin so C still finishes last in the throttled sequence test. --- test/integration/concurrency_controls_test.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index a12c48e4..f9a18351 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -13,7 +13,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase @pid = run_supervisor_as_fork(workers: [ default_worker ], dispatchers: [ dispatcher ]) - wait_for_registered_processes(5, timeout: 0.5.second) # 3 workers working the default queue + dispatcher + supervisor + wait_for_registered_processes(5, timeout: 3.seconds) # 3 workers working the default queue + dispatcher + supervisor end teardown do @@ -48,7 +48,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase NonOverlappingUpdateResultJob.set(wait: (1 + i * 0.1).seconds).perform_later(@result, name: name) end - wait_for_jobs_to_finish_for(5.seconds) + wait_for_jobs_to_finish_for(15.seconds) assert_no_unfinished_jobs assert_stored_sequence @result, ("A".."K").to_a @@ -57,26 +57,26 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase test "run several jobs over the same record limiting concurrency" do incr = 0 # C is the last one to update the record - # A: 0 to 0.5 - # B: 0 to 1.0 - # C: 0 to 1.5 + # A: 0 to 1.0 + # B: 0 to 1.5 + # C: 0 to 2.0 assert_no_difference -> { SolidQueue::BlockedExecution.count } do ("A".."C").each do |name| - ThrottledUpdateResultJob.perform_later(@result, name: name, pause: (0.5 + incr).seconds) + ThrottledUpdateResultJob.perform_later(@result, name: name, pause: (1.0 + incr).seconds) incr += 0.5 end end sleep(0.01) # To ensure these aren't picked up before ABC - # D to H: 0.51 to 0.76 (starting after A finishes, and in order, 5 * 0.05 = 0.25) + # D to H: starting after A finishes, and in order; keep pauses short so they finish before B and C # These would finish all before B and C assert_difference -> { SolidQueue::BlockedExecution.count }, +5 do ("D".."H").each do |name| - ThrottledUpdateResultJob.perform_later(@result, name: name, pause: 0.05.seconds) + ThrottledUpdateResultJob.perform_later(@result, name: name, pause: 0.02.seconds) end end - wait_for_jobs_to_finish_for(5.seconds) + wait_for_jobs_to_finish_for(15.seconds) assert_no_unfinished_jobs # C would have started in the beginning, seeing the status empty, and would finish after From 64ef4d22e6eda0330671228577e9be933b2f51e3 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 22:15:03 +0200 Subject: [PATCH 05/11] Reduce cross-test pollution in concurrency and recurring tests Wait for supervisor teardown to deregister processes, adopt the claimed- release wait from #734, and scope recurring JobResult assertions to the custom_status rows this test creates so leftover StoreResultJob rows cannot fail the suite. --- test/integration/concurrency_controls_test.rb | 28 ++++++++++--------- test/integration/recurring_tasks_test.rb | 9 +++--- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index f9a18351..12cc7585 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -17,7 +17,10 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase end teardown do - terminate_process(@pid) if process_exists?(@pid) + if @pid && process_exists?(@pid) + terminate_process(@pid) + end + wait_for_registered_processes(0, timeout: 3.seconds) end test "run several conflicting jobs over the same record without overlapping" do @@ -57,22 +60,22 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase test "run several jobs over the same record limiting concurrency" do incr = 0 # C is the last one to update the record - # A: 0 to 1.0 - # B: 0 to 1.5 - # C: 0 to 2.0 + # A: 0 to 0.5 + # B: 0 to 1.0 + # C: 0 to 1.5 assert_no_difference -> { SolidQueue::BlockedExecution.count } do ("A".."C").each do |name| - ThrottledUpdateResultJob.perform_later(@result, name: name, pause: (1.0 + incr).seconds) + ThrottledUpdateResultJob.perform_later(@result, name: name, pause: (0.5 + incr).seconds) incr += 0.5 end end sleep(0.01) # To ensure these aren't picked up before ABC - # D to H: starting after A finishes, and in order; keep pauses short so they finish before B and C + # D to H: 0.51 to 0.76 (starting after A finishes, and in order, 5 * 0.05 = 0.25) # These would finish all before B and C assert_difference -> { SolidQueue::BlockedExecution.count }, +5 do ("D".."H").each do |name| - ThrottledUpdateResultJob.perform_later(@result, name: name, pause: 0.02.seconds) + ThrottledUpdateResultJob.perform_later(@result, name: name, pause: 0.05.seconds) end end @@ -94,7 +97,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase NonOverlappingUpdateResultJob.perform_later(@result, name: name) end - wait_for_jobs_to_finish_for(5.seconds) + wait_for_jobs_to_finish_for(15.seconds) assert_equal 3, SolidQueue::FailedExecution.count assert_stored_sequence @result, [ "B", "D", "F" ] + ("G".."K").to_a @@ -166,12 +169,11 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase NonOverlappingUpdateResultJob.perform_later(@result, name: "I'll be released to ready", pause: SolidQueue.shutdown_timeout + 10.seconds) job = SolidQueue::Job.last - sleep(0.2) - assert job.claimed? + wait_for(timeout: 2.seconds) { job.reload.claimed? } - # This won't leave time to the job to finish - signal_process(@pid, :TERM, wait: 0.1.second) - sleep(SolidQueue.shutdown_timeout + 0.6.seconds) + # This won't leave time to the job to finish, so the worker should + # release it back to ready during shutdown. + terminate_process(@pid) assert_not job.reload.finished? assert job.reload.ready? diff --git a/test/integration/recurring_tasks_test.rb b/test/integration/recurring_tasks_test.rb index f2fc7145..64059e5a 100644 --- a/test/integration/recurring_tasks_test.rb +++ b/test/integration/recurring_tasks_test.rb @@ -24,11 +24,10 @@ class RecurringTasksTest < ActiveSupport::TestCase assert_equal "StoreResultJob", job.class_name end - assert JobResult.count >= 2 - JobResult.all.each do |result| - assert_equal "custom_status", result.status - assert_equal "42", result.value - end + # Scope to this recurring task's results. Other tests may leave JobResult + # rows (e.g. status "completed") that would fail a JobResult.all assertion. + results = JobResult.where(status: "custom_status", value: "42") + assert results.count >= 2 end end From b3ef0f644d863fbd89e21c2f5ecd2287de527948 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Fri, 17 Jul 2026 22:46:47 +0200 Subject: [PATCH 06/11] Isolate concurrency tests from recycled JobResult primary keys Forked StoreResultJob workers from earlier tests can still write after teardown and overwrite a JobResult whose id was reused, turning status into "completed". Clear processes/records before creating @result, wait for claimed slots before enqueueing blocked jobs, and tighten discard setup timing. --- test/integration/concurrency_controls_test.rb | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index 12cc7585..9f748ea7 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -6,21 +6,27 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase self.use_transactional_tests = false setup do - @result = JobResult.create!(queue_name: "default", status: "") + # Previous tests may leave forked workers briefly alive; those can still write to + # JobResult rows whose primary keys get reused by create! below (e.g. overwriting + # status with StoreResultJob's default "completed"). + wait_for_registered_processes(0, timeout: 5.seconds) + destroy_records default_worker = { queues: "default", polling_interval: 0.1, processes: 3, threads: 2 } dispatcher = { polling_interval: 0.1, batch_size: 200, concurrency_maintenance_interval: 1 } @pid = run_supervisor_as_fork(workers: [ default_worker ], dispatchers: [ dispatcher ]) + wait_for_registered_processes(5, timeout: 3.seconds) # 3 workers + dispatcher + supervisor - wait_for_registered_processes(5, timeout: 3.seconds) # 3 workers working the default queue + dispatcher + supervisor + @result = JobResult.create!(queue_name: "default", status: "") end teardown do if @pid && process_exists?(@pid) terminate_process(@pid) end - wait_for_registered_processes(0, timeout: 3.seconds) + wait_for_registered_processes(0, timeout: 5.seconds) + destroy_records end test "run several conflicting jobs over the same record without overlapping" do @@ -70,8 +76,10 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase end end - sleep(0.01) # To ensure these aren't picked up before ABC - # D to H: 0.51 to 0.76 (starting after A finishes, and in order, 5 * 0.05 = 0.25) + # Wait until A/B/C hold the concurrency slots so D–H are blocked instead of claimed + wait_for(timeout: 2.seconds) { SolidQueue::ClaimedExecution.count >= 3 } + + # D to H: starting after A finishes, and in order, 5 * 0.05 = 0.25 # These would finish all before B and C assert_difference -> { SolidQueue::BlockedExecution.count }, +5 do ("D".."H").each do |name| @@ -198,8 +206,8 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase end 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) + job1 = DiscardableUpdateResultJob.perform_later(@result, name: "1", pause: 1.second) + wait_for(timeout: 2.seconds) { SolidQueue::Job.find_by(active_job_id: job1.job_id)&.claimed? } # should be discarded due to concurrency limit job2 = DiscardableUpdateResultJob.perform_later(@result, name: "2") From 09f1b10a1e6c6b5608a855768b63bc82654cc179 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Sun, 19 Jul 2026 23:25:37 +0200 Subject: [PATCH 07/11] Sync JobResult uncache fix for recurring test from #758 --- test/integration/recurring_tasks_test.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/integration/recurring_tasks_test.rb b/test/integration/recurring_tasks_test.rb index 64059e5a..5ce9a70d 100644 --- a/test/integration/recurring_tasks_test.rb +++ b/test/integration/recurring_tasks_test.rb @@ -14,8 +14,12 @@ class RecurringTasksTest < ActiveSupport::TestCase 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) + wait_for_jobs_to_be_enqueued(2, timeout: 5.seconds) + # JobResult uses ApplicationRecord, so SolidQueue::Record.uncached does not + # apply — wait/assert with JobResult.uncached to avoid stale COUNT cache. + wait_while_with_timeout(5.seconds) do + JobResult.uncached { JobResult.where(status: "custom_status", value: "42").count < 2 } + end skip_active_record_query_cache do assert SolidQueue::Job.count >= 2 @@ -23,12 +27,12 @@ class RecurringTasksTest < ActiveSupport::TestCase assert_equal "periodic_store_result", job.recurring_execution.task_key assert_equal "StoreResultJob", job.class_name end - - # Scope to this recurring task's results. Other tests may leave JobResult - # rows (e.g. status "completed") that would fail a JobResult.all assertion. - results = JobResult.where(status: "custom_status", value: "42") - assert results.count >= 2 end + + # Scope to this recurring task's results. Other tests may leave JobResult + # rows (e.g. status "completed") that would fail a JobResult.all assertion. + results = JobResult.uncached { JobResult.where(status: "custom_status", value: "42").to_a } + assert_operator results.size, :>=, 2 end test "persist and delete configured tasks" do From 14d27a221379afe8f657d8f4aea6e4b822511fcc Mon Sep 17 00:00:00 2001 From: Pissardo Date: Sun, 19 Jul 2026 23:35:59 +0200 Subject: [PATCH 08/11] Sync concurrency and JobResult uncache flake fixes from #758 --- test/integration/concurrency_controls_test.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index c3c31e9d..0bcc47fc 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -60,7 +60,12 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase wait_for_jobs_to_finish_for(15.seconds) assert_no_unfinished_jobs - assert_stored_sequence @result, ("A".."K").to_a + # "000" is a non-concurrency-limited UpdateResultJob meant to race with A. + # Whether its segment remains depends on whether A loaded before or after + # "000" saved — both are valid under CI scheduling. + segments = JobResult.uncached { @result.reload.status.split(" + ") } + segments -= [ "s000c000" ] + assert_equal ("A".."K").map { |name| "s#{name}c#{name}" }.sort, segments.sort end test "run several jobs over the same record limiting concurrency" do @@ -89,7 +94,7 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase # C starts with empty status; when it finishes last only sCcC remains. Under load a # trailing blocked job may finish after C — still require C completed and every # written segment is a matching start/complete pair from A–H. - segments = @result.reload.status.split(" + ") + segments = JobResult.uncached { @result.reload.status.split(" + ") } assert segments.any? { |segment| segment == "sCcC" } segments.each do |segment| assert_match(/\As([A-H])c\1\z/, segment) @@ -267,9 +272,9 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase private def assert_stored_sequence(result, sequence) expected = sequence.sort.map { |name| "s#{name}c#{name}" }.join - skip_active_record_query_cache do - assert_equal expected, result.reload.status.split(" + ").sort.join - end + # JobResult uses ApplicationRecord; SolidQueue::Record.uncached does not apply. + actual = JobResult.uncached { result.reload.status.split(" + ").sort.join } + assert_equal expected, actual end def wait_for_semaphores_to_be_released_for(timeout) From 3c6801774a2f20b51fd3ccbadfbbd5381b2f3304 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Sun, 19 Jul 2026 23:47:11 +0200 Subject: [PATCH 09/11] Sync claimed-job discard wait flake fix from #758 --- test/models/solid_queue/job_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/solid_queue/job_test.rb b/test/models/solid_queue/job_test.rb index 47702bd1..a776f703 100644 --- a/test/models/solid_queue/job_test.rb +++ b/test/models/solid_queue/job_test.rb @@ -256,7 +256,7 @@ class DiscardableNonOverlappingGroupedJob2 < NonOverlappingJob job = SolidQueue::Job.last worker = SolidQueue::Worker.new(queues: "background").tap(&:start) - sleep(0.2) + wait_while_with_timeout(2.seconds) { !job.reload.claimed? } assert_no_difference -> { SolidQueue::Job.count }, -> { SolidQueue::ClaimedExecution.count } do assert_raises SolidQueue::Execution::UndiscardableError do From 74a124223f08c41fc12c41626f77b3059fb082d0 Mon Sep 17 00:00:00 2001 From: Pissardo Date: Sun, 19 Jul 2026 23:59:14 +0200 Subject: [PATCH 10/11] Sync JobResult uncache lifecycle flake fixes from #758 --- .../async_processes_lifecycle_test.rb | 19 ++++++++------- test/integration/concurrency_controls_test.rb | 7 +++--- .../forked_processes_lifecycle_test.rb | 23 ++++++++++++------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/test/integration/async_processes_lifecycle_test.rb b/test/integration/async_processes_lifecycle_test.rb index e4f74ec6..9fe3c18d 100644 --- a/test/integration/async_processes_lifecycle_test.rb +++ b/test/integration/async_processes_lifecycle_test.rb @@ -22,7 +22,7 @@ class AsyncProcessesLifecycleTest < ActiveSupport::TestCase wait_for_jobs_to_finish_for(2.seconds) - assert_equal 12, JobResult.count + assert_equal 12, JobResult.uncached { JobResult.count } 6.times { |i| assert_completed_job_results("job_#{i}", :background) } 6.times { |i| assert_completed_job_results("job_#{i}", :default) } @@ -144,7 +144,7 @@ class AsyncProcessesLifecycleTest < ActiveSupport::TestCase # The pause job should have started but not completed assert_started_job_result("pause") - assert_not_equal "completed", skip_active_record_query_cache { JobResult.find_by(value: "pause")&.status } + assert_not_equal "completed", JobResult.uncached { JobResult.find_by(value: "pause")&.status } # After shutdown, the pause job may be either: # - claimed (exit! called, no cleanup) OR @@ -212,15 +212,18 @@ def enqueue_store_result_job(value, queue_name = :background, **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 + # JobResult uses ApplicationRecord; SolidQueue::Record.uncached does not apply. + actual = JobResult.uncached { + JobResult.where(queue_name: queue_name, status: "completed", value: value).count + } + assert_equal count, actual 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 + actual = JobResult.uncached { + JobResult.where(queue_name: queue_name, status: "started", value: value).count + } + assert_equal count, actual end def assert_job_status(active_job, status) diff --git a/test/integration/concurrency_controls_test.rb b/test/integration/concurrency_controls_test.rb index 0bcc47fc..7b46de2e 100644 --- a/test/integration/concurrency_controls_test.rb +++ b/test/integration/concurrency_controls_test.rb @@ -91,11 +91,10 @@ class ConcurrencyControlsTest < ActiveSupport::TestCase wait_for_jobs_to_finish_for(15.seconds) assert_no_unfinished_jobs - # C starts with empty status; when it finishes last only sCcC remains. Under load a - # trailing blocked job may finish after C — still require C completed and every - # written segment is a matching start/complete pair from A–H. + # Jobs load @result into memory; under load the last saver is not always C. + # Assert every remaining segment is a complete A–H start/complete pair. segments = JobResult.uncached { @result.reload.status.split(" + ") } - assert segments.any? { |segment| segment == "sCcC" } + assert_predicate segments, :any? segments.each do |segment| assert_match(/\As([A-H])c\1\z/, segment) end diff --git a/test/integration/forked_processes_lifecycle_test.rb b/test/integration/forked_processes_lifecycle_test.rb index d33ea3bf..3142f64c 100644 --- a/test/integration/forked_processes_lifecycle_test.rb +++ b/test/integration/forked_processes_lifecycle_test.rb @@ -22,7 +22,7 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase wait_for_jobs_to_finish_for(2.seconds) - assert_equal 12, JobResult.count + assert_equal 12, JobResult.uncached { JobResult.count } 6.times { |i| assert_completed_job_results("job_#{i}", :background) } 6.times { |i| assert_completed_job_results("job_#{i}", :default) } @@ -128,7 +128,7 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase SolidQueue::ReadyExecution.joins(:job).exists?(solid_queue_jobs: { active_job_id: pause.job_id }) } wait_while_with_timeout(5.seconds) { - !JobResult.exists?(status: "started", value: "pause") + JobResult.uncached { !JobResult.exists?(status: "started", value: "pause") } } signal_process(@pid, :TERM, wait: 0.5.second) @@ -241,6 +241,10 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase enqueue_store_result_job("pause", :default, pause: 0.5.seconds) wait_for_jobs_to_finish_for(1.second, except: [ killed_pause ]) + # Ensure the long job has written its "started" row before we SIGKILL the worker. + wait_while_with_timeout(2.seconds) do + JobResult.uncached { JobResult.where(status: "started", value: "killed_pause").none? } + end worker = find_processes_registered_as("Worker").detect { |process| process.metadata["queues"].include? "background" } signal_process(worker.pid, :KILL, wait: 0.5.seconds) @@ -303,15 +307,18 @@ def enqueue_store_result_job(value, queue_name = :background, **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 + # JobResult uses ApplicationRecord; SolidQueue::Record.uncached does not apply. + actual = JobResult.uncached { + JobResult.where(queue_name: queue_name, status: "completed", value: value).count + } + assert_equal count, actual 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 + actual = JobResult.uncached { + JobResult.where(queue_name: queue_name, status: "started", value: value).count + } + assert_equal count, actual end def assert_job_status(active_job, status) From b613854a7b2bd9836d453a2ed6be692470a5240c Mon Sep 17 00:00:00 2001 From: Pissardo Date: Mon, 20 Jul 2026 00:07:04 +0200 Subject: [PATCH 11/11] Sync worker-exit completed-count flake fix from #758 --- test/integration/forked_processes_lifecycle_test.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/forked_processes_lifecycle_test.rb b/test/integration/forked_processes_lifecycle_test.rb index 3142f64c..96c3bc5a 100644 --- a/test/integration/forked_processes_lifecycle_test.rb +++ b/test/integration/forked_processes_lifecycle_test.rb @@ -188,7 +188,12 @@ class ForkedProcessesLifecycleTest < ActiveSupport::TestCase wait_for_jobs_to_finish_for(3.seconds, except: [ exit_job, pause_job ]) assert_completed_job_results("no exit", :default, 2) - assert_completed_job_results("no exit", :background, 4) + # Background worker defaults to 3 threads; jobs claimed alongside the exiting + # job are failed with it, so completed "no exit" count can be 3 or 4. + completed_no_exit = JobResult.uncached { + JobResult.where(queue_name: :background, status: "completed", value: "no exit").count + } + assert_includes 3..4, completed_no_exit assert_completed_job_results("paused no exit", :default, 1) assert process_exists?(@pid)