solid_queue 1.1.0 → 1.1.4

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: e843e842397e1d8141e0457b5b278026b71effe6ebf83d8300d2c4098db56adf
4
- data.tar.gz: a5db37bdea8dacec796f2dcb912ab8f2a69c929487dff081d8a53fe10c20086c
3
+ metadata.gz: 778d9495c79b9b8af00416fd1980250fe6c3213043cbfd31eba5eb5e2795ad13
4
+ data.tar.gz: 14f929171d65334300648a5f80e59b9f8245034119cfd436d37dae545210c97b
5
5
  SHA512:
6
- metadata.gz: a6831f7114c24d68ae8ed7b5aebfd4d3df0cc2c7b4e0046e6275a5bf5e46f8da8f5b79e9a303ae908e3581e8b96a132776f4c1a3f55bbf550dd008541e679665
7
- data.tar.gz: e51b90234b3a60355a96c9163bfc7d95a1f5eb95fcf7a2dc1faf553f04a7c242da2d48493ba5b6574c2dbf4b0d259cb69af1dda576475f1510a31f8325be5f8b
6
+ metadata.gz: cbbd8113e179c49ffcfe707aa38c96f31ac812822d380e7a6739daced30ee029046ec19cf2ea70c81e19c0f1a819412454f1f4eb1a4cfad7cf5fa8b0f88e3021
7
+ data.tar.gz: c324b1127bbad96191d3c2e268058dd1cec7c29cffe45a511b0899f3e028e86b7ad0cf0638c93100393316bde1e429ab28c828a381c0692b8558c100f296b6f6
data/README.md CHANGED
@@ -6,6 +6,32 @@ Besides regular job enqueuing and processing, Solid Queue supports delayed jobs,
6
6
 
7
7
  Solid Queue can be used with SQL databases such as MySQL, PostgreSQL or SQLite, and it leverages the `FOR UPDATE SKIP LOCKED` clause, if available, to avoid blocking and waiting on locks when polling jobs. It relies on Active Job for retries, discarding, error handling, serialization, or delays, and it's compatible with Ruby on Rails's multi-threading.
8
8
 
