solid_queue 1.5.0 → 1.6.0

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: d6cda6edb92a7805d6c2c0fc13b2649c2c2df26a4d65e555049efde022cb8d14
4
+ data.tar.gz: c6d0036cbeb743e56faa4ed9fe6756f1a8e3ae3705a402f55440bb14cb3174dc
5
5
  SHA512:
6
- metadata.gz: 926a7f82ebca8389d5d6d6116bd6539d65715704e739f66083f1e21411737da72972f4b643bd4291e95ef5bb107417cb11fde6c535d6e4013434ac589cc2fc6e
7
- data.tar.gz: 684e5f32bbe7aa4ce0645687a92c2538016431151208156ec144b574e1d48e8d04144f28a2eacca86ff5bd4449a2c6bdd439bdcd74bdf72788ae4430abd284c2
6
+ metadata.gz: 5f7420684f8aa122d12e2314aa2445fb31a3f18f2a7bc53b4ceffccccd131a7f6d9408ef776da33dbbfe437be4c835437524279ac6150e57d134cf85698caf7a
7
+ data.tar.gz: cd02de9753ce345ec599d72230f634df0e633f278871df5da4330318fcb92b8820d99612f43fb8d89be348460d1cb198a5bfab34fdce5c6f639709ab8dd426a2
data/README.md CHANGED
@@ -204,6 +204,10 @@ Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `a
204
204
 
205
205
  **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
206
206
 
207
+ This supervisor mode is separate from a worker's concurrency model. Supervisor mode decides whether supervised processes live in forks or threads. Worker configuration decides whether claimed jobs run in a thread pool (`threads: N`) or as fibers on a single fiber reactor thread (`fibers: N`).
208
+
209
+ Because these are separate concerns, you can combine the default `fork` supervisor mode with fiber workers. In that setup, each worker process gets its own fiber reactor and bounded fiber count.
210
+
207
211
  ## Configuration
208
212
 
209
213
  By default, Solid Queue will try to find your configuration under `config/queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG` or by using the `-c/--config_file` option with `bin/jobs`, like this:
@@ -214,6 +218,8 @@ bin/jobs -c config/calendar.yml
214
218
 
215
219
  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
220
 
221
+ 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`.
222
+
217
223
  This is what this configuration looks like:
218
224
 
