solid_queue 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd0590f46160c60f3a496158cf8dc2412803025cd06d94ac423c32f0b688dd77
4
- data.tar.gz: 8c892f457280b1974908d2de0c3e5be5229ff6bcb448ed61c2937ff4566da796
3
+ metadata.gz: 82eb2e9041fa69b3e7f3bd4a3d62c46b7826100a708b2237750f0f9e4fdee9c5
4
+ data.tar.gz: 14000eeba6030e0248273d4cc158e17f25f78268d5e2c62c5e74f9a959f8d94f
5
5
  SHA512:
6
- metadata.gz: 0a86b46d35b0cc8aef4a235e401b4470a6f6e64ff8c44ebdefc06f597d6cbe67ea76c786fc1e85caee1c3fc4dd492180530348078890ef74af90461705150a60
7
- data.tar.gz: efeadbeb7dc0d044c801915d6ba4d8c249b249ef0dde8726454a359d3f1a4ce9ad4e2edb6cd19f8c371967059081c0ef2904ea630a0ae620123939ce881f0fdb
6
+ metadata.gz: 9bb7b225b9a3b1b13e1f2edfa04927cb2f3954d0a0b226ca79e7fc69dff6675c002ac5e6dd13fd0ff9794d2f0a660487a70cb078868034e7a048378b14911a4f
7
+ data.tar.gz: b30ef8f4992fc7a3d858294b3ae9b1c4459f813d9ec3bb3ff4a4fde97ad114469d76d913bb19912117de3d081e6c8d159798e5bcdd0790858ea3af6f4faf7cc6
data/README.md CHANGED
@@ -23,6 +23,7 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
23
23
  - [Threads, processes, and signals](#threads-processes-and-signals)
24
24
  - [Database configuration](#database-configuration)
25
25
  - [Other configuration settings](#other-configuration-settings)
26
+ - [Validating the configuration](#validating-the-configuration)
26
27
  - [Lifecycle hooks](#lifecycle-hooks)
27
28
  - [Errors when enqueuing](#errors-when-enqueuing)
28
29
  - [Concurrency controls](#concurrency-controls)
@@ -44,7 +45,7 @@ Solid Queue is configured by default in new Rails 8 applications. If you're runn
44
45
  1. `bundle add solid_queue`
45
46
  2. `bin/rails solid_queue:install`
46
47
 
47
- (Note: The minimum supported version of Rails is 7.1 and Ruby is 3.1.6.)
48
+ (Note: The minimum supported version of Rails is 7.1 and Ruby is 3.2.)
48
49
 
49
50
  This will configure Solid Queue as the production Active Job backend, create the configuration files `config/queue.yml` and `config/recurring.yml`, and create the `db/queue_schema.rb`. It'll also create a `bin/jobs` executable wrapper that you can use to start Solid Queue.
50
51
 
@@ -213,6 +214,8 @@ bin/jobs -c config/calendar.yml
213
214
 
214
215
  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`.
215
216
 
217
+ To run **only** the scheduler (no workers or dispatchers)—for example to isolate recurring tasks on a dedicated process—set `SOLID_QUEUE_ONLY_RECURRING=true` or use the `--only-recurring` option with `bin/jobs`.
218
+
216
219
  This is what this configuration looks like:
217
220
 
218
221
  ```yml
@@ -338,7 +341,7 @@ FROM solid_queue_ready_executions
338
341
  WHERE queue_name LIKE 'beta%';
339
342
  ```
340
343
 
341
- This type of `DISTINCT` query on a column that's the leftmost column in an index can be performed very fast in MySQL thanks to a technique called [Loose Index Scan](https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html#loose-index-scan). PostgreSQL and SQLite, however, don't implement this technique, which means that if your `solid_queue_ready_executions` table is very big because your queues get very deep, this query will get slow. Normally your `solid_queue_ready_executions` table will be small, but it can happen.
344
+ This type of `DISTINCT` query on a column that's the leftmost column in an index can be performed very fast in MySQL thanks to a technique called [Loose Index Scan](https://dev.mysql.com/doc/refman/8.0/en/group-by-optimization.html#loose-index-scan). PostgreSQL doesn't implement this technique natively, so Solid Queue uses a [recursive CTE](https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-RECURSIVE) to emulate it, achieving similar performance by walking the B-tree index and jumping between distinct values. SQLite doesn't implement loose index scan either, but this is unlikely to be a problem since SQLite is typically used in development with small datasets.
342
345
 
343
346
  Similarly to using prefixes, the same will happen if you have paused queues, because we need to get a list of all queues with a query like
344
347
  ```sql
@@ -372,6 +375,8 @@ The supervisor is in charge of managing these processes, and it responds to the
372
375
 
373
376
  When receiving a `QUIT` signal, if workers still have jobs in-flight, these will be returned to the queue when the processes are deregistered.
374
377
 
378
+ On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds to `TERM` and `INT` there.
379
+
375
380
  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.
376
381
 
377
382
  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.
@@ -401,6 +406,7 @@ There are several settings that control how Solid Queue works that you can set a
401
406
  - `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.
402
407
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
403
408
  - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
409
+ - `fork_boot_timeout`: how long a forked process can take to finish booting before the supervisor replaces it—defaults to 5 minutes. It only applies in the default `fork` mode.
404
410
  - `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.
405
411
  - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`.
406
412
  - `supervisor_pidfile`: path to a pidfile that the supervisor will create when booting to prevent running more than one supervisor in the same host, or in case you want to use it for a health check. It's `nil` by default.
@@ -408,6 +414,22 @@ There are several settings that control how Solid Queue works that you can set a
408
414
  - `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.
409
415
  - `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.
410
416
 
417
+ ### Validating the configuration
418
+
419
+ 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:
420
+
421
+ ```bash
422
+ # Using the bin/jobs binstub
423
+ bin/jobs check
424
+
425
+ # Or via rake
426
+ bin/rails solid_queue:check
427
+ ```
428
+
429
+ 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.
430
+
431
+ `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`.
432
+
411
433
 
412
434
  ## Lifecycle hooks
413
435
 
@@ -471,7 +493,7 @@ Solid Queue extends Active Job with concurrency controls, that allows you to lim
471
493
 
472
494
  ```ruby
473
495
  class MyJob < ApplicationJob
474
- limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2, **) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group, on_conflict: on_conflict_behaviour
496
+ limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2, *) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group, on_conflict: on_conflict_behaviour
475
497
 
476
498
  # ...
477
499
  ```
@@ -690,6 +712,8 @@ bin/jobs --recurring_schedule_file=config/schedule.yml
690
712
 
691
713
  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`.
692
714
 
715
+ To run only the scheduler (no workers or dispatchers), set `SOLID_QUEUE_ONLY_RECURRING=true` or use `--only-recurring` with `bin/jobs`.
716
+
693
717
  The configuration itself looks like this:
694
718
 
695
719
  ```yml
@@ -705,7 +729,9 @@ production:
705
729
 
706
730
  Tasks are specified as a hash/dictionary, where the key will be the task's key internally. Each task needs to either have a `class`, which will be the job class to enqueue, or a `command`, which will be eval'ed in the context of a job (`SolidQueue::RecurringJob`) that will be enqueued according to its schedule, in the `solid_queue_recurring` queue.
707
731
 
708
- Each task needs to have also a schedule, which is parsed using [Fugit](https://github.com/floraison/fugit), so it accepts anything [that Fugit accepts as a cron](https://github.com/floraison/fugit?tab=readme-ov-file#fugitcron). You can optionally supply the following for each task:
732
+ Each task needs to have also a schedule, which is parsed using [Fugit](https://github.com/floraison/fugit), so it accepts anything [that Fugit accepts as a cron](https://github.com/floraison/fugit?tab=readme-ov-file#fugitcron). Schedules can include a time zone (e.g. `0 9 * * * America/New_York` or `every day at 9am America/New_York`). When a schedule doesn't specify one, it's interpreted in the application's configured time zone (`config.time_zone`) by default. You can change or disable this default with `config.solid_queue.time_zone`; setting it to `nil` falls back to the system's local time.
733
+
734
+ You can optionally supply the following for each task:
709
735
  - `args`: the arguments to be passed to the job, as a single argument, a hash, or an array of arguments that can also include kwargs as the last element in the array.
710
736
 
711
737
  The job in the example configuration above will be enqueued every second as:
data/UPGRADING.md CHANGED
@@ -1,3 +1,14 @@
1
+ # Upgrading to version 1.5.x
2
+ Ruby 3.1 is no longer supported, as it reached end-of-life in March 2025. Solid Queue now requires Ruby 3.2 or newer. If you're still on Ruby 3.1, Bundler will continue to resolve solid_queue 1.4.x for you, but you won't receive any new versions until you upgrade Ruby.
3
+
4
+ Recurring schedules that don't specify a time zone are now interpreted in your application's configured time zone (`config.time_zone`) by default, instead of the system's local time. This only affects schedules without an explicit time zone (e.g. `every day at 9am`); schedules that already include one (e.g. `0 9 * * * America/New_York`) are unaffected.
5
+
6
+ If your `config.time_zone` differs from the system time where your processes run, recurring jobs may fire at a different wall-clock time than before. To keep the previous behavior, set:
7
+
8
+ ```ruby
9
+ config.solid_queue.time_zone = nil
10
+ ```
11
+
1
12
  # Upgrading to version 1.x
2
13
  The value returned for `enqueue_after_transaction_commit?` has changed to `true`, and it's no longer configurable. If you want to change this, you need to use Active Job's configuration options.
3
14
 
@@ -43,7 +43,6 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
43
43
  SolidQueue.instrument(:fail_many_claimed) do |payload|
44
44
  executions.each do |execution|
45
45
  execution.failed_with(error)
46
- execution.unblock_next_job
47
46
  end
48
47
 
49
48
  payload[:process_ids] = executions.map(&:process_id).uniq
@@ -71,13 +70,11 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
71
70
  failed_with(result.error)
72
71
  raise result.error
73
72
  end
74
- ensure
75
- unblock_next_job
76
73
  end
77
74
 
78
75
  def release
79
76
  SolidQueue.instrument(:release_claimed, job_id: job.id, process_id: process_id) do
80
- transaction do
77
+ unless_already_finalized do
81
78
  job.dispatch_bypassing_concurrency_limits
82
79
  destroy!
83
80
  end
@@ -89,14 +86,7 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
89
86
  end
90
87
 
91
88
  def failed_with(error)
92
- transaction do
93
- job.failed_with(error)
94
- destroy!
95
- end
96
- end
97
-
98
- def unblock_next_job
99
- job.unblock_next_blocked_job
89
+ finalize { job.failed_with(error) }
100
90
  end
101
91
 
102
92
  private
@@ -108,9 +98,28 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
108
98
  end
109
99
 
110
100
  def finished
111
- transaction do
112
- job.finished!
101
+ finalize { job.finished! }
102
+ end
103
+
104
+ def finalize
105
+ finalized = unless_already_finalized do
106
+ yield
113
107
  destroy!
108
+ true
109
+ end
110
+
111
+ # Unblock the next job outside the finalize transaction so a failure while
112
+ # releasing the concurrency lock or dispatching the next job can't roll back
113
+ # a job that already finished or failed. Only the actor that owned and
114
+ # finalized the claim gets here, so the lock is released exactly once.
115
+ job.unblock_next_blocked_job if finalized
116
+ end
117
+
118
+ def unless_already_finalized
119
+ transaction do
120
+ return false unless self.class.unscoped.lock.find_by(id: id)
121
+
122
+ yield
114
123
  end
115
124
  end
116
125
  end
@@ -41,15 +41,16 @@ module SolidQueue
41
41
  end
42
42
 
43
43
  def successfully_dispatched(jobs)
44
- dispatched_and_ready(jobs) + dispatched_and_blocked(jobs)
44
+ jobs_by_id = jobs.index_by(&:id)
45
+ dispatched_and_ready(jobs_by_id) + dispatched_and_blocked(jobs_by_id)
45
46
  end
46
47
 
47
- def dispatched_and_ready(jobs)
48
- where(id: ReadyExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
48
+ def dispatched_and_ready(jobs_by_id)
49
+ ReadyExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
49
50
  end
50
51
 
51
- def dispatched_and_blocked(jobs)
52
- where(id: BlockedExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
52
+ def dispatched_and_blocked(jobs_by_id)
53
+ BlockedExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
53
54
  end
54
55
  end
55
56
 
@@ -23,7 +23,8 @@ module SolidQueue
23
23
  end
24
24
 
25
25
  def successfully_scheduled(jobs)
26
- where(id: ScheduledExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
26
+ jobs_by_id = jobs.index_by(&:id)
27
+ ScheduledExecution.where(job_id: jobs_by_id.keys).pluck(:job_id).map { |id| jobs_by_id[id] }
27
28
  end
28
29
  end
29
30
 
@@ -6,9 +6,7 @@ module SolidQueue
6
6
 
7
7
  class << self
8
8
  def all
9
- Job.select(:queue_name).distinct.collect do |job|
10
- new(job.queue_name)
11
- end
9
+ Job.distinct_values_of(:queue_name).map { |name| new(name) }
12
10
  end
13
11
 
14
12
  def find_by_name(name)
@@ -43,7 +43,7 @@ module SolidQueue
43
43
  end
44
44
 
45
45
  def all_queues
46
- relation.distinct(:queue_name).pluck(:queue_name)
46
+ relation.distinct_values_of(:queue_name)
47
47
  end
48
48
 
49
49
  def exact_names
@@ -53,7 +53,7 @@ module SolidQueue
53
53
  def prefixed_names
54
54
  if prefixes.empty? then []
55
55
  else
56
- relation.where(([ "queue_name LIKE ?" ] * prefixes.count).join(" OR "), *prefixes).distinct(:queue_name).pluck(:queue_name)
56
+ relation.where(([ "queue_name LIKE ?" ] * prefixes.count).join(" OR "), *prefixes).distinct_values_of(:queue_name)
57
57
  end
58
58
  end
59
59
 
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Record
5
+ module DistinctValues
6
+ extend ActiveSupport::Concern
7
+
8
+ # PostgreSQL has no native loose index scan, so a plain DISTINCT on a leading
9
+ # index column degrades to a full index scan on large tables. We emulate one
10
+ # with a recursive CTE that walks the index jumping between distinct values.
11
+ class_methods do
12
+ def distinct_values_of(column)
13
+ if loose_index_scan_emulation_needed?
14
+ loose_distinct_via_recursive_cte(column)
15
+ else
16
+ distinct.pluck(column)
17
+ end
18
+ end
19
+
20
+ private
21
+ def loose_index_scan_emulation_needed?
22
+ connection_pool.with_connection { |connection| connection.adapter_name == "PostgreSQL" }
23
+ end
24
+
25
+ # Emulates a loose index scan, honoring the current scope (e.g. LIKE prefixes)
26
+ # by building the anchor and the recursive step as scoped relations, whose
27
+ # #to_sql inlines any bind parameters so they can be embedded in the raw CTE.
28
+ def loose_distinct_via_recursive_cte(column)
29
+ connection_pool.with_connection do |connection|
30
+ col = connection.quote_column_name(column)
31
+
32
+ connection.select_values(<<~SQL.squish)
33
+ WITH RECURSIVE t AS (
34
+ (#{next_distinct_value(col, "#{col} IS NOT NULL")})
35
+ UNION ALL
36
+ SELECT (#{next_distinct_value(col, "#{col} > t.#{col}")}) FROM t WHERE t.#{col} IS NOT NULL
37
+ )
38
+ SELECT #{col} FROM t WHERE #{col} IS NOT NULL
39
+ SQL
40
+ end
41
+ end
42
+
43
+ # Smallest value of `col` within the current scope that matches `condition`.
44
+ def next_distinct_value(col, condition)
45
+ all.where(Arel.sql(condition)).reorder(Arel.sql(col)).limit(1).select(Arel.sql(col)).to_sql
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -3,6 +3,9 @@
3
3
  module SolidQueue
4
4
  class Record < ActiveRecord::Base
5
5
  self.abstract_class = true
6
+ self.strict_loading_by_default = false
7
+
8
+ include DistinctValues
6
9
 
7
10
  connects_to(**SolidQueue.connects_to) if SolidQueue.connects_to
8
11
 
@@ -21,6 +24,16 @@ module SolidQueue
21
24
  end
22
25
  end
23
26
 
27
+ def warn_about_pending_migrations
28
+ SolidQueue.deprecator.warn(<<~DEPRECATION)
29
+ Solid Queue has pending database migrations. To get the new migration files, run:
30
+ rails solid_queue:update
31
+ And then:
32
+ rails db:migrate
33
+ These migrations will be required after version #{SolidQueue.next_major_version}.0
34
+ DEPRECATION
35
+ end
36
+
24
37
  # Pass index hints to the query optimizer using SQL comment hints.
25
38
  # Uses MySQL 8 optimizer hint query comments, which SQLite and
26
39
  # PostgreSQL ignore.
@@ -56,16 +56,17 @@ module SolidQueue
56
56
  end
57
57
  end
58
58
 
59
- def delay_from_now
60
- [ (next_time - Time.current).to_f, 0.1 ].max
59
+
60
+ def next_time_after(time)
61
+ parsed_schedule_with_time_zone.next_time(time).utc
61
62
  end
62
63
 
63
64
  def next_time
64
- parsed_schedule.next_time.utc
65
+ parsed_schedule_with_time_zone.next_time.utc
65
66
  end
66
67
 
67
68
  def previous_time
68
- parsed_schedule.previous_time.utc
69
+ parsed_schedule_with_time_zone.previous_time.utc
69
70
  end
70
71
 
71
72
  def last_enqueued_time
@@ -85,6 +86,7 @@ module SolidQueue
85
86
 
86
87
  perform_later.tap do |job|
87
88
  unless job.successfully_enqueued?
89
+ report_enqueue_error(job.enqueue_error, at: at)
88
90
  payload[:enqueue_error] = job.enqueue_error&.message
89
91
  end
90
92
  end
@@ -97,6 +99,7 @@ module SolidQueue
97
99
  payload[:skipped] = true
98
100
  false
99
101
  rescue Job::EnqueueError => error
102
+ report_enqueue_error(error, at: at)
100
103
  payload[:enqueue_error] = error.message
101
104
  false
102
105
  end
@@ -168,11 +171,24 @@ module SolidQueue
168
171
  end
169
172
  end
170
173
 
174
+ def parsed_schedule_with_time_zone
175
+ @parsed_schedule_with_time_zone ||= apply_default_time_zone_to(parsed_schedule)
176
+ end
171
177
 
172
178
  def parsed_schedule
173
179
  @parsed_schedule ||= Fugit.parse(schedule, multi: :fail)
174
180
  end
175
181
 
182
+ def apply_default_time_zone_to(schedule)
183
+ if schedule.respond_to?(:zone) && schedule.zone.nil? && default_time_zone.present?
184
+ Fugit.parse("#{schedule.to_cron_s} #{default_time_zone}", multi: :fail)
185
+ else
186
+ schedule
187
+ end
188
+ rescue ArgumentError
189
+ schedule
190
+ end
191
+
176
192
  def job_class
177
193
  @job_class ||= class_name.present? ? class_name.safe_constantize : self.class.default_job_class
178
194
  end
@@ -180,5 +196,15 @@ module SolidQueue
180
196
  def enqueue_options
181
197
  { queue: queue_name, priority: priority }.compact
182
198
  end
199
+
200
+ def default_time_zone
201
+ SolidQueue.time_zone
202
+ end
203
+
204
+ def report_enqueue_error(error, at:)
205
+ if error
206
+ Rails.error.report(error, handled: true, source: "application.solid_queue", context: { task: key, at: at })
207
+ end
208
+ end
183
209
  end
184
210
  end
@@ -32,7 +32,13 @@ module SolidQueue
32
32
 
33
33
  class Proxy
34
34
  def self.signal_all(jobs)
35
- Semaphore.where(key: jobs.map(&:concurrency_key)).update_all("value = value + 1")
35
+ # Guard against incrementing a semaphore's value beyond its limit. Jobs can
36
+ # have different limits, so group them and cap each group with `value < limit`.
37
+ jobs.group_by { |job| job.concurrency_limit || 1 }.each do |limit, grouped_jobs|
38
+ Semaphore.where(key: grouped_jobs.map(&:concurrency_key))
39
+ .where(value: ...limit)
40
+ .update_all("value = value + 1")
41
+ end
36
42
  end
37
43
 
38
44
  def initialize(job)
@@ -8,9 +8,17 @@ module ActiveJob
8
8
  #
9
9
  # Rails.application.config.active_job.queue_adapter = :solid_queue
10
10
  class SolidQueueAdapter < (Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR == 1 ? Object : AbstractAdapter)
11
- class_attribute :stopping, default: false, instance_writer: false
11
+ class_attribute :stopping, default: false, instance_writer: false, instance_predicate: false
12
12
  SolidQueue.on_worker_stop { self.stopping = true }
13
13
 
14
+ # Accept an optional job argument for compatibility with Rails main, which
15
+ # began passing the running job to +queue_adapter.stopping?+ so adapters can
16
+ # decide whether to checkpoint based on it. We rely solely on the worker
17
+ # shutdown flag, so the argument is ignored.
18
+ def stopping?(_job = nil)
19
+ self.class.stopping
20
+ end
21
+
14
22
  def enqueue_after_transaction_commit?
15
23
  true
16
24
  end
@@ -6,7 +6,7 @@ default: &default
6
6
  - queues: "*"
7
7
  threads: 3
8
8
  processes: <%%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
- polling_interval: 0.1
9
+ polling_interval: 1
10
10
 
11
11
  development:
12
12
  <<: *default
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ class SolidQueue::UpdateGenerator < Rails::Generators::Base
6
+ include ActiveRecord::Generators::Migration
7
+
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :database, type: :string, aliases: %i[ --db ], default: "queue",
11
+ desc: "The database that Solid Queue uses. Defaults to `queue`"
12
+
13
+ def copy_new_migrations
14
+ Dir.glob(File.join(self.class.source_root, "db", "*.rb")).each do |migration_file|
15
+ name = File.basename(migration_file)
16
+ migration_template File.join("db", name), File.join(db_migrate_path, name), skip: true
17
+ end
18
+ end
19
+ end
@@ -20,6 +20,10 @@ module SolidQueue
20
20
  desc: "Whether to skip recurring tasks scheduling",
21
21
  banner: "SOLID_QUEUE_SKIP_RECURRING"
22
22
 
23
+ class_option :only_recurring, type: :boolean,
24
+ desc: "Whether to run only the scheduler process for recurring tasks",
25
+ banner: "SOLID_QUEUE_ONLY_RECURRING"
26
+
23
27
  def self.exit_on_failure?
24
28
  true
25
29
  end
@@ -30,5 +34,11 @@ module SolidQueue
30
34
  def start
31
35
  SolidQueue::Supervisor.start(**options.symbolize_keys)
32
36
  end
37
+
38
+ desc :check, "Validates the Solid Queue configuration for the current Rails env without starting anything. Exits non-zero on errors."
39
+ def check
40
+ configuration = SolidQueue::Configuration.new(**options.symbolize_keys)
41
+ exit 1 unless configuration.check
42
+ end
33
43
  end
34
44
  end
@@ -3,10 +3,12 @@
3
3
  module SolidQueue
4
4
  class Configuration
5
5
  include ActiveModel::Model
6
+ include ActiveModel::Validations::Callbacks
6
7
 
7
- validate :ensure_configured_processes
8
- validate :ensure_valid_recurring_tasks
9
- validate :ensure_correctly_sized_thread_pool
8
+ validate :ensure_configured_processes, :ensure_valid_recurring_tasks
9
+ validate :warn_about_incorrectly_sized_thread_pool, :warn_about_missing_config_files
10
+
11
+ before_validation { warnings.clear }
10
12
 
11
13
  class Process < Struct.new(:kind, :attributes)
12
14
  def instantiate
@@ -41,32 +43,41 @@ module SolidQueue
41
43
  end
42
44
 
43
45
  def configured_processes
44
- if only_work? then workers
46
+ if only_work?
47
+ workers
48
+ elsif only_recurring?
49
+ schedulers
45
50
  else
46
51
  dispatchers + workers + schedulers
47
52
  end
48
53
  end
49
54
 
50
- def error_messages
51
- if configured_processes.none?
52
- "No workers or processed configured. Exiting..."
53
- else
54
- error_messages = invalid_tasks.map do |task|
55
- all_messages = task.errors.full_messages.map { |msg| "\t#{msg}" }.join("\n")
56
- "#{task.key}:\n#{all_messages}"
57
- end
58
- .join("\n")
55
+ def mode
56
+ options[:mode].to_s.inquiry
57
+ end
59
58
 
60
- "Invalid processes configured:\n#{error_messages}"
61
- end
59
+ def standalone?
60
+ mode.fork? || options[:standalone]
62
61
  end
63
62
 
64
- def mode
65
- @options[:mode].to_s.inquiry
63
+ def warnings
64
+ @warnings ||= ActiveModel::Errors.new(self)
66
65
  end
67
66
 
68
- def standalone?
69
- mode.fork? || @options[:standalone]
67
+ def check
68
+ if valid?
69
+ warnings.full_messages.each { |warning| $stderr.puts warning }
70
+ $stdout.puts "Solid Queue configuration is valid."
71
+
72
+ true
73
+ else
74
+ $stderr.puts "Solid Queue configuration is invalid:"
75
+ (warnings.full_messages + errors.full_messages).each do |message|
76
+ message.each_line { |line| $stderr.puts " #{line.chomp}" }
77
+ end
78
+
79
+ false
80
+ end
70
81
  end
71
82
 
72
83
  private
@@ -88,11 +99,26 @@ module SolidQueue
88
99
  end
89
100
  end
90
101
 
91
- def ensure_correctly_sized_thread_pool
92
- if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads
93
- errors.add(:base, "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " +
102
+ def warn_about_incorrectly_sized_thread_pool
103
+ db_pool_size = SolidQueue::Record.connection_pool&.size
104
+
105
+ if db_pool_size && db_pool_size < estimated_number_of_threads
106
+ warnings.add(:base, "Warning: Solid Queue is configured to use #{estimated_number_of_threads} threads but the " \
94
107
  "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
95
108
  end
109
+ rescue ActiveRecord::ActiveRecordError
110
+ # No usable database connection. Skip the pool-size warning in that case.
111
+ end
112
+
113
+ def warn_about_missing_config_files
114
+ files = [ options[:config_file] ]
115
+ files << options[:recurring_schedule_file] unless skip_recurring_tasks?
116
+
117
+ files.compact.each do |file|
118
+ unless Pathname.new(file).exist?
119
+ warnings.add(:base, "Warning: provided configuration file '#{file}' does not exist. Falling back to default configuration.")
120
+ end
121
+ end
96
122
  end
97
123
 
98
124
  def default_options
@@ -103,6 +129,7 @@ module SolidQueue
103
129
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
104
130
  only_work: false,
105
131
  only_dispatch: false,
132
+ only_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_RECURRING"]),
106
133
  skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"])
107
134
  }
108
135
  end
@@ -119,6 +146,10 @@ module SolidQueue
119
146
  options[:only_dispatch]
120
147
  end
121
148
 
149
+ def only_recurring?
150
+ options[:only_recurring]
151
+ end
152
+
122
153
  def skip_recurring_tasks?
123
154
  options[:skip_recurring] || only_work?
124
155
  end
@@ -221,7 +252,6 @@ module SolidQueue
221
252
  if file.exist?
222
253
  ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
223
254
  else
224
- puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration."
225
255
  {}
226
256
  end
227
257
  end
@@ -16,6 +16,12 @@ module SolidQueue
16
16
  end
17
17
  end
18
18
 
19
+ initializer "solid_queue.time_zone" do |app|
20
+ unless config.solid_queue.key?(:time_zone)
21
+ SolidQueue.time_zone = app.config.time_zone
22
+ end
23
+ end
24
+
19
25
  initializer "solid_queue.app_executor", before: :run_prepare_callbacks do |app|
20
26
  config.solid_queue.app_executor ||= app.executor
21
27
  config.solid_queue.on_thread_error ||= ->(exception) { Rails.error.report(exception, handled: false) }
@@ -37,5 +43,9 @@ module SolidQueue
37
43
  include ActiveJob::ConcurrencyControls
38
44
  end
39
45
  end
46
+
47
+ initializer "solid_queue.deprecator" do |app|
48
+ app.deprecators[:solid_queue] = SolidQueue.deprecator
49
+ end
40
50
  end
41
51
  end
@@ -31,6 +31,21 @@ module SolidQueue
31
31
 
32
32
  replace_fork(pid, status)
33
33
  end
34
+
35
+ check_boot_timeouts
36
+ end
37
+
38
+ def check_boot_timeouts
39
+ process_instances.each do |pid, instance|
40
+ terminate_unready_process(pid) if instance.boot_timed_out?
41
+ end
42
+ end
43
+
44
+ def terminate_unready_process(pid)
45
+ SolidQueue.instrument(:fork_boot_timeout, process: process_instances[pid], pid: pid) do
46
+ # A child stuck in boot cannot reach its run loop to stop gracefully
47
+ signal_process(pid, :KILL)
48
+ end
34
49
  end
35
50
 
36
51
  def reap_terminated_forks
@@ -38,9 +53,13 @@ module SolidQueue
38
53
  pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
39
54
  break unless pid
40
55
 
41
- if (terminated_fork = process_instances.delete(pid)) && (!status.exited? || status.exitstatus.to_i > 0)
42
- error = Processes::ProcessExitError.new(status)
43
- release_claimed_jobs_by(terminated_fork, with_error: error)
56
+ if terminated_fork = process_instances.delete(pid)
57
+ terminated_fork.mark_as_reaped
58
+
59
+ if !status.exited? || status.exitstatus.to_i > 0
60
+ error = Processes::ProcessExitError.new(status)
61
+ release_claimed_jobs_by(terminated_fork, with_error: error)
62
+ end
44
63
  end
45
64
 
46
65
  configured_processes.delete(pid)
@@ -52,6 +71,7 @@ module SolidQueue
52
71
  def replace_fork(pid, status)
53
72
  SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
54
73
  if terminated_fork = process_instances.delete(pid)
74
+ terminated_fork.mark_as_reaped
55
75
  payload[:fork] = terminated_fork
56
76
  error = Processes::ProcessExitError.new(status)
57
77
  release_claimed_jobs_by(terminated_fork, with_error: error)
@@ -161,6 +161,11 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
161
161
  end
162
162
  end
163
163
 
164
+ def fork_boot_timeout(event)
165
+ process = event.payload[:process]
166
+ warn formatted_event(event, action: "Terminate #{process.kind} that failed to boot in time", **event.payload.slice(:pid).merge(hostname: process.hostname, name: process.name))
167
+ end
168
+
164
169
  private
165
170
  def formatted_event(event, action:, **attributes)
166
171
  "SolidQueue-#{SolidQueue::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
@@ -4,7 +4,9 @@ module SolidQueue::Processes
4
4
  module Runnable
5
5
  include Supervised
6
6
 
7
- attr_writer :mode
7
+ def mode=(value)
8
+ @mode = (value || DEFAULT_MODE).to_s.inquiry
9
+ end
8
10
 
9
11
  def start
10
12
  run_in_mode do
@@ -29,21 +31,32 @@ module SolidQueue::Processes
29
31
  !running_async? || @thread&.alive?
30
32
  end
31
33
 
34
+ def boot_timed_out?
35
+ @boot_guard.timed_out?
36
+ end
37
+
38
+ def mark_as_reaped
39
+ @boot_guard.close
40
+ end
41
+
32
42
  private
33
43
  DEFAULT_MODE = :async
34
44
 
35
45
  def mode
36
- (@mode || DEFAULT_MODE).to_s.inquiry
46
+ @mode ||= DEFAULT_MODE.to_s.inquiry
37
47
  end
38
48
 
39
49
  def run_in_mode(&block)
40
50
  case
41
51
  when running_as_fork?
42
- fork(&block)
52
+ @boot_guard = BootGuards::ForkGuard.new
53
+ fork(&block).tap { @boot_guard.start }
43
54
  when running_async?
55
+ @boot_guard = BootGuards::NullGuard.new
44
56
  @thread = create_thread(&block)
45
57
  @thread.object_id
46
58
  else
59
+ @boot_guard = BootGuards::NullGuard.new
47
60
  block.call
48
61
  end
49
62
  end
@@ -57,6 +70,8 @@ module SolidQueue::Processes
57
70
  end
58
71
  end
59
72
  end
73
+
74
+ @boot_guard.complete
60
75
  end
61
76
 
62
77
  def shutting_down?
@@ -93,4 +108,76 @@ module SolidQueue::Processes
93
108
  mode.fork?
94
109
  end
95
110
  end
111
+
112
+ module BootGuards
113
+ # Tracks a process that shares memory with its supervisor, whose boot time
114
+ # doesn't need monitoring.
115
+ class NullGuard
116
+ def complete
117
+ @completed = true
118
+ end
119
+
120
+ def start
121
+ end
122
+
123
+ def completed?
124
+ @completed
125
+ end
126
+
127
+ def timed_out?
128
+ false
129
+ end
130
+
131
+ def close
132
+ end
133
+ end
134
+
135
+ # Tracks a forked process from the moment it's started until its boot
136
+ # callbacks finish, over a pipe that survives forking: the forked process
137
+ # writes to it when it's done booting, and its supervisor reads from it to
138
+ # decide whether the process is taking too long to boot and needs replacing.
139
+ class ForkGuard
140
+ def initialize
141
+ @reader, @writer = IO.pipe
142
+ @created_at = SolidQueue::Timer.monotonic_time_now
143
+ end
144
+
145
+ # Runs in the forked process when it has finished booting
146
+ def complete
147
+ reader.close
148
+ writer.write(".")
149
+ rescue Errno::EPIPE
150
+ # The supervisor stopped waiting while this process finished booting
151
+ ensure
152
+ writer.close
153
+ end
154
+
155
+ # Runs in the parent process right after forking
156
+ def start
157
+ writer.close
158
+ end
159
+
160
+ # A byte means boot completed; EOF means the process exited before
161
+ # finishing its boot, and will be replaced when it's reaped
162
+ def completed?
163
+ @completed ||= begin
164
+ completed = reader.read_nonblock(1, exception: false) != :wait_readable
165
+ reader.close if completed
166
+ completed
167
+ end
168
+ end
169
+
170
+ def timed_out?
171
+ !completed? && SolidQueue::Timer.monotonic_time_now - created_at >= SolidQueue.fork_boot_timeout
172
+ end
173
+
174
+ def close
175
+ reader.close unless reader.closed?
176
+ writer.close unless writer.closed?
177
+ end
178
+
179
+ private
180
+ attr_reader :reader, :writer, :created_at
181
+ end
182
+ end
96
183
  end
@@ -33,8 +33,8 @@ module SolidQueue
33
33
  end
34
34
  end
35
35
 
36
- def schedule_task(task)
37
- scheduled_tasks[task.key] = schedule(task)
36
+ def schedule_task(task, run_at: task.next_time)
37
+ scheduled_tasks[task.key] = schedule(task, run_at: run_at)
38
38
  end
39
39
 
40
40
  def unschedule_tasks
@@ -99,9 +99,11 @@ module SolidQueue
99
99
  dynamic_tasks_enabled? ? RecurringTask.dynamic.to_a : []
100
100
  end
101
101
 
102
- def schedule(task)
103
- scheduled_task = Concurrent::ScheduledTask.new(task.delay_from_now, args: [ self, task, task.next_time ]) do |thread_schedule, thread_task, thread_task_run_at|
104
- thread_schedule.schedule_task(thread_task)
102
+ def schedule(task, run_at: task.next_time)
103
+ delay = [ (run_at - Time.current).to_f, 0.1 ].max
104
+
105
+ scheduled_task = Concurrent::ScheduledTask.new(delay, args: [ self, task, run_at ]) do |thread_schedule, thread_task, thread_task_run_at|
106
+ thread_schedule.schedule_task(thread_task, run_at: thread_task.next_time_after(thread_task_run_at))
105
107
 
106
108
  wrap_in_app_executor do
107
109
  thread_task.enqueue(at: thread_task_run_at)
@@ -11,7 +11,7 @@ module SolidQueue
11
11
  end
12
12
 
13
13
  private
14
- SIGNALS = %i[ QUIT INT TERM ]
14
+ SIGNALS = Gem.win_platform? ? %i[ INT TERM ] : %i[ QUIT INT TERM ]
15
15
 
16
16
  def register_signal_handlers
17
17
  SIGNALS.each do |signal|
@@ -13,6 +13,8 @@ module SolidQueue
13
13
  configuration = Configuration.new(**options)
14
14
 
15
15
  if configuration.valid?
16
+ configuration.warnings.full_messages.each { |warning| SolidQueue.logger.warn(warning) }
17
+
16
18
  klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor
17
19
  klass.new(configuration).tap(&:start)
18
20
  else
@@ -4,8 +4,19 @@ namespace :solid_queue do
4
4
  Rails::Command.invoke :generate, [ "solid_queue:install" ]
5
5
  end
6
6
 
7
+ desc "Copy any new Solid Queue migrations to the application"
8
+ task :update do
9
+ Rails::Command.invoke :generate, [ "solid_queue:update" ]
10
+ end
11
+
7
12
  desc "start solid_queue supervisor to dispatch and process jobs"
8
13
  task start: :environment do
9
14
  SolidQueue::Supervisor.start
10
15
  end
16
+
17
+ desc "validate the Solid Queue configuration for the current Rails env without starting any process"
18
+ task check: :environment do
19
+ configuration = SolidQueue::Configuration.new
20
+ exit 1 unless configuration.check
21
+ end
11
22
  end
@@ -20,9 +20,8 @@ module SolidQueue
20
20
  end
21
21
  end
22
22
 
23
- private
24
- def monotonic_time_now
25
- ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
26
- end
23
+ def monotonic_time_now
24
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
25
+ end
27
26
  end
28
27
  end
@@ -1,3 +1,7 @@
1
1
  module SolidQueue
2
- VERSION = "1.4.0"
2
+ VERSION = "1.5.1"
3
+
4
+ def self.next_major_version
5
+ Gem::Version.new(VERSION).segments.first + 1
6
+ end
3
7
  end
data/lib/solid_queue.rb CHANGED
@@ -29,6 +29,7 @@ module SolidQueue
29
29
 
30
30
  mattr_accessor :process_heartbeat_interval, default: 60.seconds
31
31
  mattr_accessor :process_alive_threshold, default: 5.minutes
32
+ mattr_accessor :fork_boot_timeout, default: 5.minutes
32
33
 
33
34
  mattr_accessor :shutdown_timeout, default: 5.seconds
34
35
 
@@ -41,6 +42,15 @@ module SolidQueue
41
42
  mattr_accessor :clear_finished_jobs_after, default: 1.day
42
43
  mattr_accessor :default_concurrency_control_period, default: 3.minutes
43
44
 
45
+ mattr_reader :time_zone
46
+
47
+ def time_zone=(zone)
48
+ @@time_zone = if zone
49
+ resolved = zone.respond_to?(:tzinfo) ? zone : ActiveSupport::TimeZone[zone]
50
+ resolved&.tzinfo&.name || zone.to_s
51
+ end
52
+ end
53
+
44
54
  delegate :on_start, :on_stop, :on_exit, to: Supervisor
45
55
 
46
56
  def schedule_recurring_task(key, **options)
@@ -77,6 +87,10 @@ module SolidQueue
77
87
  preserve_finished_jobs
78
88
  end
79
89
 
90
+ def deprecator
91
+ @deprecator ||= ActiveSupport::Deprecation.new(next_major_version, "SolidQueue")
92
+ end
93
+
80
94
  def instrument(channel, **options, &block)
81
95
  ActiveSupport::Notifications.instrument("#{channel}.solid_queue", **options, &block)
82
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-20 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -294,6 +294,7 @@ files:
294
294
  - app/models/solid_queue/queue_selector.rb
295
295
  - app/models/solid_queue/ready_execution.rb
296
296
  - app/models/solid_queue/record.rb
297
+ - app/models/solid_queue/record/distinct_values.rb
297
298
  - app/models/solid_queue/recurring_execution.rb
298
299
  - app/models/solid_queue/recurring_task.rb
299
300
  - app/models/solid_queue/recurring_task/arguments.rb
@@ -308,6 +309,7 @@ files:
308
309
  - lib/generators/solid_queue/install/templates/config/queue.yml
309
310
  - lib/generators/solid_queue/install/templates/config/recurring.yml
310
311
  - lib/generators/solid_queue/install/templates/db/queue_schema.rb
312
+ - lib/generators/solid_queue/update/update_generator.rb
311
313
  - lib/puma/plugin/solid_queue.rb
312
314
  - lib/solid_queue.rb
313
315
  - lib/solid_queue/app_executor.rb
@@ -360,7 +362,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
360
362
  requirements:
361
363
  - - ">="
362
364
  - !ruby/object:Gem::Version
363
- version: '3.1'
365
+ version: '3.2'
364
366
  required_rubygems_version: !ruby/object:Gem::Requirement
365
367
  requirements:
366
368
  - - ">="