solid_queue 1.2.3 → 1.3.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: b9c40f4cc06834a2134e188e25fb6ce63b9a55818d94155cf6bac70ae99b3359
4
- data.tar.gz: 312a40d851d6f0d3fe52263f2310eb43cf2d4302aafbbac706dc55f7822c6ced
3
+ metadata.gz: ae346e5b4e8249ab21441c1b45118745124d1d7d78b5b43c08cf6b333e0ec665
4
+ data.tar.gz: fc4b4a6131ff3cb5799f1325f9b0523606152bf8e331bf7122af4463c543e2b5
5
5
  SHA512:
6
- metadata.gz: 48547f088ad66bd5896876cd0ef4f2f681e2536eecde84b4ced47e3ec957db98dd72674b272dd649e348343b6cfefc466d6adf33d8c3ab538818be84bfb666c0
7
- data.tar.gz: 2a5f18f2d1f366bdd37ca7157417147fa644078ee6e7f93a757546625c68168cada47d1c15f534efed944c45641e063b180d9c55c437ecd10dd427d0e1a62853
6
+ metadata.gz: 2282d35fe14f5dfa424f1016dd8e00b2f3613e7023cb48cf99b82de5e2079de0154e7102fef72867060cdf4f1e7c9aa125cbed43c229ead9c200342c74e41099
7
+ data.tar.gz: cab19aa7d9d4a22fc3c068ebe00d2bbd1c288c023a37ef24b461bc8d84d9c3e100337ffcd53bf634d17e712e42937175e59cc6b2041e81661e91edfa9fb94282
data/README.md CHANGED
@@ -14,8 +14,9 @@ Solid Queue can be used with SQL databases such as MySQL, PostgreSQL, or SQLite,
14
14
  - [Dashboard UI Setup](#dashboard-ui-setup)
15
15
  - [Incremental adoption](#incremental-adoption)
16
16
  - [High performance requirements](#high-performance-requirements)
17
+ - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
18
+ - [Fork vs. async mode](#fork-vs-async-mode)
17
19
  - [Configuration](#configuration)
18
- - [Workers, dispatchers, and scheduler](#workers-dispatchers-and-scheduler)
19
20
  - [Queue order and priorities](#queue-order-and-priorities)
20
21
  - [Queues specification and performance](#queues-specification-and-performance)
21
22
  - [Threads, processes, and signals](#threads-processes-and-signals)
@@ -92,10 +93,10 @@ development:
92
93
  + primary:
93
94
  <<: *default
94
95
  database: storage/development.sqlite3
95
- + queue:
96
- + <<: *default
97
- + database: storage/development_queue.sqlite3
98
- + migrations_paths: db/queue_migrate
96
+ + queue:
97
+ + <<: *default
98
+ + database: storage/development_queue.sqlite3
99
+ + migrations_paths: db/queue_migrate
99
100
  ```
100
101
 
101
102
  Next, add the following to `development.rb`
@@ -127,7 +128,7 @@ In `config/cable.yml`
127
128
  ```diff
128
129
  development:
129
130
  - adapter: async
130
- + adapter: solid_cable
131
+ + adapter: solid_cable
131
132
  + connects_to:
132
133
  + database:
133
134
  + writing: cable
@@ -177,11 +178,9 @@ end
177
178
 
178
179
  ### High performance requirements
179
180
 
180
- Solid Queue was designed for the highest throughput when used with MySQL 8+ or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
181
-
182
- ## Configuration
181
+ Solid Queue was designed for the highest throughput when used with MySQL 8+, MariaDB 10.6+, or PostgreSQL 9.5+, as they support `FOR UPDATE SKIP LOCKED`. You can use it with older versions, but in that case, you might run into lock waits if you run multiple workers for the same queue. You can also use it with SQLite on smaller applications.
183
182
 
184
- ### Workers, dispatchers, and scheduler
183
+ ## Workers, dispatchers, and scheduler
185
184
 
186
185
  We have several types of actors in Solid Queue:
187
186
 
@@ -190,7 +189,19 @@ We have several types of actors in Solid Queue:
190
189
  - The _scheduler_ manages [recurring tasks](#recurring-tasks), enqueuing jobs for them when they're due.
191
190
  - The _supervisor_ runs workers and dispatchers according to the configuration, controls their heartbeats, and stops and starts them when needed.
192
191
 
193
- Solid Queue's supervisor will fork a separate process for each supervised worker/dispatcher/scheduler.
192
+ ### Fork vs. async mode
193
+
194
+ By default, Solid Queue runs in `fork` mode. This means the supervisor will fork a separate process for each supervised worker/dispatcher/scheduler. This provides the best isolation and performance, but can have additional memory usage and might not work with some Ruby implementations. As an alternative, you can run all workers, dispatchers and schedulers in the same process as the supervisor, in different threads, with an `async` mode. You can choose this mode by running `bin/jobs` as:
195
+
196
+ ```
197
+ bin/jobs --mode async
198
+ ```
199
+
200
+ Or you can also set the environment variable `SOLID_QUEUE_SUPERVISOR_MODE` to `async`. If you use the `async` mode, the `processes` option in the configuration described below will be ignored.
201
+
202
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
203
+
204
+ ## Configuration
194
205
 
195
206
  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:
196
207
 
@@ -254,7 +265,7 @@ Here's an overview of the different options:
254
265
 
255
266
  - `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.
256
267
  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.
257
- - `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.
268
+ - `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).
258
269
  - `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.
259
270
 
260
271
 
@@ -334,7 +345,7 @@ queues: back*
334
345
 
335
346
  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.
336
347
 
337
- The supervisor is in charge of managing these processes, and it responds to the following signals:
348
+ 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:
338
349
  - `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.
339
350
  - `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately.
340
351
 
@@ -342,7 +353,7 @@ When receiving a `QUIT` signal, if workers still have jobs in-flight, these will
342
353
 
343
354
  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.
344
355
 
345
- 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::Process::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.
356
+ 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.
346
357
 
347
358
 
348
359
  ### Database configuration
@@ -366,7 +377,7 @@ There are several settings that control how Solid Queue works that you can set a
366
377
 
367
378
  **This is not used for errors raised within a job execution**. Errors happening in jobs are handled by Active Job's `retry_on` or `discard_on`, and ultimately will result in [failed jobs](#failed-jobs-and-retries). This is for errors happening within Solid Queue itself.
368
379
 
369
- - `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, and for PostgreSQL, versions < 9.5. If you use SQLite, this has no effect, as writes are sequential.
380
+ - `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.
370
381
  - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
371
382
  - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
372
383
  - `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.
@@ -519,11 +530,11 @@ DeliverAnnouncementToContactJob.set(wait: 30.minutes).perform_later(contact)
519
530
 
520
531
  The 3 jobs will go into the scheduled queue and will wait there until they're due. Then, 10 minutes after, the first two jobs will be enqueued and the second one most likely will be blocked because the first one will be running first. Then, assuming the jobs are fast and finish in a few seconds, when the third job is due, it'll be enqueued normally.
521
532
 
522
- Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. I'd generally advise against mixing concurrency controls with waiting/scheduling in the future.
533
+ Normally scheduled jobs are enqueued in batches, but with concurrency controls, jobs need to be enqueued one by one. This has an impact on performance, similarly to the impact of concurrency controls in bulk enqueuing. Read below for more details. We generally advise against mixing concurrency controls with waiting/scheduling in the future.
523
534
 
524
535
  ### Performance considerations
525
536
 
526
- Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, I'd encourage relying on a limited number of workers per queue instead. For example:
537
+ Concurrency controls introduce significant overhead (blocked executions need to be created and promoted to ready, semaphores need to be created and updated) so you should consider carefully whether you need them. For throttling purposes, where you plan to have `limit` significantly larger than 1, we encourage relying on a limited number of workers per queue instead. For example:
527
538
 
528
539
  ```ruby
529
540
  class ThrottledJob < ApplicationJob
@@ -603,6 +614,22 @@ that you set in production only. This is what Rails 8's default Puma config look
603
614
 
604
615
  **Note**: phased restarts are not supported currently because the plugin requires [app preloading](https://github.com/puma/puma?tab=readme-ov-file#cluster-mode) to work.
605
616
 
617
+ ### Running as a fork or asynchronously
618
+
619
+ By default, the Puma plugin will fork additional processes for each worker and dispatcher so that they run in different processes. This provides the best isolation and performance, but can have additional memory usage.
620
+
621
+ Alternatively, workers and dispatchers can be run within the same Puma process(s). To do so just configure the plugin as:
622
+
623
+ ```ruby
624
+ plugin :solid_queue
625
+ solid_queue_mode :async
626
+ ```
627
+
628
+ Note that in this case, the `processes` configuration option will be ignored. See also [Fork vs. async mode](#fork-vs-async-mode).
629
+
630
+ **The recommended and default mode is `fork`. Only use `async` if you know what you're doing and have strong reasons to**
631
+
632
+
606
633
  ## Jobs and transactional integrity
607
634
  :warning: Having your jobs in the same ACID-compliant database as your application data enables a powerful yet sharp tool: taking advantage of transactional integrity to ensure some action in your app is not committed unless your job is also committed and vice versa, and ensuring that your job won't be enqueued until the transaction within which you're enqueuing it is committed. This can be very powerful and useful, but it can also backfire if you base some of your logic on this behaviour, and in the future, you move to another active job backend, or if you simply move Solid Queue to its own database, and suddenly the behaviour changes under you. Because this can be quite tricky and many people shouldn't need to worry about it, by default Solid Queue is configured in a different database as the main app.
608
635
 
@@ -1,5 +1,13 @@
1
1
  require "puma/plugin"
2
2
 
3
+ module Puma
4
+ class DSL
5
+ def solid_queue_mode(mode = :fork)
6
+ @options[:solid_queue_mode] = mode.to_sym
7
+ end
8
+ end
9
+ end
10
+
3
11
  Puma::Plugin.create do
4
12
  attr_reader :puma_pid, :solid_queue_pid, :log_writer, :solid_queue_supervisor
5
13
 
@@ -7,35 +15,73 @@ Puma::Plugin.create do
7
15
  @log_writer = launcher.log_writer
8
16
  @puma_pid = $$
9
17
 
10
- in_background do
11
- monitor_solid_queue
18
+ if launcher.options[:solid_queue_mode] == :async
19
+ start_async(launcher)
20
+ else
21
+ start_forked(launcher)
12
22
  end
23
+ end
13
24
 
14
- if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
15
- launcher.events.on_booted do
16
- @solid_queue_pid = fork do
17
- Thread.new { monitor_puma }
18
- SolidQueue::Supervisor.start
25
+ private
26
+ def start_forked(launcher)
27
+ in_background do
28
+ monitor_solid_queue
29
+ end
30
+
31
+ if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
32
+ launcher.events.on_booted do
33
+ @solid_queue_pid = fork do
34
+ Thread.new { monitor_puma }
35
+ SolidQueue::Supervisor.start(mode: :fork)
36
+ end
19
37
  end
38
+
39
+ launcher.events.on_stopped { stop_solid_queue_fork }
40
+ launcher.events.on_restart { stop_solid_queue_fork }
41
+ else
42
+ launcher.events.after_booted do
43
+ @solid_queue_pid = fork do
44
+ Thread.new { monitor_puma }
45
+ start_solid_queue(mode: :fork)
46
+ end
47
+ end
48
+
49
+ launcher.events.after_stopped { stop_solid_queue_fork }
50
+ launcher.events.before_restart { stop_solid_queue_fork }
20
51
  end
52
+ end
21
53
 
22
- launcher.events.on_stopped { stop_solid_queue }
23
- launcher.events.on_restart { stop_solid_queue }
24
- else
25
- launcher.events.after_booted do
26
- @solid_queue_pid = fork do
27
- Thread.new { monitor_puma }
28
- SolidQueue::Supervisor.start
54
+ def start_async(launcher)
55
+ if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new("7")
56
+ launcher.events.on_booted do
57
+ start_solid_queue(mode: :async, standalone: false)
58
+ end
59
+
60
+ launcher.events.on_stopped { solid_queue_supervisor&.stop }
61
+
62
+ launcher.events.on_restart do
63
+ solid_queue_supervisor&.stop
64
+ start_solid_queue(mode: :async, standalone: false)
65
+ end
66
+ else
67
+ launcher.events.after_booted do
68
+ start_solid_queue(mode: :async, standalone: false)
69
+ end
70
+
71
+ launcher.events.after_stopped { solid_queue_supervisor&.stop }
72
+
73
+ launcher.events.before_restart do
74
+ solid_queue_supervisor&.stop
75
+ start_solid_queue(mode: :async, standalone: false)
29
76
  end
30
77
  end
78
+ end
31
79
 
32
- launcher.events.after_stopped { stop_solid_queue }
33
- launcher.events.before_restart { stop_solid_queue }
80
+ def start_solid_queue(**options)
81
+ @solid_queue_supervisor = SolidQueue::Supervisor.start(**options)
34
82
  end
35
- end
36
83
 
37
- private
38
- def stop_solid_queue
84
+ def stop_solid_queue_fork
39
85
  Process.waitpid(solid_queue_pid, Process::WNOHANG)
40
86
  log "Stopping Solid Queue..."
41
87
  Process.kill(:INT, solid_queue_pid) if solid_queue_pid
@@ -48,7 +94,7 @@ Puma::Plugin.create do
48
94
  end
49
95
 
50
96
  def monitor_solid_queue
51
- monitor(:solid_queue_dead?, "Detected Solid Queue has gone away, stopping Puma...")
97
+ monitor(:solid_queue_fork_dead?, "Detected Solid Queue has gone away, stopping Puma...")
52
98
  end
53
99
 
54
100
  def monitor(process_dead, message)
@@ -62,7 +108,7 @@ Puma::Plugin.create do
62
108
  end
63
109
  end
64
110
 
65
- def solid_queue_dead?
111
+ def solid_queue_fork_dead?
66
112
  if solid_queue_started?
67
113
  Process.waitpid(solid_queue_pid, Process::WNOHANG)
68
114
  end
@@ -17,5 +17,15 @@ module SolidQueue
17
17
  SolidQueue.on_thread_error.call(error)
18
18
  end
19
19
  end
20
+
21
+ def create_thread(&block)
22
+ Thread.new do
23
+ Thread.current.name = name
24
+ block.call
25
+ rescue Exception => exception
26
+ handle_thread_error(exception)
27
+ raise
28
+ end
29
+ end
20
30
  end
21
31
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class AsyncSupervisor < Supervisor
5
+ after_shutdown :terminate_gracefully, unless: :standalone?
6
+
7
+ def stop
8
+ super
9
+ @thread&.join
10
+ end
11
+
12
+ private
13
+ def supervise
14
+ if standalone? then super
15
+ else
16
+ @thread = create_thread { super }
17
+ end
18
+ end
19
+
20
+ def check_and_replace_terminated_processes
21
+ terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? }
22
+ terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) }
23
+ end
24
+
25
+ def replace_thread(thread_id, instance)
26
+ SolidQueue.instrument(:replace_thread, supervisor_pid: ::Process.pid) do |payload|
27
+ payload[:thread] = instance
28
+
29
+ error = Processes::ThreadTerminatedError.new(terminated_instance.name)
30
+ release_claimed_jobs_by(terminated_instance, with_error: error)
31
+
32
+ start_process(configured_processes.delete(thread_id))
33
+ end
34
+ end
35
+
36
+ def perform_graceful_termination
37
+ process_instances.values.each(&:stop)
38
+
39
+ Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? })
40
+ end
41
+
42
+ def perform_immediate_termination
43
+ exit!
44
+ end
45
+
46
+ def all_processes_terminated?
47
+ process_instances.values.none?(&:alive?)
48
+ end
49
+ end
50
+ end
@@ -8,6 +8,10 @@ module SolidQueue
8
8
  desc: "Path to config file (default: #{Configuration::DEFAULT_CONFIG_FILE_PATH}).",
9
9
  banner: "SOLID_QUEUE_CONFIG"
10
10
 
11
+ class_option :mode, type: :string, default: "fork", enum: %w[ fork async ],
12
+ desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async) (default: fork).",
13
+ banner: "SOLID_QUEUE_SUPERVISOR_MODE"
14
+
11
15
  class_option :recurring_schedule_file, type: :string,
12
16
  desc: "Path to recurring schedule definition (default: #{Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH}).",
13
17
  banner: "SOLID_QUEUE_RECURRING_SCHEDULE"
@@ -56,6 +56,14 @@ module SolidQueue
56
56
  end
57
57
  end
58
58
 
59
+ def mode
60
+ @options[:mode].to_s.inquiry
61
+ end
62
+
63
+ def standalone?
64
+ mode.fork? || @options[:standalone]
65
+ end
66
+
59
67
  private
60
68
  attr_reader :options
61
69
 
@@ -84,6 +92,8 @@ module SolidQueue
84
92
 
85
93
  def default_options
86
94
  {
95
+ mode: ENV["SOLID_QUEUE_SUPERVISOR_MODE"] || :fork,
96
+ standalone: true,
87
97
  config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH),
88
98
  recurring_schedule_file: Rails.root.join(ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || DEFAULT_RECURRING_SCHEDULE_FILE_PATH),
89
99
  only_work: false,
@@ -110,7 +120,12 @@ module SolidQueue
110
120
 
111
121
  def workers
112
122
  workers_options.flat_map do |worker_options|
113
- processes = worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
123
+ processes = if mode.fork?
124
+ worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
125
+ else
126
+ 1
127
+ end
128
+
114
129
  processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
115
130
  end
116
131
  end
@@ -188,6 +203,7 @@ module SolidQueue
188
203
  if file.exist?
189
204
  ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
190
205
  else
206
+ puts "[solid_queue] WARNING: Provided configuration file '#{file}' does not exist. Falling back to default configuration."
191
207
  {}
192
208
  end
193
209
  end
@@ -3,6 +3,7 @@
3
3
  module SolidQueue
4
4
  class Dispatcher < Processes::Poller
5
5
  include LifecycleHooks
6
+
6
7
  attr_reader :batch_size
7
8
 
8
9
  after_boot :run_start_hooks
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class ForkSupervisor < Supervisor
5
+ private
6
+
7
+ def perform_graceful_termination
8
+ term_forks
9
+
10
+ Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_processes_terminated? }) do
11
+ reap_terminated_forks
12
+ end
13
+ end
14
+
15
+ def perform_immediate_termination
16
+ quit_forks
17
+ end
18
+
19
+ def term_forks
20
+ signal_processes(process_instances.keys, :TERM)
21
+ end
22
+
23
+ def quit_forks
24
+ signal_processes(process_instances.keys, :QUIT)
25
+ end
26
+
27
+ def check_and_replace_terminated_processes
28
+ loop do
29
+ pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
30
+ break unless pid
31
+
32
+ replace_fork(pid, status)
33
+ end
34
+ end
35
+
36
+ def reap_terminated_forks
37
+ loop do
38
+ pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
39
+ break unless pid
40
+
41
+ if (terminated_fork = process_instances.delete(pid)) && !status.exited? || status.exitstatus > 0
42
+ error = Processes::ProcessExitError.new(status)
43
+ release_claimed_jobs_by(terminated_fork, with_error: error)
44
+ end
45
+
46
+ configured_processes.delete(pid)
47
+ end
48
+ rescue SystemCallError
49
+ # All children already reaped
50
+ end
51
+
52
+ def replace_fork(pid, status)
53
+ SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
54
+ if terminated_fork = process_instances.delete(pid)
55
+ payload[:fork] = terminated_fork
56
+ error = Processes::ProcessExitError.new(status)
57
+ release_claimed_jobs_by(terminated_fork, with_error: error)
58
+
59
+ start_process(configured_processes.delete(pid))
60
+ end
61
+ end
62
+ end
63
+
64
+ def all_processes_terminated?
65
+ process_instances.empty?
66
+ end
67
+ end
68
+ end
@@ -18,17 +18,19 @@ module SolidQueue::Processes
18
18
  attr_accessor :process
19
19
 
20
20
  def register
21
- @process = SolidQueue::Process.register \
22
- kind: kind,
23
- name: name,
24
- pid: pid,
25
- hostname: hostname,
26
- supervisor: try(:supervisor),
27
- metadata: metadata.compact
21
+ wrap_in_app_executor do
22
+ @process = SolidQueue::Process.register \
23
+ kind: kind,
24
+ name: name,
25
+ pid: pid,
26
+ hostname: hostname,
27
+ supervisor: try(:supervisor),
28
+ metadata: metadata.compact
29
+ end
28
30
  end
29
31
 
30
32
  def deregister
31
- process&.deregister
33
+ wrap_in_app_executor { process&.deregister }
32
34
  end
33
35
 
34
36
  def registered?
@@ -7,20 +7,26 @@ module SolidQueue::Processes
7
7
  attr_writer :mode
8
8
 
9
9
  def start
10
- boot
11
-
12
- if running_async?
13
- @thread = create_thread { run }
14
- else
10
+ run_in_mode do
11
+ boot
15
12
  run
16
13
  end
17
14
  end
18
15
 
19
16
  def stop
20
17
  super
21
-
22
18
  wake_up
23
- @thread&.join
19
+
20
+ # When not supervised, block until the thread terminates for backward
21
+ # compatibility with code that expects stop to be synchronous.
22
+ # When supervised, the supervisor controls the shutdown timeout.
23
+ unless supervised?
24
+ @thread&.join
25
+ end
26
+ end
27
+
28
+ def alive?
29
+ !running_async? || @thread&.alive?
24
30
  end
25
31
 
26
32
  private
@@ -30,6 +36,18 @@ module SolidQueue::Processes
30
36
  (@mode || DEFAULT_MODE).to_s.inquiry
31
37
  end
32
38
 
39
+ def run_in_mode(&block)
40
+ case
41
+ when running_as_fork?
42
+ fork(&block)
43
+ when running_async?
44
+ @thread = create_thread(&block)
45
+ @thread.object_id
46
+ else
47
+ block.call
48
+ end
49
+ end
50
+
33
51
  def boot
34
52
  SolidQueue.instrument(:start_process, process: self) do
35
53
  run_callbacks(:boot) do
@@ -74,16 +92,5 @@ module SolidQueue::Processes
74
92
  def running_as_fork?
75
93
  mode.fork?
76
94
  end
77
-
78
-
79
- def create_thread(&block)
80
- Thread.new do
81
- Thread.current.name = name
82
- block.call
83
- rescue Exception => exception
84
- handle_thread_error(exception)
85
- raise
86
- end
87
- end
88
95
  end
89
96
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ module Processes
5
+ class ThreadTerminatedError < RuntimeError
6
+ def initialize(name)
7
+ super("Thread #{name} terminated unexpectedly")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -46,7 +46,7 @@ module SolidQueue
46
46
  end
47
47
 
48
48
  def reload_tasks
49
- @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys)
49
+ @configured_tasks = SolidQueue::RecurringTask.where(key: task_keys).to_a
50
50
  end
51
51
 
52
52
  def schedule(task)
@@ -32,5 +32,16 @@ module SolidQueue
32
32
  ClaimedExecution.orphaned.fail_all_with(Processes::ProcessMissingError.new)
33
33
  end
34
34
  end
35
+
36
+ # When a supervised process crashes or exits we need to mark all the
37
+ # executions it had claimed as failed so that they can be retried
38
+ # by some other worker.
39
+ def release_claimed_jobs_by(terminated_process, with_error:)
40
+ wrap_in_app_executor do
41
+ if registered_process = SolidQueue::Process.find_by(name: terminated_process.name)
42
+ registered_process.fail_all_claimed_executions_with(with_error)
43
+ end
44
+ end
45
+ end
35
46
  end
36
47
  end
@@ -6,8 +6,8 @@ module SolidQueue
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- before_boot :register_signal_handlers
10
- after_shutdown :restore_default_signal_handlers
9
+ before_boot :register_signal_handlers, if: :standalone?
10
+ after_shutdown :restore_default_signal_handlers, if: :standalone?
11
11
  end
12
12
 
13
13
  private
@@ -13,17 +13,21 @@ module SolidQueue
13
13
  configuration = Configuration.new(**options)
14
14
 
15
15
  if configuration.valid?
16
- new(configuration).tap(&:start)
16
+ klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor
17
+ klass.new(configuration).tap(&:start)
17
18
  else
18
19
  abort configuration.errors.full_messages.join("\n") + "\nExiting..."
19
20
  end
20
21
  end
21
22
  end
22
23
 
24
+ delegate :mode, :standalone?, to: :configuration
25
+
23
26
  def initialize(configuration)
24
27
  @configuration = configuration
25
- @forks = {}
28
+
26
29
  @configured_processes = {}
30
+ @process_instances = {}
27
31
 
28
32
  super
29
33
  end
@@ -43,8 +47,12 @@ module SolidQueue
43
47
  run_stop_hooks
44
48
  end
45
49
 
50
+ def kind
51
+ "Supervisor(#{mode})"
52
+ end
53
+
46
54
  private
47
- attr_reader :configuration, :forks, :configured_processes
55
+ attr_reader :configuration, :configured_processes, :process_instances
48
56
 
49
57
  def boot
50
58
  SolidQueue.instrument(:start_process, process: self) do
@@ -62,11 +70,13 @@ module SolidQueue
62
70
  loop do
63
71
  break if stopped?
64
72
 
65
- set_procline
66
- process_signal_queue
73
+ if standalone?
74
+ set_procline
75
+ process_signal_queue
76
+ end
67
77
 
68
78
  unless stopped?
69
- reap_and_replace_terminated_forks
79
+ check_and_replace_terminated_processes
70
80
  interruptible_sleep(1.second)
71
81
  end
72
82
  end
@@ -77,30 +87,23 @@ module SolidQueue
77
87
  def start_process(configured_process)
78
88
  process_instance = configured_process.instantiate.tap do |instance|
79
89
  instance.supervised_by process
80
- instance.mode = :fork
90
+ instance.mode = mode
81
91
  end
82
92
 
83
- pid = fork do
84
- process_instance.start
85
- end
93
+ process_id = process_instance.start
86
94
 
87
- configured_processes[pid] = configured_process
88
- forks[pid] = process_instance
95
+ configured_processes[process_id] = configured_process
96
+ process_instances[process_id] = process_instance
89
97
  end
90
98
 
91
- def set_procline
92
- procline "supervising #{supervised_processes.join(", ")}"
99
+ def check_and_replace_terminated_processes
93
100
  end
94
101
 
95
102
  def terminate_gracefully
96
- SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload|
97
- term_forks
98
-
99
- Timer.wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do
100
- reap_terminated_forks
101
- end
103
+ SolidQueue.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do |payload|
104
+ perform_graceful_termination
102
105
 
103
- unless all_forks_terminated?
106
+ unless all_processes_terminated?
104
107
  payload[:shutdown_timeout_exceeded] = true
105
108
  terminate_immediately
106
109
  end
@@ -108,82 +111,37 @@ module SolidQueue
108
111
  end
109
112
 
110
113
  def terminate_immediately
111
- SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do
112
- quit_forks
114
+ SolidQueue.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: configured_processes.keys) do
115
+ perform_immediate_termination
113
116
  end
114
117
  end
115
118
 
116
- def shutdown
117
- SolidQueue.instrument(:shutdown_process, process: self) do
118
- run_callbacks(:shutdown) do
119
- stop_maintenance_task
120
- end
121
- end
122
- end
123
-
124
- def sync_std_streams
125
- STDOUT.sync = STDERR.sync = true
126
- end
127
-
128
- def supervised_processes
129
- forks.keys
119
+ def perform_graceful_termination
120
+ raise NotImplementedError
130
121
  end
131
122
 
132
- def term_forks
133
- signal_processes(forks.keys, :TERM)
123
+ def perform_immediate_termination
124
+ raise NotImplementedError
134
125
  end
135
126
 
136
- def quit_forks
137
- signal_processes(forks.keys, :QUIT)
127
+ def all_processes_terminated?
128
+ raise NotImplementedError
138
129
  end
139
130
 
140
- def reap_and_replace_terminated_forks
141
- loop do
142
- pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
143
- break unless pid
144
-
145
- replace_fork(pid, status)
146
- end
147
- end
148
-
149
- def reap_terminated_forks
150
- loop do
151
- pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
152
- break unless pid
153
-
154
- if (terminated_fork = forks.delete(pid)) && (!status.exited? || status.exitstatus > 0)
155
- handle_claimed_jobs_by(terminated_fork, status)
156
- end
157
-
158
- configured_processes.delete(pid)
159
- end
160
- rescue SystemCallError
161
- # All children already reaped
162
- end
163
-
164
- def replace_fork(pid, status)
165
- SolidQueue.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
166
- if terminated_fork = forks.delete(pid)
167
- payload[:fork] = terminated_fork
168
- handle_claimed_jobs_by(terminated_fork, status)
169
-
170
- start_process(configured_processes.delete(pid))
131
+ def shutdown
132
+ SolidQueue.instrument(:shutdown_process, process: self) do
133
+ run_callbacks(:shutdown) do
134
+ stop_maintenance_task
171
135
  end
172
136
  end
173
137
  end
174
138
 
175
- # When a supervised fork crashes or exits we need to mark all the
176
- # executions it had claimed as failed so that they can be retried
177
- # by some other worker.
178
- def handle_claimed_jobs_by(terminated_fork, status)
179
- if registered_process = SolidQueue::Process.find_by(name: terminated_fork.name)
180
- error = Processes::ProcessExitError.new(status)
181
- registered_process.fail_all_claimed_executions_with(error)
182
- end
139
+ def set_procline
140
+ procline "supervising #{configured_processes.keys.join(", ")}"
183
141
  end
184
142
 
185
- def all_forks_terminated?
186
- forks.empty?
143
+ def sync_std_streams
144
+ STDOUT.sync = STDERR.sync = true
187
145
  end
188
146
  end
189
147
  end
@@ -4,18 +4,18 @@ module SolidQueue
4
4
  module Timer
5
5
  extend self
6
6
 
7
- def wait_until(timeout, condition, &block)
7
+ def wait_until(timeout, condition)
8
8
  if timeout > 0
9
9
  deadline = monotonic_time_now + timeout
10
10
 
11
11
  while monotonic_time_now < deadline && !condition.call
12
12
  sleep 0.1
13
- block.call
13
+ yield if block_given?
14
14
  end
15
15
  else
16
16
  while !condition.call
17
17
  sleep 0.5
18
- block.call
18
+ yield if block_given?
19
19
  end
20
20
  end
21
21
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "1.2.3"
2
+ VERSION = "1.3.0"
3
3
  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.2.3
4
+ version: 1.3.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: 2025-10-28 00:00:00.000000000 Z
11
+ date: 2026-01-09 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: minitest
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '5.0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '5.0'
125
139
  - !ruby/object:Gem::Dependency
126
140
  name: mocha
127
141
  requirement: !ruby/object:Gem::Requirement
@@ -297,11 +311,13 @@ files:
297
311
  - lib/puma/plugin/solid_queue.rb
298
312
  - lib/solid_queue.rb
299
313
  - lib/solid_queue/app_executor.rb
314
+ - lib/solid_queue/async_supervisor.rb
300
315
  - lib/solid_queue/cli.rb
301
316
  - lib/solid_queue/configuration.rb
302
317
  - lib/solid_queue/dispatcher.rb
303
318
  - lib/solid_queue/dispatcher/concurrency_maintenance.rb
304
319
  - lib/solid_queue/engine.rb
320
+ - lib/solid_queue/fork_supervisor.rb
305
321
  - lib/solid_queue/lifecycle_hooks.rb
306
322
  - lib/solid_queue/log_subscriber.rb
307
323
  - lib/solid_queue/pool.rb
@@ -316,6 +332,7 @@ files:
316
332
  - lib/solid_queue/processes/registrable.rb
317
333
  - lib/solid_queue/processes/runnable.rb
318
334
  - lib/solid_queue/processes/supervised.rb
335
+ - lib/solid_queue/processes/thread_terminated_error.rb
319
336
  - lib/solid_queue/scheduler.rb
320
337
  - lib/solid_queue/scheduler/recurring_schedule.rb
321
338
  - lib/solid_queue/supervisor.rb