219
225
  ```yml
@@ -230,6 +236,9 @@ production:
230
236
  threads: 5
231
237
  polling_interval: 0.1
232
238
  processes: 3
239
+ - queues: "api*"
240
+ fibers: 100
241
+ polling_interval: 0.05
233
242
  scheduler:
234
243
  dynamic_tasks_enabled: true
235
244
  polling_interval: 5
@@ -272,9 +281,11 @@ Here's an overview of the different options:
272
281
 
273
282
  Check the sections below on [how queue order behaves combined with priorities](#queue-order-and-priorities), and [how the way you specify the queues per worker might affect performance](#queues-specification-and-performance).
274
283
 
275
- - `threads`: this is the max size of the thread pool that each worker will have to run jobs. Each worker will fetch this number of jobs from their queue(s), at most and will post them to the thread pool to be run. By default, this is `3`. Only workers have this setting.
276
- It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker thread uses one connection, and two additional connections are reserved for polling and heartbeat.
277
- - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. **Note**: this option will be ignored if [running in `async` mode](#fork-vs-async-mode).
284
+ - `threads`: configures a worker to execute jobs in a thread pool of this size. By default, workers use `threads: 3`. Only workers have this setting, and it can't be combined with `fibers`.
285
+ It is recommended to set this value less than or equal to the queue database's connection pool size minus 2, as each worker uses connections for polling and heartbeat and thread mode may use additional connections for job execution.
286
+ - `fibers`: configures a worker to execute jobs as fibers on a single fiber reactor thread, with this value as the maximum number of in-flight jobs. It can't be combined with `threads`.
287
+ Fiber workers require fiber-scoped isolated execution state. In Rails apps, set `config.active_support.isolation_level = :fiber` before using `fibers`. Solid Queue refuses to boot fiber workers when isolation remains thread-scoped. On Rails 7.2 and later, a practical starting point is usually `3-5` queue database connections per worker process rather than matching the `fibers` value, because ordinary Active Record query paths can release connections between non-blocking waits. On Rails 7.1, size the queue database pool more conservatively, as in-flight fiber jobs may still retain connections roughly in proportion to `fibers`.
288
+ - `processes`: this is the number of worker processes that will be forked by the supervisor with the settings given. By default, this is `1`, just a single process. This setting is useful if you want to dedicate more than one CPU core to a queue or queues with the same configuration. Only workers have this setting. This works with both `threads` and `fibers` workers as long as the supervisor is running in the default `fork` mode. **Note**: this option is ignored only when the supervisor itself is [running in `async` mode](#fork-vs-async-mode).
278
289
  - `concurrency_maintenance`: whether the dispatcher will perform the concurrency maintenance work. This is `true` by default, and it's useful if you don't use any [concurrency controls](#concurrency-controls) and want to disable it or if you run multiple dispatchers and want some of them to just dispatch jobs without doing anything else.
279
290
 
280
291
 
@@ -365,7 +376,17 @@ queues: back*
365
376
 
366
377
  ### Threads, processes, and signals
367
378
 
368
- Workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
379
+ By default, workers in Solid Queue use a thread pool to run work in multiple threads, configurable via the `threads` parameter above. Workers can also be configured with `fibers`, in which case claimed jobs are executed as fibers on a single reactor thread and bounded by the worker's fiber count. Besides this, parallelism can be achieved via multiple processes on one machine (configurable via different workers or the `processes` parameter above) or by horizontal scaling.
380
+
381
+ Fiber worker execution is best suited for cooperative, mostly I/O-bound jobs. Blocking or CPU-heavy work still blocks the single reactor thread, so it should not be expected to outperform thread mode for every workload.
382
+
383
+ Because fiber workers run multiple fibers on a single thread, Rails must also isolate execution state per fiber rather than per thread. If your app keeps the default thread-scoped isolation level, Solid Queue will raise a boot-time error instead of running fiber workers with shared Active Record state.
384
+
385
+ Keep in mind that `config.active_support.isolation_level = :fiber` applies to your whole application, not just to Solid Queue: if you run Solid Queue inside Puma via [the plugin](#puma-plugin), or combine fiber workers with thread workers in the same process using the supervisor's `async` mode, everything in that process will use fiber-scoped execution state. This is fully supported by Rails, but it's a global setting worth being deliberate about.
386
+
387
+ On Rails 7.2 and later, fiber workers can often use a much smaller queue database pool than an equivalent thread pool. A practical starting point is `3-5` queue database connections per worker process: one for job execution, one for polling, one for heartbeats, plus some headroom. In the default `fork` supervisor mode, that guidance applies per worker process. In supervisor `async` mode, all workers share one process, so add together the requirements for the workers running there.
388
+
389
+ That lower-pool guidance depends on job code not holding connections open across non-blocking waits. APIs such as `ActiveRecord::Base.connection`, `lease_connection`, `connection_pool.checkout`, or long-lived `with_connection` / transaction blocks can pin connections and push fiber workers back toward thread-like pool usage. On Rails 7.1, plan conservatively and assume the configured fiber count can still grow queue database connection usage.
369
390
 
370
391
  The supervisor is in charge of managing these processes, and it responds to the following signals when running in its own process via `bin/jobs` or with [the Puma plugin](#puma-plugin) with the default `fork` mode:
371
392
  - `TERM`, `INT`: starts graceful termination. The supervisor will send a `TERM` signal to its supervised processes, and it'll wait up to `SolidQueue.shutdown_timeout` time until they're done. If any supervised processes are still around by then, it'll send a `QUIT` signal to them to indicate they must exit.
@@ -377,6 +398,10 @@ On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds
377
398
 
378
399
  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.
379
400
 
401
+ Worker heartbeats are driven by a separate timer task, not by the worker execution backend itself. This means fiber workers do not rely on the reactor loop to prove liveness. However, liveness is still tracked at the worker-process level, not at the individual thread or fiber level.
402
+
403
+ This means finished and failed jobs still follow the normal Solid Queue lifecycle, but a single stuck job can remain claimed if the worker process itself is still alive. If you need stronger stuck-job detection, that requires an explicit timeout or watchdog mechanism on top of process heartbeats.
404
+
380
405
  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.
381
406
 
382
407
 
@@ -392,7 +417,7 @@ _Note_: The settings in this section should be set in your `config/application.r
392
417
 
