solid_queue 1.5.1 → 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: 82eb2e9041fa69b3e7f3bd4a3d62c46b7826100a708b2237750f0f9e4fdee9c5
4
- data.tar.gz: 14000eeba6030e0248273d4cc158e17f25f78268d5e2c62c5e74f9a959f8d94f
3
+ metadata.gz: d6cda6edb92a7805d6c2c0fc13b2649c2c2df26a4d65e555049efde022cb8d14
4
+ data.tar.gz: c6d0036cbeb743e56faa4ed9fe6756f1a8e3ae3705a402f55440bb14cb3174dc
5
5
  SHA512:
6
- metadata.gz: 9bb7b225b9a3b1b13e1f2edfa04927cb2f3954d0a0b226ca79e7fc69dff6675c002ac5e6dd13fd0ff9794d2f0a660487a70cb078868034e7a048378b14911a4f
7
- data.tar.gz: b30ef8f4992fc7a3d858294b3ae9b1c4459f813d9ec3bb3ff4a4fde97ad114469d76d913bb19912117de3d081e6c8d159798e5bcdd0790858ea3af6f4faf7cc6
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:
@@ -232,6 +236,9 @@ production:
232
236
  threads: 5
233
237
  polling_interval: 0.1
234
238
  processes: 3
239
+ - queues: "api*"
240
+ fibers: 100
241
+ polling_interval: 0.05
235
242
  scheduler:
236
243
  dynamic_tasks_enabled: true
237
244
  polling_interval: 5
@@ -274,9 +281,11 @@ Here's an overview of the different options:
274
281
 
275
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).
276
283
 
277
- - `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.
278
- 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.
279
- - `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).
280
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.
281
290
 
282
291
 
@@ -367,7 +376,17 @@ queues: back*
367
376
 
368
377
  ### Threads, processes, and signals
369
378
 
370
- 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.
371
390
 
372
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:
373
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.
@@ -379,6 +398,10 @@ On Windows, the `QUIT` signal can't be trapped, so the supervisor only responds
379
398
 
380
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.
381
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
+
382
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.
383
406
 
384
407
 
@@ -394,7 +417,7 @@ _Note_: The settings in this section should be set in your `config/application.r
394
417
 
395
418
  There are several settings that control how Solid Queue works that you can set as well:
396
419
  - `logger`: the logger you want Solid Queue to use. Defaults to the app logger.
397
- - `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
398
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
399
422
 
400
423
  ```ruby
@@ -807,6 +830,8 @@ SolidQueue.unschedule_recurring_task("my_dynamic_task")
807
830
 
808
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.
809
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
+
810
835
  Tasks scheduled like this persist between Solid Queue's restarts and won't stop running until you manually unschedule them.
811
836
 
812
837
  ## Inspiration
@@ -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,6 +39,7 @@ 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)
@@ -99,12 +102,12 @@ module SolidQueue
99
102
  end
100
103
  end
101
104
 
102
- def warn_about_incorrectly_sized_thread_pool
105
+ def warn_about_incorrectly_sized_database_pool
103
106
  db_pool_size = SolidQueue::Record.connection_pool&.size
104
107
 
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 " \
107
- "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`")
108
111
  end
109
112
  rescue ActiveRecord::ActiveRecordError
110
113
  # No usable database connection. Skip the pool-size warning in that case.
@@ -121,6 +124,34 @@ module SolidQueue
121
124
  end
122
125
  end
123
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
+
124
155
  def default_options
125
156
  {
126
157
  mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork,
@@ -162,7 +193,8 @@ module SolidQueue
162
193
  1
163
194
  end
164
195
 
165
- 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)) }
166
198
  end
167
199
  end
168
200
 
@@ -256,10 +288,42 @@ module SolidQueue
256
288
  end
257
289
  end
258
290
 
259
- def estimated_number_of_threads
260
- # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
261
- thread_count = workers_options.map { |options| options.fetch(:threads, WORKER_DEFAULTS[:threads]) }.max
262
- (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
263
327
  end
264
328
  end
265
329
  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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
- VERSION = "1.5.1"
2
+ VERSION = "1.6.0"
3
3
 
4
4
  def self.next_major_version
5
5
  Gem::Version.new(VERSION).segments.first + 1
@@ -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
 
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.1
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-29 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
@@ -319,6 +333,7 @@ files:
319
333
  - lib/solid_queue/dispatcher.rb
320
334
  - lib/solid_queue/dispatcher/concurrency_maintenance.rb
321
335
  - lib/solid_queue/engine.rb
336
+ - lib/solid_queue/fiber_pool.rb
322
337
  - lib/solid_queue/fork_supervisor.rb
323
338
  - lib/solid_queue/lifecycle_hooks.rb
324
339
  - lib/solid_queue/log_subscriber.rb
@@ -343,6 +358,7 @@ files:
343
358
  - lib/solid_queue/supervisor/pidfiled.rb
344
359
  - lib/solid_queue/supervisor/signals.rb
345
360
  - lib/solid_queue/tasks.rb
361
+ - lib/solid_queue/thread_pool.rb
346
362
  - lib/solid_queue/timer.rb
347
363
  - lib/solid_queue/version.rb
348
364
  - lib/solid_queue/worker.rb