solid_queue 1.5.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: d40f055103084c9aaa9efe6ded0e675ff967a88200bf9f5579b1287e29d48818
4
- data.tar.gz: af6ab85d5c674dbceff32768b5aa7b4d87bc1aeea24476e3fb469d639c4fad46
3
+ metadata.gz: 82eb2e9041fa69b3e7f3bd4a3d62c46b7826100a708b2237750f0f9e4fdee9c5
4
+ data.tar.gz: 14000eeba6030e0248273d4cc158e17f25f78268d5e2c62c5e74f9a959f8d94f
5
5
  SHA512:
6
- metadata.gz: 926a7f82ebca8389d5d6d6116bd6539d65715704e739f66083f1e21411737da72972f4b643bd4291e95ef5bb107417cb11fde6c535d6e4013434ac589cc2fc6e
7
- data.tar.gz: 684e5f32bbe7aa4ce0645687a92c2538016431151208156ec144b574e1d48e8d04144f28a2eacca86ff5bd4449a2c6bdd439bdcd74bdf72788ae4430abd284c2
6
+ metadata.gz: 9bb7b225b9a3b1b13e1f2edfa04927cb2f3954d0a0b226ca79e7fc69dff6675c002ac5e6dd13fd0ff9794d2f0a660487a70cb078868034e7a048378b14911a4f
7
+ data.tar.gz: b30ef8f4992fc7a3d858294b3ae9b1c4459f813d9ec3bb3ff4a4fde97ad114469d76d913bb19912117de3d081e6c8d159798e5bcdd0790858ea3af6f4faf7cc6
data/README.md CHANGED
@@ -214,6 +214,8 @@ bin/jobs -c config/calendar.yml
214
214
 
215
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`.
216
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
+
217
219
  This is what this configuration looks like:
218
220
 
219
221
  ```yml
@@ -404,6 +406,7 @@ There are several settings that control how Solid Queue works that you can set a
404
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.
405
407
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
406
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.
407
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.
408
411
  - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`.
409
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.
@@ -709,6 +712,8 @@ bin/jobs --recurring_schedule_file=config/schedule.yml
709
712
 
710
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`.
711
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
+
712
717
  The configuration itself looks like this:
713
718
 
714
719
  ```yml
@@ -19,23 +19,25 @@ module SolidQueue
19
19
 
20
20
  private
21
21
  def loose_index_scan_emulation_needed?
22
- connection.adapter_name == "PostgreSQL"
22
+ connection_pool.with_connection { |connection| connection.adapter_name == "PostgreSQL" }
23
23
  end
24
24
 
25
25
  # Emulates a loose index scan, honoring the current scope (e.g. LIKE prefixes)
26
26
  # by building the anchor and the recursive step as scoped relations, whose
27
27
  # #to_sql inlines any bind parameters so they can be embedded in the raw CTE.
28
28
  def loose_distinct_via_recursive_cte(column)
29
- col = connection.quote_column_name(column)
29
+ connection_pool.with_connection do |connection|
30
+ col = connection.quote_column_name(column)
30
31
 
31
- connection.select_values(<<~SQL.squish)
32
- WITH RECURSIVE t AS (
33
- (#{next_distinct_value(col, "#{col} IS NOT NULL")})
34
- UNION ALL
35
- SELECT (#{next_distinct_value(col, "#{col} > t.#{col}")}) FROM t WHERE t.#{col} IS NOT NULL
36
- )
37
- SELECT #{col} FROM t WHERE #{col} IS NOT NULL
38
- SQL
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
39
41
  end
40
42
 
41
43
  # Smallest value of `col` within the current scope that matches `condition`.
@@ -24,6 +24,16 @@ module SolidQueue
24
24
  end
25
25
  end
26
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
+
27
37
  # Pass index hints to the query optimizer using SQL comment hints.
28
38
  # Uses MySQL 8 optimizer hint query comments, which SQLite and
29
39
  # PostgreSQL ignore.
@@ -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
@@ -43,7 +43,10 @@ module SolidQueue
43
43
  end
44
44
 
45
45
  def configured_processes
46
- if only_work? then workers
46
+ if only_work?
47
+ workers
48
+ elsif only_recurring?
49
+ schedulers
47
50
  else
48
51
  dispatchers + workers + schedulers
49
52
  end
@@ -126,6 +129,7 @@ module SolidQueue
126
129
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
127
130
  only_work: false,
128
131
  only_dispatch: false,
132
+ only_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_RECURRING"]),
129
133
  skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"])
130
134
  }
131
135
  end
@@ -142,6 +146,10 @@ module SolidQueue
142
146
  options[:only_dispatch]
143
147
  end
144
148
 
149
+ def only_recurring?
150
+ options[:only_recurring]
151
+ end
152
+
145
153
  def skip_recurring_tasks?
146
154
  options[:skip_recurring] || only_work?
147
155
  end
@@ -43,5 +43,9 @@ module SolidQueue
43
43
  include ActiveJob::ConcurrencyControls
44
44
  end
45
45
  end
46
+
47
+ initializer "solid_queue.deprecator" do |app|
48
+ app.deprecators[:solid_queue] = SolidQueue.deprecator
49
+ end
46
50
  end
47
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)}"
@@ -31,6 +31,14 @@ module SolidQueue::Processes
31
31
  !running_async? || @thread&.alive?
32
32
  end
33
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
+
34
42
  private
35
43
  DEFAULT_MODE = :async
36
44
 
@@ -41,11 +49,14 @@ module SolidQueue::Processes
41
49
  def run_in_mode(&block)
42
50
  case
43
51
  when running_as_fork?
44
- fork(&block)
52
+ @boot_guard = BootGuards::ForkGuard.new
53
+ fork(&block).tap { @boot_guard.start }
45
54
  when running_async?
55
+ @boot_guard = BootGuards::NullGuard.new
46
56
  @thread = create_thread(&block)
47
57
  @thread.object_id
48
58
  else
59
+ @boot_guard = BootGuards::NullGuard.new
49
60
  block.call
50
61
  end
51
62
  end
@@ -59,6 +70,8 @@ module SolidQueue::Processes
59
70
  end
60
71
  end
61
72
  end
73
+
74
+ @boot_guard.complete
62
75
  end
63
76
 
64
77
  def shutting_down?
@@ -95,4 +108,76 @@ module SolidQueue::Processes
95
108
  mode.fork?
96
109
  end
97
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
98
183
  end
@@ -4,6 +4,11 @@ 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
@@ -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.5.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
 
@@ -86,6 +87,10 @@ module SolidQueue
86
87
  preserve_finished_jobs
87
88
  end
88
89
 
90
+ def deprecator
91
+ @deprecator ||= ActiveSupport::Deprecation.new(next_major_version, "SolidQueue")
92
+ end
93
+
89
94
  def instrument(channel, **options, &block)
90
95
  ActiveSupport::Notifications.instrument("#{channel}.solid_queue", **options, &block)
91
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.5.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-07-23 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
@@ -309,6 +309,7 @@ files:
309
309
  - lib/generators/solid_queue/install/templates/config/queue.yml
310
310
  - lib/generators/solid_queue/install/templates/config/recurring.yml
311
311
  - lib/generators/solid_queue/install/templates/db/queue_schema.rb
312
+ - lib/generators/solid_queue/update/update_generator.rb
312
313
  - lib/puma/plugin/solid_queue.rb
313
314
  - lib/solid_queue.rb
314
315
  - lib/solid_queue/app_executor.rb