393
418
  There are several settings that control how Solid Queue works that you can set as well:
394
419
  - `logger`: the logger you want Solid Queue to use. Defaults to the app logger.
395
- - `app_executor`: the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap asynchronous operations, defaults to the app executor
420
+ - `app_executor`: the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap background operations, defaults to the app executor
396
421
  - `on_thread_error`: custom lambda/Proc to call when there's an error within a Solid Queue thread that takes the exception raised as argument. Defaults to
397
422
 
398
423
  ```ruby
@@ -404,6 +429,7 @@ There are several settings that control how Solid Queue works that you can set a
404
429
  - `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
430
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
406
431
  - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
432
+ - `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
433
  - `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
434
  - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`.
409
435
  - `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 +735,8 @@ bin/jobs --recurring_schedule_file=config/schedule.yml
709
735
 
710
736
  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
737
 
738
+ To run only the scheduler (no workers or dispatchers), set `SOLID_QUEUE_ONLY_RECURRING=true` or use `--only-recurring` with `bin/jobs`.
739
+
712
740
  The configuration itself looks like this:
713
741
 
714
742
  ```yml
@@ -802,6 +830,8 @@ SolidQueue.unschedule_recurring_task("my_dynamic_task")
802
830
 
803
831
  Only dynamic tasks can be unscheduled at runtime. Attempting to unschedule a static task (defined in `config/recurring.yml`) will raise an `ActiveRecord::RecordNotFound` error.
804
832
 
833
+ To update an existing dynamic task, unschedule it and then schedule it again with the new options. A running scheduler only detects dynamic tasks being created and deleted, so updating a `SolidQueue::RecurringTask` record in place (for example, changing its `schedule` with `update!`) won't be picked up until the scheduler restarts.
834
+
805
835
  Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
806
836
 
807
837
  ## Inspiration
@@ -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
@@ -6,7 +6,9 @@ module SolidQueue
6
6
  include ActiveModel::Validations::Callbacks
7
7
 
8
8
  validate :ensure_configured_processes, :ensure_valid_recurring_tasks
9
- validate :warn_about_incorrectly_sized_thread_pool, :warn_about_missing_config_files
9
+ validate :ensure_valid_worker_execution_options
10
+ validate :ensure_fiber_workers_have_required_dependency, :ensure_fiber_workers_use_supported_isolation_level
11
+ validate :warn_about_incorrectly_sized_database_pool, :warn_about_missing_config_files
10
12
 
11
13
  before_validation { warnings.clear }
12
14
 
@@ -37,13 +39,17 @@ module SolidQueue
37
39
 
38
40
  DEFAULT_CONFIG_FILE_PATH = "config/queue.yml"
39
41
  DEFAULT_RECURRING_SCHEDULE_FILE_PATH = "config/recurring.yml"
42
+ FIBER_QUERY_SCOPED_CONNECTIONS_VERSION = Gem::Version.new("7.2.0")
40
43
 
41
44
  def initialize(**options)
42
45
  @options = options.with_defaults(default_options)
43
46
  end
44
47
 
45
48
  def configured_processes
46
- if only_work? then workers
49
+ if only_work?
50
+ workers
51
+ elsif only_recurring?
52
+ schedulers
47
53
  else
48
54
  dispatchers + workers + schedulers
49
55
  end
@@ -96,12 +102,12 @@ module SolidQueue
96
102
  end
97
103
  end
98
104
 
99
- def warn_about_incorrectly_sized_thread_pool
105
+ def warn_about_incorrectly_sized_database_pool
100
106
  db_pool_size = SolidQueue::Record.connection_pool&.size
101
107
 
102
- if db_pool_size && db_pool_size < estimated_number_of_threads
103
- warnings.add(:base, "Warning: Solid Queue is configured to use #{estimated_number_of_threads} threads but the " \
104
- "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
108
+ if db_pool_size && db_pool_size < estimated_database_pool_size
109
+ warnings.add(:base, "Warning: Solid Queue needs at least #{estimated_database_pool_size} database connections " \
110
+ "for the configured workers but the database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
105
111
  end
106
112
  rescue ActiveRecord::ActiveRecordError
107
113
  # No usable database connection. Skip the pool-size warning in that case.
@@ -118,6 +124,34 @@ module SolidQueue
118
124
  end
119
125
  end
120
126
 
127
+ def ensure_valid_worker_execution_options
128
+ workers_options.each do |options|
129
+ if options.key?(:threads) && options.key?(:fibers)
130
+ errors.add(:base, "Workers can specify either `threads` or `fibers`, but not both.")
131
+ end
132
+ end
133
+ end
134
+
135
+ def ensure_fiber_workers_have_required_dependency
136
+ return unless workers_options.any? { |options| fiber_worker?(options) }
137
+
138
+ require "async"
139
+ require "async/semaphore"
140
+ rescue LoadError
141
+ errors.add(:base, "Fiber workers require the `async` gem. " \
142
+ "Add `gem \"async\"` to your Gemfile to configure workers with `fibers`.")
143
+ end
144
+
145
+ def ensure_fiber_workers_use_supported_isolation_level
146
+ return unless workers_options.any? { |options| fiber_worker?(options) }
147
+
148
+ unless ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
149
+ errors.add(:base, "Fiber workers require fiber-scoped isolated execution state. " \
150
+ "Set `config.active_support.isolation_level = :fiber` in your Rails configuration " \
151
+ "(or `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber` outside Rails).")
152
+ end
153
+ end
154
+
121
155
  def default_options