9
+ ## Table of contents
10
+
11
+ - [Installation](#installation)
12
+ - [Usage in development and other non-production environments](#usage-in-development-and-other-non-production-environments)
13
+ - [Single database configuration](#single-database-configuration)
14
+ - [Incremental adoption](#incremental-adoption)
15
+ - [High performance requirements](#high-performance-requirements)
16
+ - [Configuration](#configuration)
17
+ - [Workers, dispatchers and scheduler](#workers-dispatchers-and-scheduler)
18
+ - [Queue order and priorities](#queue-order-and-priorities)
19
+ - [Queues specification and performance](#queues-specification-and-performance)
20
+ - [Threads, processes and signals](#threads-processes-and-signals)
21
+ - [Database configuration](#database-configuration)
22
+ - [Other configuration settings](#other-configuration-settings)
23
+ - [Lifecycle hooks](#lifecycle-hooks)
24
+ - [Errors when enqueuing](#errors-when-enqueuing)
25
+ - [Concurrency controls](#concurrency-controls)
26
+ - [Failed jobs and retries](#failed-jobs-and-retries)
27
+ - [Error reporting on jobs](#error-reporting-on-jobs)
28
+ - [Puma plugin](#puma-plugin)
29
+ - [Jobs and transactional integrity](#jobs-and-transactional-integrity)
30
+ - [Recurring tasks](#recurring-tasks)
31
+ - [Inspiration](#inspiration)
32
+ - [License](#license)
33
+
34
+
9
35
  ## Installation
10
36
 
11
37
  Solid Queue is configured by default in new Rails 8 applications. But if you're running an earlier version, you can add it manually following these steps:
@@ -13,6 +39,8 @@ Solid Queue is configured by default in new Rails 8 applications. But if you're
13
39
  1. `bundle add solid_queue`
14
40
  2. `bin/rails solid_queue:install`
15
41
 
42
+ (Note: The minimum supported version of Rails is 7.1 and Ruby is 3.1.6.)
43
+
16
44
  This will configure Solid Queue as the production Active Job backend, create the configuration files `config/queue.yml` and `config/recurring.yml`, and create the `db/queue_schema.rb`. It'll also create a `bin/jobs` executable wrapper that you can use to start Solid Queue.
17
45
 
18
46
  Once you've done that, you will then have to add the configuration for the queue database in `config/database.yml`. If you're using SQLite, it'll look like this:
@@ -43,8 +71,6 @@ production:
43
71
  migrations_paths: db/queue_migrate
44
72
  ```
45
73
 
46
- Note: Calling `bin/rails solid_queue:install` will automatically add `config.solid_queue.connects_to = { database: { writing: :queue } }` to `config/environments/production.rb`, so no additional configuration is needed there (although you must make sure that you use the `queue` name in `database.yml` for this to match!). But if you want to use Solid Queue in a different environment (like staging or even development), you'll have to manually add that `config.solid_queue.connects_to` line to the respective environment file. And, as always, make sure that the name you're using for the database in `config/database.yml` matches the name you use in `config.solid_queue.connects_to`.
47
-
48
74
  Then run `db:prepare` in production to ensure the database is created and the schema is loaded.
49
75
 
50
76
  Now you're ready to start processing jobs by running `bin/jobs` on the server that's doing the work. This will start processing jobs in all queues using the default configuration. See [below](#configuration) to learn more about configuring Solid Queue.
@@ -53,6 +79,72 @@ For small projects, you can run Solid Queue on the same machine as your webserve
53
79
 
54
80
  **Note**: future changes to the schema will come in the form of regular migrations.
55
81
 
82
+ ### Usage in development and other non-production environments
83
+
84
+ Calling `bin/rails solid_queue:install` will automatically add `config.solid_queue.connects_to = { database: { writing: :queue } }` to `config/environments/production.rb`. In order to use Solid Queue in other environments (such as development or staging), you'll need to add a similar configuration(s).
85
+
86
+ For example, if you're using SQLite in development, update `database.yml` as follows:
87
+
88
+ ```diff
89
+ development:
90
+ + primary:
91
+ <<: *default
92
+ database: storage/development.sqlite3
93
+ + queue:
94
+ + <<: *default
95
+ + database: storage/development_queue.sqlite3
96
+ + migrations_paths: db/queue_migrate
97
+ ```
98
+
99
+ Next, add the following to `development.rb`
100
+
101
+ ```ruby
102
+ # Use Solid Queue in Development.
103
+ config.active_job.queue_adapter = :solid_queue
104
+ config.solid_queue.connects_to = { database: { writing: :queue } }
105
+ ```
106
+
107
+ Once you've added this, run `db:prepare` to create the Solid Queue database and load the schema.
108
+
109
+ Finally, in order for jobs to be processed, you'll need to have Solid Queue running. In Development, this can be done via [the Puma plugin](#puma-plugin) as well. In `puma.rb` update the following line:
110
+
111
+ ```ruby
112
+ # You can either set the env var, or check for development
113
+ plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] || Rails.env.development?
114
+ ```
115
+
116
+ You can also just use `bin/jobs`, but in this case you might want to [set a different logger for Solid Queue](#other-configuration-settings) because the default logger will log to `log/development.log` and you won't see anything when you run `bin/jobs`. For example:
117
+ ```ruby
118
+ config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT)
119
+ ```
120
+
121
+ **Note about Action Cable**: If you use Action Cable (or anything dependent on Action Cable, such as Turbo Streams), you will also need to update it to use a database.
122
+
123
+ In `config/cable.yml`
124
+
125
+ ```diff
126
+ development:
127
+ - adapter: async
128
+ + adapter: solid_cable
129
+ + connects_to:
130
+ + database:
131
+ + writing: cable
132
+ + polling_interval: 0.1.seconds
133
+ + message_retention: 1.day
134
+ ```
135
+
136
+ In `config/database.yml`
137
+
138
+ ```diff
139
+ development:
140
+ primary:
141
+ <<: *default
142
+ database: storage/development.sqlite3
143
+ + cable:
144
+ + <<: *default
145
+ + database: storage/development_cable.sqlite3
146
+ + migrations_paths: db/cable_migrate
147
+ ```
56
148
 
57
149
  ### Single database configuration
58
150
 
@@ -64,7 +156,7 @@ Running Solid Queue in a separate database is recommended, but it's also possibl
64
156
 
65
157
  You won't have multiple databases, so `database.yml` doesn't need to have primary and queue database.
66
158
 
67
- ## Incremental adoption
159
+ ### Incremental adoption
68
160
 
69
161
  If you're planning to adopt Solid Queue incrementally by switching one job at the time, you can do so by leaving the `config.active_job.queue_adapter` set to your old backend, and then set the `queue_adapter` directly in the jobs you're moving:
70
162
 
@@ -77,7 +169,7 @@ class MyJob < ApplicationJob
77
169
  end
78
170
  ```
79
171
 
80
- ## High performance requirements
172
+ ### High performance requirements
81
173
 
82
174
  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.
83
175
 
@@ -86,6 +178,7 @@ Solid Queue was designed for the highest throughput when used with MySQL 8+ or P
86
178
  ### Workers, dispatchers and scheduler
87
179
 
88
180
  We have several types of actors in Solid Queue:
181
+
89
182
  - _Workers_ are in charge of picking jobs ready to run from queues and processing them. They work off the `solid_queue_ready_executions` table.
90
183
  - _Dispatchers_ are in charge of selecting jobs scheduled to run in the future that are due and _dispatching_ them, which is simply moving them from the `solid_queue_scheduled_executions` table over to the `solid_queue_ready_executions` table so that workers can pick them up. On top of that, they do some maintenance work related to [concurrency controls](#concurrency-controls).
91
184
  - The _scheduler_ manages [recurring tasks](#recurring-tasks), enqueuing jobs for them when they're due.
@@ -99,7 +192,6 @@ By default, Solid Queue will try to find your configuration under `config/queue.
99
192
  bin/jobs -c config/calendar.yml
100
193
  ```
101
194
 
102
-
103
195
  This is what this configuration looks like:
104
196
 
105
197
  ```yml
@@ -153,6 +245,7 @@ Here's an overview of the different options:
153
245
  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).
154
246
 
155
247
  - `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.
248
+ 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.
156
249
  - `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.
157
250
  - `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.
158
251
 
@@ -220,7 +313,7 @@ and then remove the paused ones. Pausing in general should be something rare, us
220
313
  Do this:
221
314
 
222
315
  ```yml
223
- queues: background, backend
316
+ queues: [ background, backend ]
224
317
  ```
225
318
 
226
319
  instead of this:
@@ -250,33 +343,6 @@ You can configure the database used by Solid Queue via the `config.solid_queue.c
250
343
 
251
344
  All the options available to Active Record for multiple databases can be used here.
252
345
 
253
- ## Lifecycle hooks
254
-
255
- In Solid queue, you can hook into two different points in the supervisor's life:
256
- - `start`: after the supervisor has finished booting and right before it forks workers and dispatchers.
257
- - `stop`: after receiving a signal (`TERM`, `INT` or `QUIT`) and right before starting graceful or immediate shutdown.
258
-
259
- And into two different points in a worker's life:
260
- - `worker_start`: after the worker has finished booting and right before it starts the polling loop.
261
- - `worker_stop`: after receiving a signal (`TERM`, `INT` or `QUIT`) and right before starting graceful or immediate shutdown (which is just `exit!`).
262
-
263
- You can use the following methods with a block to do this:
264
- ```ruby
265
- SolidQueue.on_start
266
- SolidQueue.on_stop
267
-
268
- SolidQueue.on_worker_start
269
- SolidQueue.on_worker_stop
270
- ```
271
-
272
- For example:
273
- ```ruby
274
- SolidQueue.on_start { start_metrics_server }
275
- SolidQueue.on_stop { stop_metrics_server }
276
- ```
277
-
278
- These can be called several times to add multiple hooks, but it needs to happen before Solid Queue is started. An initializer would be a good place to do this.
279
-
280
346
  ### Other configuration settings
281
347
 
282
348
  _Note_: The settings in this section should be set in your `config/application.rb` or your environment config like this: `config.solid_queue.silence_polling = true`
@@ -299,9 +365,58 @@ There are several settings that control how Solid Queue works that you can set a
299
365
  - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `true`.
300
366
  - `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.
301
367
  - `preserve_finished_jobs`: whether to keep finished jobs in the `solid_queue_jobs` table—defaults to `true`.
302
- - `clear_finished_jobs_after`: period to keep finished jobs around, in case `preserve_finished_jobs` is true—defaults to 1 day. **Note:** Right now, there's no automatic cleanup of finished jobs. You'd need to do this by periodically invoking `SolidQueue::Job.clear_finished_in_batches`, but this will happen automatically in the near future.
368
+ - `clear_finished_jobs_after`: period to keep finished jobs around, in case `preserve_finished_jobs` is true—defaults to 1 day. **Note:** Right now, there's no automatic cleanup of finished jobs. You'd need to do this by periodically invoking `SolidQueue::Job.clear_finished_in_batches`, which can be configured as [a recurring task](#recurring-tasks).
303
369
  - `default_concurrency_control_period`: the value to be used as the default for the `duration` parameter in [concurrency controls](#concurrency-controls). It defaults to 3 minutes.
304
370
 
371
+
372
+ ## Lifecycle hooks
373
+
374
+ In Solid queue, you can hook into two different points in the supervisor's life:
375
+ - `start`: after the supervisor has finished booting and right before it forks workers and dispatchers.
376
+ - `stop`: after receiving a signal (`TERM`, `INT` or `QUIT`) and right before starting graceful or immediate shutdown.
377
+
378
+ And into two different points in the worker's, dispatcher's and scheduler's life:
379
+ - `(worker|dispatcher|scheduler)_start`: after the worker/dispatcher/scheduler has finished booting and right before it starts the polling loop or loading the recurring schedule.
380
+ - `(worker|dispatcher|scheduler)_stop`: after receiving a signal (`TERM`, `INT` or `QUIT`) and right before starting graceful or immediate shutdown (which is just `exit!`).
381
+
382
+ Each of these hooks has an instance of the supervisor/worker/dispatcher/scheduler yielded to the block so that you may read its configuration for logging or metrics reporting purposes.
383
+
384
+ You can use the following methods with a block to do this:
385
+ ```ruby
386
+ SolidQueue.on_start
387
+ SolidQueue.on_stop
388
+
389
+ SolidQueue.on_worker_start
390
+ SolidQueue.on_worker_stop
391
+
392
+ SolidQueue.on_dispatcher_start
393
+ SolidQueue.on_dispatcher_stop
394
+
395
+ SolidQueue.on_scheduler_start
396
+ SolidQueue.on_scheduler_stop
397
+ ```
398
+
399
+ For example:
400
+ ```ruby
401
+ SolidQueue.on_start do |supervisor|
402
+ MyMetricsReporter.process_name = supervisor.name
403
+
404
+ start_metrics_server
405
+ end
406
+
407
+ SolidQueue.on_stop do |_supervisor|
408
+ stop_metrics_server
409
+ end
410
+
411
+ SolidQueue.on_worker_start do |worker|
412
+ MyMetricsReporter.process_name = worker.name
413
+ MyMetricsReporter.queues = worker.queues.join(',')
414
+ end
415
+ ```
416
+
417
+ These can be called several times to add multiple hooks, but it needs to happen before Solid Queue is started. An initializer would be a good place to do this.
418
+
419
+
305
420
  ## Errors when enqueuing
306
421
 
307
422
  Solid Queue will raise a `SolidQueue::Job::EnqueueError` for any Active Record errors that happen when enqueuing a job. The reason for not raising `ActiveJob::EnqueueError` is that this one gets handled by Active Job, causing `perform_later` to return `false` and set `job.enqueue_error`, yielding the job to a block that you need to pass to `perform_later`. This works very well for your own jobs, but makes failure very hard to handle for jobs enqueued by Rails or other gems, such as `Turbo::Streams::BroadcastJob` or `ActiveStorage::AnalyzeJob`, because you don't control the call to `perform_later` in that cases.
@@ -412,6 +527,12 @@ plugin :solid_queue
412
527
  ```
413
528
  to your `puma.rb` configuration.
414
529
 
530
+ If you're using Puma in development but you don't want to use Solid Queue in development, make sure you avoid the plugin being used, for example using an environment variable like this:
531
+ ```ruby
532
+ plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
533
+ ```
534
+ that you set in production only. This is what Rails 8's default Puma config looks like. Otherwise, if you're using Puma in development but not Solid Queue, starting Puma would start also Solid Queue supervisor and it'll most likely fail because it won't be properly configured.
535
+
415
536
 
416
537
  ## Jobs and transactional integrity
417
538
  :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.
@@ -477,9 +598,15 @@ MyJob.perform_later(42, status: "custom_status")
477
598
 
478
599
  - `priority`: a numeric priority value to be used when enqueuing the job.
479
600
 
480
-
481
601
  Tasks are enqueued at their corresponding times by the scheduler, and each task schedules the next one. This is pretty much [inspired by what GoodJob does](https://github.com/bensheldon/good_job/blob/994ecff5323bf0337e10464841128fda100750e6/lib/good_job/cron_manager.rb).
482
602
 
603
+ For recurring tasks defined as a `command`, you can also change the job class that runs them as follows:
604
+ ```ruby
605
+ Rails.application.config.after_initialize do # or to_prepare
606
+ SolidQueue::RecurringTask.default_job_class = MyRecurringCommandJob
607
+ end
608
+ ```
609
+
483
610
  It's possible to run multiple schedulers with the same `recurring_tasks` configuration, for example, if you have multiple servers for redundancy, and you run the `scheduler` in more than one of them. To avoid enqueuing duplicate tasks at the same time, an entry in a new `solid_queue_recurring_executions` table is created in the same transaction as the job is enqueued. This table has a unique index on `task_key` and `run_at`, ensuring only one entry per task per time will be created. This only works if you have `preserve_finished_jobs` set to `true` (the default), and the guarantee applies as long as you keep the jobs around.
484
611
 
485
612
  **Note**: a single recurring schedule is supported, so you can have multiple schedulers using the same schedule, but not multiple schedulers using different configurations.
@@ -92,7 +92,7 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
92
92
 
93
93
  private
94
94
  def execute
95
- ActiveJob::Base.execute(job.arguments)
95
+ ActiveJob::Base.execute(job.arguments.merge("provider_job_id" => job.id))
96
96
  Result.new(true, nil)
97
97
  rescue Exception => e
98
98
  Result.new(false, e)
@@ -10,9 +10,10 @@ module SolidQueue
10
10
  end
11
11
 
12
12
  class_methods do
13
- def clear_finished_in_batches(batch_size: 500, finished_before: SolidQueue.clear_finished_jobs_after.ago, class_name: nil)
13
+ def clear_finished_in_batches(batch_size: 500, finished_before: SolidQueue.clear_finished_jobs_after.ago, class_name: nil, sleep_between_batches: 0)
14
14
  loop do
15
15
  records_deleted = clearable(finished_before: finished_before, class_name: class_name).limit(batch_size).delete_all
16
+ sleep(sleep_between_batches) if sleep_between_batches > 0
16
17
  break if records_deleted == 0
17
18
  end
18
19
  end
@@ -12,6 +12,8 @@ module SolidQueue
12
12
 
13
13
  scope :static, -> { where(static: true) }
14
14
 
15
+ has_many :recurring_executions, foreign_key: :task_key, primary_key: :key
16
+
15
17
  mattr_accessor :default_job_class
16
18
  self.default_job_class = RecurringJob
17
19
 
@@ -53,6 +55,18 @@ module SolidQueue
53
55
  parsed_schedule.next_time.utc
54
56
  end
55
57
 
58
+ def previous_time
59
+ parsed_schedule.previous_time.utc
60
+ end
61
+
62
+ def last_enqueued_time
63
+ if recurring_executions.loaded?
64
+ recurring_executions.map(&:run_at).max
65
+ else
66
+ recurring_executions.maximum(:run_at)
67
+ end
68
+ end
69
+
56
70
  def enqueue(at:)
57
71
  SolidQueue.instrument(:enqueue_recurring_task, task: key, at: at) do |payload|
58
72
  active_job = if using_solid_queue_adapter?
@@ -14,7 +14,7 @@ module SolidQueue
14
14
  def dispatch_next_batch(batch_size)
15
15
  transaction do
16
16
  job_ids = next_batch(batch_size).non_blocking_lock.pluck(:job_id)
17
- if job_ids.empty? then []
17
+ if job_ids.empty? then 0
18
18
  else
19
19
  SolidQueue.instrument(:dispatch_scheduled, batch_size: batch_size) do |payload|
20
20
  payload[:size] = dispatch_jobs(job_ids)
@@ -4,7 +4,7 @@ module SolidQueue
4
4
  module AppExecutor
5
5
  def wrap_in_app_executor(&block)
6
6
  if SolidQueue.app_executor
7
- SolidQueue.app_executor.wrap(&block)
7
+ SolidQueue.app_executor.wrap(source: "application.solid_queue", &block)
8
8
  else
9
9
  yield
10
10
  end
@@ -2,6 +2,12 @@
2
2
 
3
3
  module SolidQueue
4
4
  class Configuration
5
+ include ActiveModel::Model
6
+
7
+ validate :ensure_configured_processes
8
+ validate :ensure_valid_recurring_tasks
9
+ validate :ensure_correctly_sized_thread_pool
10
+
5
11
  class Process < Struct.new(:kind, :attributes)
6
12
  def instantiate
7
13
  "SolidQueue::#{kind.to_s.titleize}".safe_constantize.new(**attributes)
@@ -36,14 +42,46 @@ module SolidQueue
36
42
  end
37
43
  end
38
44
 
39
- def max_number_of_threads
40
- # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
41
- workers_options.map { |options| options[:threads] }.max + 2
45
+ def error_messages
46
+ if configured_processes.none?
47
+ "No workers or processed configured. Exiting..."
48
+ else
49
+ error_messages = invalid_tasks.map do |task|
50
+ all_messages = task.errors.full_messages.map { |msg| "\t#{msg}" }.join("\n")
51
+ "#{task.key}:\n#{all_messages}"
52
+ end
53
+ .join("\n")
54
+
55
+ "Invalid processes configured:\n#{error_messages}"
56
+ end
42
57
  end
43
58
 
44
59
  private
45
60
  attr_reader :options
46
61
 
62
+ def ensure_configured_processes
63
+ unless configured_processes.any?
64
+ errors.add(:base, "No processes configured")
65
+ end
66
+ end
67
+
68
+ def ensure_valid_recurring_tasks
69
+ unless skip_recurring_tasks? || invalid_tasks.none?
70
+ error_messages = invalid_tasks.map do |task|
71
+ "- #{task.key}: #{task.errors.full_messages.join(", ")}"
72
+ end
73
+
74
+ errors.add(:base, "Invalid recurring tasks:\n#{error_messages.join("\n")}")
75
+ end
76
+ end
77
+
78
+ def ensure_correctly_sized_thread_pool
79
+ if (db_pool_size = SolidQueue::Record.connection_pool&.size) && db_pool_size < estimated_number_of_threads
80
+ errors.add(:base, "Solid Queue is configured to use #{estimated_number_of_threads} threads but the " +
81
+ "database connection pool is #{db_pool_size}. Increase it in `config/database.yml`")
82
+ end
83
+ end
84
+
47
85
  def default_options
48
86
  {
49
87
  config_file: Rails.root.join(ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH),
@@ -54,6 +92,10 @@ module SolidQueue
54
92
  }
55
93
  end
56
94
 
95
+ def invalid_tasks
96
+ recurring_tasks.select(&:invalid?)
97
+ end
98
+
57
99
  def only_work?
58
100
  options[:only_work]
59
101
  end
@@ -99,8 +141,8 @@ module SolidQueue
99
141
 
100
142
  def recurring_tasks
101
143
  @recurring_tasks ||= recurring_tasks_config.map do |id, options|
102
- RecurringTask.from_configuration(id, **options)
103
- end.select(&:valid?)
144
+ RecurringTask.from_configuration(id, **options) if options&.has_key?(:schedule)
145
+ end.compact
104
146
  end
105
147
 
106
148
  def processes_config
@@ -111,7 +153,9 @@ module SolidQueue
111
153
  end
112
154
 
113
155
  def recurring_tasks_config
114
- @recurring_tasks_config ||= config_from options[:recurring_schedule_file]
156
+ @recurring_tasks_config ||= begin
157
+ config_from options[:recurring_schedule_file]
158
+ end
115
159
  end
116
160
 
117
161
 
@@ -147,5 +191,11 @@ module SolidQueue
147
191
  {}
148
192
  end
149
193
  end
194
+
195
+ def estimated_number_of_threads
196
+ # At most "threads" in each worker + 1 thread for the worker + 1 thread for the heartbeat task
197
+ thread_count = workers_options.map { |options| options.fetch(:threads, WORKER_DEFAULTS[:threads]) }.max
198
+ (thread_count || 1) + 2
199
+ end
150
200
  end
151
201
  end
@@ -2,10 +2,14 @@
2
2
 
3
3
  module SolidQueue
4
4
  class Dispatcher < Processes::Poller
5
- attr_accessor :batch_size, :concurrency_maintenance
5
+ include LifecycleHooks
6
+ attr_reader :batch_size
6
7
 
8
+ after_boot :run_start_hooks
7
9
  after_boot :start_concurrency_maintenance
8
10
  before_shutdown :stop_concurrency_maintenance
11
+ before_shutdown :run_stop_hooks
12
+ after_shutdown :run_exit_hooks
9
13
 
10
14
  def initialize(**options)
11
15
  options = options.dup.with_defaults(SolidQueue::Configuration::DISPATCHER_DEFAULTS)
@@ -22,9 +26,12 @@ module SolidQueue
22
26
  end
23
27
 
24
28
  private
29
+ attr_reader :concurrency_maintenance
30
+
25
31
  def poll
26
32
  batch = dispatch_next_batch
27
- batch.size
33
+
34
+ batch.zero? ? polling_interval : 0.seconds
28
35
  end
29
36
 
30
37
  def dispatch_next_batch
@@ -37,20 +44,12 @@ module SolidQueue
37
44
  concurrency_maintenance&.start
38
45
  end
39
46
 
40
- def schedule_recurring_tasks
41
- recurring_schedule.schedule_tasks
42
- end
43
-
44
47
  def stop_concurrency_maintenance
45
48
  concurrency_maintenance&.stop
46
49
  end
47
50
 
48
- def unschedule_recurring_tasks
49
- recurring_schedule.unschedule_tasks
50
- end
51
-
52
51
  def all_work_completed?
53
- SolidQueue::ScheduledExecution.none? && recurring_schedule.empty?
52
+ SolidQueue::ScheduledExecution.none?
54
53
  end
55
54
 
56
55
  def set_procline
@@ -37,5 +37,13 @@ module SolidQueue
37
37
  include ActiveJob::ConcurrencyControls
38
38
  end
39
39
  end
40
+
41
+ initializer "solid_queue.include_interruptible_concern" do
42
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
43
+ SolidQueue::Processes::Base.include SolidQueue::Processes::Interruptible
44
+ else
45
+ SolidQueue::Processes::Base.include SolidQueue::Processes::OgInterruptible
46
+ end
47
+ end
40
48
  end
41
49
  end
@@ -5,7 +5,7 @@ module SolidQueue
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- mattr_reader :lifecycle_hooks, default: { start: [], stop: [] }
8
+ mattr_reader :lifecycle_hooks, default: { start: [], stop: [], exit: [] }
9
9
  end
10
10
 
11
11
  class_methods do
@@ -17,7 +17,12 @@ module SolidQueue
17
17
  self.lifecycle_hooks[:stop] << block
18
18
  end
19
19
 
20
+ def on_exit(&block)
21
+ self.lifecycle_hooks[:exit] << block
22
+ end
23
+
20
24
  def clear_hooks
25
+ self.lifecycle_hooks[:exit] = []
21
26
  self.lifecycle_hooks[:start] = []
22
27
  self.lifecycle_hooks[:stop] = []
23
28
  end
@@ -32,9 +37,13 @@ module SolidQueue
32
37
  run_hooks_for :stop
33
38
  end
34
39
 
40
+ def run_exit_hooks
41
+ run_hooks_for :exit
42
+ end
43
+
35
44
  def run_hooks_for(event)
36
45
  self.class.lifecycle_hooks.fetch(event, []).each do |block|
37
- block.call
46
+ block.call(self)
38
47
  rescue Exception => exception
39
48
  handle_thread_error(exception)
40
49
  end
@@ -145,6 +145,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
145
145
  end
146
146
 
147
147
  def replace_fork(event)
148
+ supervisor_pid = event.payload[:supervisor_pid]
148
149
  status = event.payload[:status]
149
150
  attributes = event.payload.slice(:pid).merge \
150
151
  status: (status.exitstatus || "no exit status set"),
@@ -155,7 +156,7 @@ class SolidQueue::LogSubscriber < ActiveSupport::LogSubscriber
155
156
 
156
157
  if replaced_fork = event.payload[:fork]
157
158
  info formatted_event(event, action: "Replaced terminated #{replaced_fork.kind}", **attributes.merge(hostname: replaced_fork.hostname, name: replaced_fork.name))
158
- else
159
+ elsif supervisor_pid != 1 # Running Docker, possibly having some processes that have been reparented
159
160
  warn formatted_event(event, action: "Tried to replace forked process but it had already died", **attributes)
160
161
  end
161
162
  end
@@ -18,20 +18,16 @@ module SolidQueue
18
18
  def post(execution)
19
19
  available_threads.decrement
20
20
 
21
- future = Concurrent::Future.new(args: [ execution ], executor: executor) do |thread_execution|
21
+ Concurrent::Promises.future_on(executor, execution) do |thread_execution|
22
22
  wrap_in_app_executor do
23
23
  thread_execution.perform
24
24
  ensure
25
25
  available_threads.increment
26
26
  mutex.synchronize { on_idle.try(:call) if idle? }
27
27
  end
28
+ end.on_rejection! do |e|
29
+ handle_thread_error(e)
28
30
  end
29
-
30
- future.add_observer do |_, _, error|
31
- handle_thread_error(error) if error
32
- end
33
-
34
- future.execute
35
31
  end
36
32
 
37
33
  def idle_threads
@@ -4,7 +4,7 @@ module SolidQueue
4
4
  module Processes
5
5
  class Base
6
6
  include Callbacks # Defines callbacks needed by other concerns
7
- include AppExecutor, Registrable, Interruptible, Procline
7
+ include AppExecutor, Registrable, Procline
8
8
 
9
9
  attr_reader :name
10
10
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidQueue::Processes
4
4
  module Interruptible
5
+ include SolidQueue::AppExecutor
6
+
5
7
  def wake_up
6
8
  interrupt
7
9
  end
@@ -12,14 +14,20 @@ module SolidQueue::Processes
12
14
  queue << true
13
15
  end
14
16
 
17
+ # Sleeps for 'time'. Can be interrupted asynchronously and return early via wake_up.
18
+ # @param time [Numeric, Duration] the time to sleep. 0 returns immediately.
15
19
  def interruptible_sleep(time)
16
- # Invoking from the main thread can result in a 35% slowdown (at least when running the test suite).
17
- # Using some form of Async (Futures) addresses this performance issue.
20
+ # Invoking this from the main thread may result in significant slowdown.
21
+ # Utilizing asynchronous execution (Futures) addresses this performance issue.
18
22
  Concurrent::Promises.future(time) do |timeout|
19
- if timeout > 0 && queue.pop(timeout:)
20
- queue.clear
21
- end
23
+ queue.clear unless queue.pop(timeout:).nil?
24
+ end.on_rejection! do |e|
25
+ wrapped_exception = RuntimeError.new("Interruptible#interruptible_sleep - #{e.class}: #{e.message}")
26
+ wrapped_exception.set_backtrace(e.backtrace)
27
+ handle_thread_error(wrapped_exception)
22
28
  end.value
29
+
30
+ nil
23
31
  end
24
32
 
25
33
  def queue
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue::Processes
4
+ # The original implementation of Interruptible that works
5
+ # with Ruby 3.1 and earlier
6
+ module OgInterruptible
7
+ def wake_up
8
+ interrupt
9
+ end
10
+
11
+ private
12
+ SELF_PIPE_BLOCK_SIZE = 11
13
+
14
+ def interrupt
15
+ self_pipe[:writer].write_nonblock(".")
16
+ rescue Errno::EAGAIN, Errno::EINTR
17
+ # Ignore writes that would block and retry
18
+ # if another signal arrived while writing
19
+ retry
20
+ end
21
+
22
+ def interruptible_sleep(time)
23
+ if time > 0 && self_pipe[:reader].wait_readable(time)
24
+ loop { self_pipe[:reader].read_nonblock(SELF_PIPE_BLOCK_SIZE) }
25
+ end
26
+ rescue Errno::EAGAIN, Errno::EINTR
27
+ end
28
+
29
+ # Self-pipe for signal-handling (http://cr.yp.to/docs/selfpipe.html)
30
+ def self_pipe
31
+ @self_pipe ||= create_self_pipe
32
+ end
33
+
34
+ def create_self_pipe
35
+ reader, writer = IO.pipe
36
+ { reader: reader, writer: writer }
37
+ end
38
+ end
39
+ end
@@ -25,11 +25,11 @@ module SolidQueue::Processes
25
25
  loop do
26
26
  break if shutting_down?
27
27
 
28
- wrap_in_app_executor do
29
- unless poll > 0
30
- interruptible_sleep(polling_interval)
31
- end
28
+ delay = wrap_in_app_executor do
29
+ poll
32
30
  end
31
+
32
+ interruptible_sleep(delay)
33
33
  end
34
34
  ensure
35
35
  SolidQueue.instrument(:shutdown_process, process: self) do
@@ -4,7 +4,7 @@ module SolidQueue
4
4
  module Processes
5
5
  class ProcessPrunedError < RuntimeError
6
6
  def initialize(last_heartbeat_at)
7
- super("Process was found dead and pruned (last heartbeat at: #{last_heartbeat_at}")
7
+ super("Process was found dead and pruned (last heartbeat at: #{last_heartbeat_at})")
8
8
  end
9
9
  end
10
10
  end
@@ -3,11 +3,15 @@
3
3
  module SolidQueue
4
4
  class Scheduler < Processes::Base
5
5
  include Processes::Runnable
6
+ include LifecycleHooks
6
7
 
7
- attr_accessor :recurring_schedule
8
+ attr_reader :recurring_schedule
8
9
 
10
+ after_boot :run_start_hooks
9
11
  after_boot :schedule_recurring_tasks
10
12
  before_shutdown :unschedule_recurring_tasks
13
+ before_shutdown :run_stop_hooks
14
+ after_shutdown :run_exit_hooks
11
15
 
12
16
  def initialize(recurring_tasks:, **options)
13
17
  @recurring_schedule = RecurringSchedule.new(recurring_tasks)
@@ -5,15 +5,17 @@ module SolidQueue
5
5
  include LifecycleHooks
6
6
  include Maintenance, Signals, Pidfiled
7
7
 
8
+ after_shutdown :run_exit_hooks
9
+
8
10
  class << self
9
11
  def start(**options)
10
12
  SolidQueue.supervisor = true
11
13
  configuration = Configuration.new(**options)
12
14
 
13
- if configuration.configured_processes.any?
15
+ if configuration.valid?
14
16
  new(configuration).tap(&:start)
15
17
  else
16
- abort "No workers or processed configured. Exiting..."
18
+ abort configuration.errors.full_messages.join("\n") + "\nExiting..."
17
19
  end
18
20
  end
19
21
  end
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "1.1.0"
2
+ VERSION = "1.1.4"
3
3
  end
@@ -6,13 +6,16 @@ module SolidQueue
6
6
 
7
7
  after_boot :run_start_hooks
8
8
  before_shutdown :run_stop_hooks
9
+ after_shutdown :run_exit_hooks
9
10
 
10
- attr_accessor :queues, :pool
11
+ attr_reader :queues, :pool
11
12
 
12
13
  def initialize(**options)
13
14
  options = options.dup.with_defaults(SolidQueue::Configuration::WORKER_DEFAULTS)
14
15
 
15
- @queues = Array(options[:queues])
16
+ # Ensure that the queues array is deep frozen to prevent accidental modification
17
+ @queues = Array(options[:queues]).map(&:freeze).freeze
18
+
16
19
  @pool = Pool.new(options[:threads], on_idle: -> { wake_up })
17
20
 
18
21
  super(**options)
@@ -29,7 +32,7 @@ module SolidQueue
29
32
  pool.post(execution)
30
33
  end
31
34
 
32
- executions.size
35
+ pool.idle? ? polling_interval : 10.minutes
33
36
  end
34
37
  end
35
38
 
data/lib/solid_queue.rb CHANGED
@@ -41,14 +41,20 @@ module SolidQueue
41
41
  mattr_accessor :clear_finished_jobs_after, default: 1.day
42
42
  mattr_accessor :default_concurrency_control_period, default: 3.minutes
43
43
 
44
- delegate :on_start, :on_stop, to: Supervisor
44
+ delegate :on_start, :on_stop, :on_exit, to: Supervisor
45
45
 
46
- def on_worker_start(...)
47
- Worker.on_start(...)
48
- end
46
+ [ Dispatcher, Scheduler, Worker ].each do |process|
47
+ define_singleton_method(:"on_#{process.name.demodulize.downcase}_start") do |&block|
48
+ process.on_start(&block)
49
+ end
50
+
51
+ define_singleton_method(:"on_#{process.name.demodulize.downcase}_stop") do |&block|
52
+ process.on_stop(&block)
53
+ end
49
54
 
50
- def on_worker_stop(...)
51
- Worker.on_stop(...)
55
+ define_singleton_method(:"on_#{process.name.demodulize.downcase}_exit") do |&block|
56
+ process.on_exit(&block)
57
+ end
52
58
  end
53
59
 
54
60
  def supervisor?
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.1.0
4
+ version: 1.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rosa Gutierrez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-05 00:00:00.000000000 Z
11
+ date: 2025-03-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -220,6 +220,20 @@ dependencies:
220
220
  - - ">="
221
221
  - !ruby/object:Gem::Version
222
222
  version: '0'
223
+ - !ruby/object:Gem::Dependency
224
+ name: zeitwerk
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - '='
228
+ - !ruby/object:Gem::Version
229
+ version: 2.6.0
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - '='
235
+ - !ruby/object:Gem::Version
236
+ version: 2.6.0
223
237
  description: Database-backed Active Job backend.
224
238
  email:
225
239
  - rosa@37signals.com
@@ -281,6 +295,7 @@ files:
281
295
  - lib/solid_queue/processes/base.rb
282
296
  - lib/solid_queue/processes/callbacks.rb
283
297
  - lib/solid_queue/processes/interruptible.rb
298
+ - lib/solid_queue/processes/og_interruptible.rb
284
299
  - lib/solid_queue/processes/poller.rb
285
300
  - lib/solid_queue/processes/process_exit_error.rb
286
301
  - lib/solid_queue/processes/process_missing_error.rb
@@ -307,15 +322,8 @@ metadata:
307
322
  homepage_uri: https://github.com/rails/solid_queue
308
323
  source_code_uri: https://github.com/rails/solid_queue
309
324
  post_install_message: |
310
- Upgrading to Solid Queue 0.9.0? There are some breaking changes about how recurring tasks are configured.
311
-
312
- Upgrading to Solid Queue 0.8.0 from < 0.6.0? You need to upgrade to 0.6.0 first.
313
-
314
- Upgrading to Solid Queue 0.4.x, 0.5.x, 0.6.x or 0.7.x? There are some breaking changes about how Solid Queue is started,
315
- configuration and new migrations.
316
-
317
- --> Check https://github.com/rails/solid_queue/blob/main/UPGRADING.md
318
- for upgrade instructions.
325
+ Upgrading from Solid Queue < 1.0? Check details on breaking changes and upgrade instructions
326
+ --> https://github.com/rails/solid_queue/blob/main/UPGRADING.md
319
327
  rdoc_options: []
320
328
  require_paths:
321
329
  - lib
@@ -323,7 +331,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
323
331
  requirements:
324
332
  - - ">="
325
333
  - !ruby/object:Gem::Version
326
- version: '0'
334
+ version: '3.1'
327
335
  required_rubygems_version: !ruby/object:Gem::Requirement
328
336
  requirements:
329
337
  - - ">="