122
156
  {
123
157
  mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork,
@@ -126,6 +160,7 @@ module SolidQueue
126
160
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
127
161
  only_work: false,
128
162
  only_dispatch: false,
163
+ only_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_ONLY_RECURRING"]),
129
164
  skip_recurring: ActiveModel::Type::Boolean.new.cast(ENV["SOLID_QUEUE_SKIP_RECURRING"])
130
165
  }
131
166
  end
@@ -142,6 +177,10 @@ module SolidQueue
142
177
  options[:only_dispatch]
143
178
  end
144
179
 
180
+ def only_recurring?
181
+ options[:only_recurring]
182
+ end
183
+
145
184
  def skip_recurring_tasks?
146
185
  options[:skip_recurring] || only_work?
147
186
  end
@@ -154,7 +193,8 @@ module SolidQueue
154
193
  1
155
194
  end
156
195
 
157
- processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
196
+ defaults = worker_defaults_for(worker_options)
197
+ processes.times.map { Process.new(:worker, worker_options.with_defaults(defaults)) }
158
198
  end
159
199
  end
160
200
 
@@ -248,10 +288,42 @@ module SolidQueue
248
288
  end
249
289
  end
250
290
 
251
- def estimated_number_of_threads
252
- # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
253
- thread_count = workers_options.map { |options| options.fetch(:threads, WORKER_DEFAULTS[:threads]) }.max
254
- (thread_count || 1) + 2
291
+ def estimated_database_pool_size
292
+ worker_pool_size = workers_options.map { |options| estimated_database_pool_size_for_worker(options) }.max
293
+ worker_pool_size || 1
294
+ end
295
+
296
+ def estimated_database_pool_size_for_worker(options)
297
+ # Connections used to execute jobs + 1 for the worker's polling thread + 1 for the heartbeat task
298
+ estimated_execution_connections_for_worker(options) + 2
299
+ end
300
+
301
+ def worker_capacity(options)
302
+ options[:fibers] || options[:threads] || WORKER_DEFAULTS[:threads]
303
+ end
304
+
305
+ def estimated_execution_connections_for_worker(options)
306
+ fiber_worker?(options) ? fiber_execution_connections_for_worker(options) : worker_capacity(options)
307
+ end
308
+
309
+ def fiber_execution_connections_for_worker(options)
310
+ fiber_jobs_release_connections_between_queries? ? 1 : worker_capacity(options)
311
+ end
312
+
313
+ def fiber_jobs_release_connections_between_queries?
314
+ ActiveRecord.gem_version >= FIBER_QUERY_SCOPED_CONNECTIONS_VERSION
315
+ end
316
+
317
+ def fiber_worker?(options)
318
+ options.key?(:fibers)
319
+ end
320
+
321
+ def worker_defaults_for(options)
322
+ if fiber_worker?(options)
323
+ WORKER_DEFAULTS.except(:threads)
324
+ else
325
+ WORKER_DEFAULTS
326
+ end
255
327
  end
256
328
  end
257
329
  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
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class FiberPool < Pool
5
+ def initialize(size, on_idle: nil)
6
+ super
7
+
8
+ @state_mutex = Mutex.new
9
+ @shutdown = false
10
+ @fatal_error = nil
11
+ @boot_queue = Thread::Queue.new
12
+ @pending_executions = Thread::Queue.new
13
+ @reactor_thread = nil
14
+ end
15
+
16
+ def post(execution)
17
+ raise_if_fatal_error!
18
+ raise RuntimeError, "Execution pool is shutting down" if shutdown?
19
+
20
+ super
21
+ end
22
+
23
+ def available_capacity
24
+ raise_if_fatal_error!
25
+ super
26
+ end
27
+
28
+ def shutdown
29
+ state_mutex.synchronize do
30
+ next false if @shutdown
31
+
32
+ @shutdown = true
33
+ end.tap do |shut_down|
34
+ # Wake the reactor: already-queued executions are drained before the
35
+ # blocked pop in +wait_for_executions+ returns nil
36
+ pending_executions.close if shut_down
37
+ end
38
+ end
39
+
40
+ def shutdown?
41
+ state_mutex.synchronize { @shutdown }
42
+ end
43
+
44
+ def wait_for_termination(timeout)
45
+ reactor_thread&.join(timeout)
46
+ end
47
+
48
+ private
49
+ attr_reader :boot_queue, :pending_executions, :reactor_thread, :state_mutex
50
+
51
+ def name
52
+ @name ||= "solid_queue-fiber-pool-#{object_id}"
53
+ end
54
+
55
+ def schedule(execution)
56
+ start_reactor_if_needed
57
+ pending_executions << execution
58
+ end
59
+
60
+ # The reactor thread is started lazily, when the first execution is posted,
61
+ # so that the pool can be safely built before forking: in the default fork
62
+ # supervisor mode, workers are instantiated in the supervisor process, and
63
+ # a thread started there wouldn't survive the fork. The async gem is also
64
+ # required lazily here, so that setups without fiber workers never load it.
65
+ def start_reactor_if_needed
66
+ @reactor_thread ||= begin
67
+ require "async"
68
+ require "async/semaphore"
69
+
70
+ start_reactor.tap do
71
+ boot_result = boot_queue.pop
72
+ raise boot_result if boot_result.is_a?(Exception)
73
+ end
74
+ end
75
+ end
76
+
77
+ def start_reactor
78
+ create_thread do
79
+ Async do |task|
80
+ semaphore = Async::Semaphore.new(size, parent: task)
81
+ boot_queue << :ready
82
+
83
+ # The reactor exits when all in-flight execution fibers, children
84
+ # of this task, have finished
85
+ wait_for_executions(semaphore)
86
+ end
87
+ rescue Exception => error
88
+ register_fatal_error(error)
89
+ raise
90
+ end
91
+ end
92
+
93
+ def wait_for_executions(semaphore)
94
+ # Thread::Queue#pop is fiber-scheduler-aware: it suspends this fiber, letting
95
+ # execution fibers run, and wakes the reactor when the poller thread pushes new
96
+ # work or closes the queue on shutdown, after which it drains any remaining
97
+ # executions and returns nil
98
+ while execution = pending_executions.pop
99
+ semaphore.async(execution) do |_execution_task, scheduled_execution|
100
+ perform_execution(scheduled_execution)
101
+ end
102
+ end
103
+ end
104
+
105
+ def perform_execution(execution)
106
+ wrap_in_app_executor { execution.perform }
107
+ rescue Async::Stop => error
108
+ handle_thread_error(error)
109
+ register_fatal_error(error)
110
+ rescue Exception => error
111
+ handle_thread_error(error)
112
+ ensure
113
+ restore_capacity
114
+ end
115
+
116
+ def register_fatal_error(error)
117
+ state_mutex.synchronize do
118
+ @fatal_error ||= error
119
+ end
120
+
121
+ boot_queue << error if boot_queue.empty?
122
+ on_idle&.call
123
+ end
124
+
125
+ def raise_if_fatal_error!
126
+ error = state_mutex.synchronize { @fatal_error }
127
+ raise error if error
128
+ end
129
+ end
130
+ 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,51 +4,72 @@ module SolidQueue
4
4
  class Pool
5
5
  include AppExecutor
6
6
 
7
- attr_reader :size
7
+ def self.build(type:, size:, on_idle: nil)
8
+ SolidQueue.const_get("#{type.to_s.camelize}Pool").new(size, on_idle: on_idle)
9
+ end
8
10
 
9
- delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
11
+ attr_reader :size
10
12
 
11
13
  def initialize(size, on_idle: nil)
12
14
  @size = size
13
15
  @on_idle = on_idle
14
- @available_threads = Concurrent::AtomicFixnum.new(size)
16
+ @available_capacity = size
15
17
  @mutex = Mutex.new
16
18
  end
17
19
 
20
+ def type
21
+ self.class.name.demodulize.delete_suffix("Pool").underscore.to_sym
22
+ end
23
+
18
24
  def post(execution)
19
- available_threads.decrement
20
-
21
- Concurrent::Promises.future_on(executor, execution) do |thread_execution|
22
- wrap_in_app_executor do
23
- thread_execution.perform
24
- ensure
25
- available_threads.increment
26
- mutex.synchronize { on_idle.try(:call) if idle? }
27
- end
28
- end.on_rejection! do |e|
29
- handle_thread_error(e)
25
+ reserve_capacity!
26
+
27
+ begin
28
+ schedule(execution)
29
+ rescue Exception
30
+ restore_capacity
31
+ raise
30
32
  end
31
33
  end
32
34
 
33
- def idle_threads
34
- available_threads.value
35
+ def available_capacity
36
+ mutex.synchronize { @available_capacity }
35
37
  end
36
38
 
37
39
  def idle?
38
- idle_threads > 0
40
+ available_capacity.positive?
39
41
  end
40
42
 
41
43
  private
42
- attr_reader :available_threads, :on_idle, :mutex
44
+ attr_reader :mutex, :on_idle
45
+
46
+ def schedule(execution)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def perform_execution(execution)
51
+ wrap_in_app_executor { execution.perform }
52
+ rescue Exception => error
53
+ handle_thread_error(error)
54
+ ensure
55
+ restore_capacity
56
+ end
57
+
58
+ def reserve_capacity!
59
+ mutex.synchronize do
60
+ raise RuntimeError, "Execution pool is at capacity" if @available_capacity <= 0
43
61
 
44
- DEFAULT_OPTIONS = {
45
- min_threads: 0,
46
- idletime: 60,
47
- fallback_policy: :abort
48
- }
62
+ @available_capacity -= 1
63
+ end
64
+ end
65
+
66
+ def restore_capacity
67
+ should_notify = mutex.synchronize do
68
+ @available_capacity += 1
69
+ @available_capacity.positive?
70
+ end
49
71
 
50
- def executor
51
- @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
72
+ on_idle&.call if should_notify
52
73
  end
53
74
  end
54
75
  end
@@ -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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class ThreadPool < Pool
5
+ delegate :shutdown, :shutdown?, :wait_for_termination, to: :executor
6
+
7
+ private
8
+ DEFAULT_OPTIONS = {
9
+ min_threads: 0,
10
+ idletime: 60,
11
+ fallback_policy: :abort
12
+ }
13
+
14
+ def schedule(execution)
15
+ Concurrent::Promises.future_on(executor, execution) do |thread_execution|
16
+ perform_execution(thread_execution)
17
+ end.on_rejection! do |error|
18
+ # Backstop for errors raised outside perform_execution's own rescue,
19
+ # such as when restoring capacity or waking up the worker
20
+ handle_thread_error(error)
21
+ end
22
+ end
23
+
24
+ def executor
25
+ @executor ||= Concurrent::ThreadPoolExecutor.new DEFAULT_OPTIONS.merge(max_threads: size, max_queue: size)
26
+ end
27
+ end
28
+ 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.5.0"
2
+ VERSION = "1.6.0"
3
+
4
+ def self.next_major_version
5
+ Gem::Version.new(VERSION).segments.first + 1
6
+ end
3
7
  end
@@ -11,18 +11,24 @@ module SolidQueue
11
11
  attr_reader :queues, :pool
12
12
 
13
13
  def initialize(**options)
14
+ execution_pool_type = options.key?(:fibers) ? :fiber : :thread
15
+
14
16
  options = options.dup.with_defaults(SolidQueue::Configuration::WORKER_DEFAULTS)
17
+ execution_pool_size = execution_pool_type == :fiber ? options[:fibers] : options[:threads]
15
18
 
16
19
  # Ensure that the queues array is deep frozen to prevent accidental modification
17
20
  @queues = Array(options[:queues]).map(&:freeze).freeze
18
21
 
19
- @pool = Pool.new(options[:threads], on_idle: -> { wake_up })
22
+ @pool = Pool.build \
23
+ type: execution_pool_type,
24
+ size: execution_pool_size,
25
+ on_idle: -> { wake_up }
20
26
 
21
27
  super(**options)
22
28
  end
23
29
 
24
30
  def metadata
25
- super.merge(queues: queues.join(","), thread_pool_size: pool.size)
31
+ super.merge(queues: queues.join(","), pool_type: pool.type, pool_size: pool.size)
26
32
  end
27
33
 
28
34
  private
@@ -38,7 +44,7 @@ module SolidQueue
38
44
 
39
45
  def claim_executions
40
46
  with_polling_volume do
41
- SolidQueue::ReadyExecution.claim(queues, pool.idle_threads, process_id)
47
+ SolidQueue::ReadyExecution.claim(queues, pool.available_capacity, process_id)
42
48
  end
43
49
  end
44
50
 
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.6.0
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-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '1.9'
125
+ - !ruby/object:Gem::Dependency
126
+ name: async
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '2.24'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '2.24'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: minitest
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -309,6 +323,7 @@ files:
309
323
  - lib/generators/solid_queue/install/templates/config/queue.yml
310
324
  - lib/generators/solid_queue/install/templates/config/recurring.yml
311
325
  - lib/generators/solid_queue/install/templates/db/queue_schema.rb
326
+ - lib/generators/solid_queue/update/update_generator.rb
312
327
  - lib/puma/plugin/solid_queue.rb
313
328
  - lib/solid_queue.rb
314
329
  - lib/solid_queue/app_executor.rb
@@ -318,6 +333,7 @@ files:
318
333
  - lib/solid_queue/dispatcher.rb
319
334
  - lib/solid_queue/dispatcher/concurrency_maintenance.rb
320
335
  - lib/solid_queue/engine.rb
336
+ - lib/solid_queue/fiber_pool.rb
321
337
  - lib/solid_queue/fork_supervisor.rb
322
338
  - lib/solid_queue/lifecycle_hooks.rb
323
339
  - lib/solid_queue/log_subscriber.rb
@@ -342,6 +358,7 @@ files:
342
358
  - lib/solid_queue/supervisor/pidfiled.rb
343
359
  - lib/solid_queue/supervisor/signals.rb
344
360
  - lib/solid_queue/tasks.rb
361
+ - lib/solid_queue/thread_pool.rb
345
362
  - lib/solid_queue/timer.rb
346
363
  - lib/solid_queue/version.rb
347
364
  - lib/solid_queue/worker.rb