solid_queue 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cb50c543011419d9ad0cee9bbf9009088732dd3025b1c03fb89c9155e81b4ee
4
- data.tar.gz: 97ab2ed127edf72e5cea4ac78f69778f1728b84ef4ec4922dc5fc885ffa123c2
3
+ metadata.gz: 72d9dc2e30127c7b1fe101648d4d18b04af2d33491c729816e25dcf488246b54
4
+ data.tar.gz: e39dcad251d4124355adcedfa9cb1ee88eea12043eb8505568332d9fcacee428
5
5
  SHA512:
6
- metadata.gz: eb2150d9025eba950e409d9b84543cc41e019f997dfb4c65c4d7819365576198ddf0253847c7937594eb6930a0b528415c68456c73f2afa084353ff72fdc5511
7
- data.tar.gz: 4918c8b1bc0a37ef6b888408ba7fdfdb96401cb5515d24138eb850d726a17913e4544e53c507c6069eaf1f905b6fb5c9e9406877b870eec135d0f62141480962
6
+ metadata.gz: 967732a5e679269543869ca64aa410bfc43c935ad3fb6856de0860bbe5cee58716e389b5935e48a91c6c658a84fa79aff9f5eae93ba3931344d31aff0d45c8fc
7
+ data.tar.gz: 523af9f1b26a137d99ade0805bdc955cb81584971308928d5e16e83d3c46861947deaaa95ff96775d7eceb913f3d8995eca14707cda4533f7c3ee39f20afb650
data/README.md CHANGED
@@ -2,27 +2,11 @@
2
2
 
3
3
  Solid Queue is a DB-based queuing backend for [Active Job](https://edgeguides.rubyonrails.org/active_job_basics.html), designed with simplicity and performance in mind.
4
4
 
5
- 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 multi-threading.
5
+ Besides regular job enqueuing and processing, Solid Queue supports delayed jobs, concurrency controls, pausing queues, numeric priorities per job, priorities by queue order, and bulk enqueuing (`enqueue_all` for Active Job's `perform_all_later`). _Improvements to logging and instrumentation, a better CLI tool, a way to run within an existing process in "async" mode, unique jobs and recurring, cron-like tasks are coming very soon._
6
6
 
7
- ## Usage
8
- To set Solid Queue as your Active Job's queue backend, you should add this to your environment config:
9
- ```ruby
10
- # config/environments/production.rb
11
- config.active_job.queue_adapter = :solid_queue
12
- ```
13
-
14
- Alternatively, you can set only specific jobs to use Solid Queue as their backend if you're migrating from another adapter and want to move jobs progressively:
15
-
16
- ```ruby
17
- # app/jobs/my_job.rb
18
-
19
- class MyJob < ApplicationJob
20
- self.queue_adapter = :solid_queue
21
- # ...
22
- end
23
- ```
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 multi-threading.
24
8
 
25
- ## Installation
9
+ ## Installation and usage
26
10
  Add this line to your application's Gemfile:
27
11
 
28
12
  ```ruby
@@ -39,21 +23,51 @@ Or install it yourself as:
39
23
  $ gem install solid_queue
40
24
  ```
41
25
 
42
- Add the migration to your app and run it:
26
+ Now, you need to install the necessary migrations and configure the Active Job's adapter. You can do both at once using the provided generator:
27
+
28
+ ```bash
29
+ $ bin/rails generate solid_queue:install
43
30
  ```
31
+
32
+ This will set `solid_queue` as the Active Job's adapter in production, and will copy the required migration over to your app.
33
+
34
+ Alternatively, you can add the only the migration to your app:
35
+ ```bash
44
36
  $ bin/rails solid_queue:install:migrations
45
- $ bin/rails db:migrate
46
37
  ```
47
38
 
48
- With this, you'll be ready to enqueue jobs using Solid Queue, but you need to start Solid Queue's supervisor to run them.
39
+ And set Solid Queue as your Active Job's queue backend manually, in your environment config:
40
+ ```ruby
41
+ # config/environments/production.rb
42
+ config.active_job.queue_adapter = :solid_queue
43
+ ```
44
+
45
+ Alternatively, you can set only specific jobs to use Solid Queue as their backend if you're migrating from another adapter and want to move jobs progressively:
46
+
47
+ ```ruby
48
+ # app/jobs/my_job.rb
49
+
50
+ class MyJob < ApplicationJob
51
+ self.queue_adapter = :solid_queue
52
+ # ...
53
+ end
54
+ ```
55
+
56
+ Finally, you need to run the migrations:
57
+
58
+ ```bash
59
+ $ bin/rails db:migrate
49
60
  ```
61
+
62
+ After this, you'll be ready to enqueue jobs using Solid Queue, but you need to start Solid Queue's supervisor to run them.
63
+ ```bash
50
64
  $ bundle exec rake solid_queue:start
51
65
  ```
52
66
 
53
67
  This will start processing jobs in all queues using the default configuration. See [below](#configuration) to learn more about configuring Solid Queue.
54
68
 
55
69
  ## Requirements
56
- Besides Rails 7, Solid Queue works best 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.
70
+ Besides Rails 7.1, Solid Queue works best 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.
57
71
 
58
72
  ## Configuration
59
73
 
@@ -61,7 +75,7 @@ Besides Rails 7, Solid Queue works best with MySQL 8+ or PostgreSQL 9.5+, as the
61
75
 
62
76
  We have three types of processes in Solid Queue:
63
77
  - _Workers_ are in charge of picking jobs ready to run from queues and processing them. They work off the `solid_queue_ready_executions` table.
64
- - _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_jobs` table over to the `solid_queue_ready_executions` table so that workers can pick them up. They also do some maintenance work related to concurrency controls.
78
+ - _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. They also do some maintenance work related to concurrency controls.
65
79
  - The _supervisor_ forks workers and dispatchers according to the configuration, controls their heartbeats, and sends them signals to stop and start them when needed.
66
80
 
67
81
  By default, Solid Queue will try to find your configuration under `config/solid_queue.yml`, but you can set a different path using the environment variable `SOLID_QUEUE_CONFIG`. This is what this configuration looks like:
@@ -71,11 +85,12 @@ production:
71
85
  dispatchers:
72
86
  - polling_interval: 1
73
87
  batch_size: 500
88
+ concurrency_maintenance_interval: 300
74
89
  workers:
75
- - queues: *
90
+ - queues: "*"
76
91
  threads: 3
77
92
  polling_interval: 2
78
- - queues: real_time,background
93
+ - queues: [ real_time, background ]
79
94
  threads: 5
80
95
  polling_interval: 0.1
81
96
  processes: 3
@@ -83,20 +98,23 @@ production:
83
98
 
84
99
  Everything is optional. If no configuration is provided, Solid Queue will run with one dispatcher and one worker with default settings.
85
100
 
86
- - `polling_interval`: the time interval in seconds that workers and dispatchers will wait before checking for more jobs. This time defaults to `5` seconds for dispatchers and `1` second for workers.
87
- - `batch_size`: the dispatcher will dispatch jobs in batches of this size.
88
- - `queues`: the list of queues that workers will pick jobs from. You can use `*` to indicate all queues (which is also the default and the behaviour you'll get if you omit this). You can provide a comma-separated list of queues. Jobs will be polled from those queues in order, so for example, with `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`. You can also provide a prefix with a wildcard to match queues starting with a prefix. For example:
89
- ```yml
90
- staging:
91
- workers:
92
- - queues: staging*
93
- threads: 3
94
- polling_interval: 5
101
+ - `polling_interval`: the time interval in seconds that workers and dispatchers will wait before checking for more jobs. This time defaults to `1` second for dispatchers and `0.1` seconds for workers.
102
+ - `batch_size`: the dispatcher will dispatch jobs in batches of this size. The default is 500.
103
+ - `concurrency_maintenance_interval`: the time interval in seconds that the dispatcher will wait before checking for blocked jobs that can be unblocked. Read more about [concurrency controls](#concurrency-controls) to learn more about this setting. It defaults to `600` seconds.
104
+ - `queues`: the list of queues that workers will pick jobs from. You can use `*` to indicate all queues (which is also the default and the behaviour you'll get if you omit this). You can provide a single queue, or a list of queues as an array. Jobs will be polled from those queues in order, so for example, with `[ real_time, background ]`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`. You can also provide a prefix with a wildcard to match queues starting with a prefix. For example:
95
105
 
96
- ```
97
- This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored.
106
+ ```yml
107
+ staging:
108
+ workers:
109
+ - queues: staging*
110
+ threads: 3
111
+ polling_interval: 5
112
+
113
+ ```
98
114
 
99
- Finally, you can combine prefixes with exact names, like `staging*, background`, and the behaviour with respect to order will be the same as with only exact names.
115
+ This will create a worker fetching jobs from all queues starting with `staging`. The wildcard `*` is only allowed on its own or at the end of a queue name; you can't specify queue names such as `*_some_queue`. These will be ignored.
116
+
117
+ Finally, you can combine prefixes with exact names, like `[ staging*, background ]`, and the behaviour with respect to order will be the same as with only exact names.
100
118
  - `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 `5`. Only workers have this setting.
101
119
  - `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.
102
120
 
@@ -129,23 +147,25 @@ There are several settings that control how Solid Queue works that you can set a
129
147
  - `logger`: the logger you want Solid Queue to use. Defaults to the app logger.
130
148
  - `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
131
149
  - `on_thread_error`: custom lambda/Proc to call when there's an error within a thread that takes the exception raised as argument. Defaults to
132
- ```ruby
133
- -> (exception) { Rails.error.report(exception, handled: false) }
134
- ```
150
+
151
+ ```ruby
152
+ -> (exception) { Rails.error.report(exception, handled: false) }
153
+ ```
135
154
  - `connects_to`: a custom database configuration that will be used in the abstract `SolidQueue::Record` Active Record model. This is required to use a different database than the main app. For example:
136
- ```ruby
155
+
156
+ ```ruby
137
157
  # Use a separate DB for Solid Queue
138
158
  config.solid_queue.connects_to = { database: { writing: :solid_queue_primary, reading: :solid_queue_replica } }
139
- ```
159
+ ```
140
160
  - `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'd 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.
141
- - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to to 60 seconds.
142
- - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to to 5 minutes.
143
- - `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 to 5 seconds.
144
- - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to to `false`.
161
+ - `process_heartbeat_interval`: the heartbeat interval that all processes will follow—defaults to 60 seconds.
162
+ - `process_alive_threshold`: how long to wait until a process is considered dead after its last heartbeat—defaults to 5 minutes.
163
+ - `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.
164
+ - `silence_polling`: whether to silence Active Record logs emitted when polling for both workers and dispatchers—defaults to `false`.
145
165
  - `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.
146
- - `preserve_finished_jobs`: whether to keep finished jobs in the `solid_queue_jobs` table—defaults to to `true`.
147
- - `clear_finished_jobs_after`: period to keep finished jobs around, in case `preserve_finished_jobs` is true—defaults to 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.
148
- - `default_concurrency_control_period`: the value to be used as the default for the `duration` parameter in [concurrency controls](#concurrency-controls). It defaults to to 3 minutes.
166
+ - `preserve_finished_jobs`: whether to keep finished jobs in the `solid_queue_jobs` table—defaults to `true`.
167
+ - `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.
168
+ - `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.
149
169
 
150
170
 
151
171
  ## Concurrency controls
@@ -197,6 +217,20 @@ Note that the `duration` setting depends indirectly on the value for `concurrenc
197
217
 
198
218
  Finally, failed jobs that are automatically or manually retried work in the same way as new jobs that get enqueued: they get in the queue for gaining the lock, and whenever they get it, they'll be run. It doesn't matter if they had gained the lock already in the past.
199
219
 
220
+ ## Failed jobs and retries
221
+
222
+ Solid Queue doesn't include any automatic retry mechanism, it [relies on Active Job for this](https://edgeguides.rubyonrails.org/active_job_basics.html#retrying-or-discarding-failed-jobs). Jobs that fail will be kept in the system, and a _failed execution_ (a record in the `solid_queue_failed_executions` table) will be created for these. The job will stay there until manually discarded or re-enqueued. You can do this in a console as:
223
+ ```ruby
224
+ failed_execution = SolidQueue::FailedExecution.find(...) # Find the failed execution related to your job
225
+ failed_execution.error # inspect the error
226
+
227
+ failed_execution.retry # This will re-enqueue the job as if it was enqueued for the first time
228
+ failed_execution.discard # This will delete the job from the system
229
+ ```
230
+
231
+ We're planning to release a dashboard called _Mission Control_, where, among other things, you'll be able to examine and retry/discard failed jobs, one by one, or in bulk.
232
+
233
+
200
234
  ## Puma plugin
201
235
  We provide a Puma plugin if you want to run the Solid Queue's supervisor together with Puma and have Puma monitor and manage it. You just need to add
202
236
  ```ruby
@@ -211,20 +245,21 @@ to your `puma.rb` configuration.
211
245
  If you prefer not to rely on this, or avoid relying on it unintentionally, you should make sure that:
212
246
  - Your jobs relying on specific records are always enqueued on [`after_commit` callbacks](https://guides.rubyonrails.org/active_record_callbacks.html#after-commit-and-after-rollback) or otherwise from a place where you're certain that whatever data the job will use has been committed to the database before the job is enqueued.
213
247
  - Or, to opt out completely from this behaviour, configure a database for Solid Queue, even if it's the same as your app, ensuring that a different connection on the thread handling requests or running jobs for your app will be used to enqueue jobs. For example:
214
- ```ruby
215
- class ApplicationRecord < ActiveRecord::Base
216
- self.abstract_class = true
217
248
 
218
- connects_to database: { writing: :primary, reading: :replica }
219
- ```
249
+ ```ruby
250
+ class ApplicationRecord < ActiveRecord::Base
251
+ self.abstract_class = true
220
252
 
221
- ```ruby
222
- solid_queue.config.connects_to { database: { writing: :primary, reading: :replica } }
223
- ```
253
+ connects_to database: { writing: :primary, reading: :replica }
254
+ ```
255
+
256
+ ```ruby
257
+ config.solid_queue.connects_to = { database: { writing: :primary, reading: :replica } }
258
+ ```
224
259
 
225
260
  ## Inspiration
226
261
 
227
- Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job).
262
+ Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job). We recommend checking out these projects as they're great examples from which we've learnt a lot.
228
263
 
229
264
  ## License
230
265
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SolidQueue
4
4
  class BlockedExecution < Execution
5
- assume_attributes_from_job :concurrency_key
5
+ assumes_attributes_from_job :concurrency_key
6
6
  before_create :set_expires_at
7
7
 
8
8
  has_one :semaphore, foreign_key: :key, primary_key: :concurrency_key
@@ -24,7 +24,7 @@ module SolidQueue
24
24
 
25
25
  def release_one(concurrency_key)
26
26
  transaction do
27
- ordered.where(concurrency_key: concurrency_key).limit(1).lock.each(&:release)
27
+ ordered.where(concurrency_key: concurrency_key).limit(1).non_blocking_lock.each(&:release)
28
28
  end
29
29
  end
30
30
 
@@ -46,7 +46,6 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
46
46
 
47
47
  private
48
48
  def execute
49
- SolidQueue.logger.info("[SolidQueue] Performing job #{job.id} - #{job.active_job_id}")
50
49
  ActiveJob::Base.execute(job.arguments)
51
50
  Result.new(true, nil)
52
51
  rescue Exception => e
@@ -58,8 +57,6 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
58
57
  job.finished!
59
58
  destroy!
60
59
  end
61
-
62
- SolidQueue.logger.info("[SolidQueue] Performed job #{job.id} - #{job.active_job_id}")
63
60
  end
64
61
 
65
62
  def failed_with(error)
@@ -67,7 +64,5 @@ class SolidQueue::ClaimedExecution < SolidQueue::Execution
67
64
  job.failed_with(error)
68
65
  destroy!
69
66
  end
70
-
71
- SolidQueue.logger.info("[SolidQueue] Failed job #{job.id} - #{job.active_job_id}")
72
67
  end
73
68
  end
@@ -5,17 +5,24 @@ module SolidQueue
5
5
  module JobAttributes
6
6
  extend ActiveSupport::Concern
7
7
 
8
- ASSUMIBLE_ATTRIBUTES_FROM_JOB = %i[ queue_name priority ]
8
+ included do
9
+ class_attribute :assumible_attributes_from_job, instance_accessor: false, default: %i[ queue_name priority ]
10
+ end
9
11
 
10
12
  class_methods do
11
- def assume_attributes_from_job(*attributes)
12
- before_create -> { assume_attributes_from_job(ASSUMIBLE_ATTRIBUTES_FROM_JOB | attributes) }
13
+ def assumes_attributes_from_job(*attribute_names)
14
+ self.assumible_attributes_from_job |= attribute_names
15
+ before_create -> { assume_attributes_from_job }
16
+ end
17
+
18
+ def attributes_from_job(job)
19
+ job.attributes.symbolize_keys.slice(*assumible_attributes_from_job)
13
20
  end
14
21
  end
15
22
 
16
23
  private
17
- def assume_attributes_from_job(attributes)
18
- attributes.each do |attribute|
24
+ def assume_attributes_from_job
25
+ self.class.assumible_attributes_from_job.each do |attribute|
19
26
  send("#{attribute}=", job.send(attribute))
20
27
  end
21
28
  end
@@ -11,5 +11,17 @@ module SolidQueue
11
11
  belongs_to :job
12
12
 
13
13
  alias_method :discard, :destroy
14
+
15
+ class << self
16
+ def create_all_from_jobs(jobs)
17
+ insert_all execution_data_from_jobs(jobs)
18
+ end
19
+
20
+ def execution_data_from_jobs(jobs)
21
+ jobs.collect do |job|
22
+ attributes_from_job(job).merge(job_id: job.id)
23
+ end
24
+ end
25
+ end
14
26
  end
15
27
  end
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SolidQueue::FailedExecution < SolidQueue::Execution
4
- if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
5
- serialize :error, coder: JSON
6
- else
7
- serialize :error, JSON
8
- end
4
+ serialize :error, coder: JSON
9
5
 
10
6
  before_create :expand_error_details_from_exception
11
7
 
@@ -6,20 +6,56 @@ module SolidQueue
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- include Clearable, ConcurrencyControls
9
+ include Clearable, ConcurrencyControls, Schedulable
10
10
 
11
11
  has_one :ready_execution, dependent: :destroy
12
12
  has_one :claimed_execution, dependent: :destroy
13
13
  has_one :failed_execution, dependent: :destroy
14
14
 
15
- has_one :scheduled_execution, dependent: :destroy
16
-
17
15
  after_create :prepare_for_execution
18
16
 
19
17
  scope :finished, -> { where.not(finished_at: nil) }
18
+ scope :failed, -> { includes(:failed_execution).where.not(failed_execution: { id: nil }) }
20
19
  end
21
20
 
22
- %w[ ready claimed failed scheduled ].each do |status|
21
+ class_methods do
22
+ def prepare_all_for_execution(jobs)
23
+ due, not_yet_due = jobs.partition(&:due?)
24
+ dispatch_all(due) + schedule_all(not_yet_due)
25
+ end
26
+
27
+ def dispatch_all(jobs)
28
+ with_concurrency_limits, without_concurrency_limits = jobs.partition(&:concurrency_limited?)
29
+
30
+ dispatch_all_at_once(without_concurrency_limits)
31
+ dispatch_all_one_by_one(with_concurrency_limits)
32
+
33
+ successfully_dispatched(jobs)
34
+ end
35
+
36
+ private
37
+ def dispatch_all_at_once(jobs)
38
+ ReadyExecution.create_all_from_jobs jobs
39
+ end
40
+
41
+ def dispatch_all_one_by_one(jobs)
42
+ jobs.each(&:dispatch)
43
+ end
44
+
45
+ def successfully_dispatched(jobs)
46
+ dispatched_and_ready(jobs) + dispatched_and_blocked(jobs)
47
+ end
48
+
49
+ def dispatched_and_ready(jobs)
50
+ where(id: ReadyExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
51
+ end
52
+
53
+ def dispatched_and_blocked(jobs)
54
+ where(id: BlockedExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
55
+ end
56
+ end
57
+
58
+ %w[ ready claimed failed ].each do |status|
23
59
  define_method("#{status}?") { public_send("#{status}_execution").present? }
24
60
  end
25
61
 
@@ -49,8 +85,8 @@ module SolidQueue
49
85
  finished_at.present?
50
86
  end
51
87
 
52
- def failed_with(exception)
53
- FailedExecution.create_or_find_by!(job_id: id, exception: exception)
88
+ def due?
89
+ scheduled_at.nil? || scheduled_at <= Time.current
54
90
  end
55
91
 
56
92
  def discard
@@ -66,14 +102,6 @@ module SolidQueue
66
102
  end
67
103
 
68
104
  private
69
- def due?
70
- scheduled_at.nil? || scheduled_at <= Time.current
71
- end
72
-
73
- def schedule
74
- ScheduledExecution.create_or_find_by!(job_id: id)
75
- end
76
-
77
105
  def ready
78
106
  ReadyExecution.create_or_find_by!(job_id: id)
79
107
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module Schedulable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_one :scheduled_execution, dependent: :destroy
10
+
11
+ scope :scheduled, -> { where.not(finished_at: nil) }
12
+ end
13
+
14
+ class_methods do
15
+ def schedule_all(jobs)
16
+ schedule_all_at_once(jobs)
17
+ successfully_scheduled(jobs)
18
+ end
19
+
20
+ private
21
+ def schedule_all_at_once(jobs)
22
+ ScheduledExecution.create_all_from_jobs(jobs)
23
+ end
24
+
25
+ def successfully_scheduled(jobs)
26
+ where(id: ScheduledExecution.where(job_id: jobs.map(&:id)).pluck(:job_id))
27
+ end
28
+ end
29
+
30
+ def due?
31
+ scheduled_at.nil? || scheduled_at <= Time.current
32
+ end
33
+
34
+ private
35
+ def schedule
36
+ ScheduledExecution.create_or_find_by!(job_id: id)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,38 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class SolidQueue::Job < SolidQueue::Record
4
- include Executable
3
+ module SolidQueue
4
+ class Job < Record
5
+ include Executable
5
6
 
6
- if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1")
7
7
  serialize :arguments, coder: JSON
8
- else
9
- serialize :arguments, JSON
10
- end
11
8
 
12
- DEFAULT_PRIORITY = 0
13
- DEFAULT_QUEUE_NAME = "default"
14
-
15
- class << self
16
- def enqueue_active_job(active_job, scheduled_at: Time.current)
17
- enqueue \
18
- queue_name: active_job.queue_name,
19
- active_job_id: active_job.job_id,
20
- priority: active_job.priority,
21
- scheduled_at: scheduled_at,
22
- class_name: active_job.class.name,
23
- arguments: active_job.serialize,
24
- concurrency_key: active_job.try(:concurrency_key)
25
- end
9
+ class << self
10
+ def enqueue_all(active_jobs)
11
+ active_jobs_by_job_id = active_jobs.index_by(&:job_id)
26
12
 
27
- def enqueue(**kwargs)
28
- create!(**kwargs.compact.with_defaults(defaults)).tap do
29
- SolidQueue.logger.debug "[SolidQueue] Enqueued job #{kwargs}"
13
+ transaction do
14
+ jobs = create_all_from_active_jobs(active_jobs)
15
+ prepare_all_for_execution(jobs).tap do |enqueued_jobs|
16
+ enqueued_jobs.each do |enqueued_job|
17
+ active_jobs_by_job_id[enqueued_job.active_job_id].provider_job_id = enqueued_job.id
18
+ end
19
+ end
20
+ end
30
21
  end
31
- end
32
22
 
33
- private
34
- def defaults
35
- { queue_name: DEFAULT_QUEUE_NAME, priority: DEFAULT_PRIORITY }
23
+ def enqueue(active_job, scheduled_at: Time.current)
24
+ active_job.scheduled_at = scheduled_at
25
+
26
+ create_from_active_job(active_job).tap do |enqueued_job|
27
+ active_job.provider_job_id = enqueued_job.id
28
+ end
36
29
  end
30
+
31
+ private
32
+ DEFAULT_PRIORITY = 0
33
+ DEFAULT_QUEUE_NAME = "default"
34
+
35
+ def create_from_active_job(active_job)
36
+ create!(**attributes_from_active_job(active_job))
37
+ end
38
+
39
+ def create_all_from_active_jobs(active_jobs)
40
+ job_rows = active_jobs.map { |job| attributes_from_active_job(job) }
41
+ insert_all(job_rows)
42
+ where(active_job_id: active_jobs.map(&:job_id))
43
+ end
44
+
45
+ def attributes_from_active_job(active_job)
46
+ {
47
+ queue_name: active_job.queue_name || DEFAULT_QUEUE_NAME,
48
+ active_job_id: active_job.job_id,
49
+ priority: active_job.priority || DEFAULT_PRIORITY,
50
+ scheduled_at: active_job.scheduled_at,
51
+ class_name: active_job.class.name,
52
+ arguments: active_job.serialize,
53
+ concurrency_key: active_job.concurrency_key
54
+ }
55
+ end
56
+ end
37
57
  end
38
58
  end
@@ -9,7 +9,7 @@ module SolidQueue::Process::Prunable
9
9
 
10
10
  class_methods do
11
11
  def prune
12
- prunable.lock.find_in_batches(batch_size: 50) do |batch|
12
+ prunable.non_blocking_lock.find_in_batches(batch_size: 50) do |batch|
13
13
  batch.each do |process|
14
14
  SolidQueue.logger.info("[SolidQueue] Pruning dead process #{process.id} - #{process.metadata}")
15
15
  process.deregister
@@ -4,7 +4,7 @@ module SolidQueue
4
4
  class ReadyExecution < Execution
5
5
  scope :queued_as, ->(queue_name) { where(queue_name: queue_name) }
6
6
 
7
- assume_attributes_from_job
7
+ assumes_attributes_from_job
8
8
 
9
9
  class << self
10
10
  def claim(queue_list, limit, process_id)
@@ -26,7 +26,7 @@ module SolidQueue
26
26
  end
27
27
 
28
28
  def select_candidates(queue_relation, limit)
29
- queue_relation.ordered.limit(limit).lock.pluck(:job_id)
29
+ queue_relation.ordered.limit(limit).non_blocking_lock.pluck(:job_id)
30
30
  end
31
31
 
32
32
  def lock_candidates(job_ids, process_id)
@@ -6,11 +6,11 @@ module SolidQueue
6
6
 
7
7
  connects_to **SolidQueue.connects_to if SolidQueue.connects_to
8
8
 
9
- def self.lock(...)
9
+ def self.non_blocking_lock
10
10
  if SolidQueue.use_skip_locked
11
- super(Arel.sql("FOR UPDATE SKIP LOCKED"))
11
+ lock(Arel.sql("FOR UPDATE SKIP LOCKED"))
12
12
  else
13
- super
13
+ lock
14
14
  end
15
15
  end
16
16
  end
@@ -6,12 +6,12 @@ module SolidQueue
6
6
  scope :ordered, -> { order(scheduled_at: :asc, priority: :asc) }
7
7
  scope :next_batch, ->(batch_size) { due.ordered.limit(batch_size) }
8
8
 
9
- assume_attributes_from_job :scheduled_at
9
+ assumes_attributes_from_job :scheduled_at
10
10
 
11
11
  class << self
12
12
  def dispatch_next_batch(batch_size)
13
13
  transaction do
14
- job_ids = next_batch(batch_size).lock.pluck(:job_id)
14
+ job_ids = next_batch(batch_size).non_blocking_lock.pluck(:job_id)
15
15
  if job_ids.empty? then []
16
16
  else
17
17
  dispatch_batch(job_ids)
@@ -22,44 +22,12 @@ module SolidQueue
22
22
  private
23
23
  def dispatch_batch(job_ids)
24
24
  jobs = Job.where(id: job_ids)
25
- with_concurrency_limits, without_concurrency_limits = jobs.partition(&:concurrency_limited?)
26
25
 
27
- dispatch_at_once(without_concurrency_limits)
28
- dispatch_one_by_one(with_concurrency_limits)
29
-
30
- successfully_dispatched(job_ids).tap do |dispatched_job_ids|
26
+ Job.dispatch_all(jobs).map(&:id).tap do |dispatched_job_ids|
31
27
  where(job_id: dispatched_job_ids).delete_all
32
28
  SolidQueue.logger.info("[SolidQueue] Dispatched scheduled batch with #{dispatched_job_ids.size} jobs")
33
29
  end
34
30
  end
35
-
36
- def dispatch_at_once(jobs)
37
- ReadyExecution.insert_all ready_rows_from_batch(jobs)
38
- end
39
-
40
- def dispatch_one_by_one(jobs)
41
- jobs.each(&:dispatch)
42
- end
43
-
44
- def ready_rows_from_batch(jobs)
45
- prepared_at = Time.current
46
-
47
- jobs.map do |job|
48
- { job_id: job.id, queue_name: job.queue_name, priority: job.priority, created_at: prepared_at }
49
- end
50
- end
51
-
52
- def successfully_dispatched(job_ids)
53
- dispatched_and_ready(job_ids) + dispatched_and_blocked(job_ids)
54
- end
55
-
56
- def dispatched_and_ready(job_ids)
57
- ReadyExecution.where(job_id: job_ids).pluck(:job_id)
58
- end
59
-
60
- def dispatched_and_blocked(job_ids)
61
- BlockedExecution.where(job_id: job_ids).pluck(:job_id)
62
- end
63
31
  end
64
32
  end
65
33
  end
@@ -1,4 +1,4 @@
1
- class CreateSolidQueueTables < ActiveRecord::Migration[7.1]
1
+ class CreateSolidQueueTables < ActiveRecord::Migration[7.0]
2
2
  def change
3
3
  create_table :solid_queue_jobs do |t|
4
4
  t.string :queue_name, null: false
@@ -36,6 +36,10 @@ module ActiveJob
36
36
  end
37
37
  end
38
38
 
39
+ def concurrency_limited?
40
+ concurrency_key.present?
41
+ end
42
+
39
43
  private
40
44
  def concurrency_group
41
45
  compute_concurrency_parameter(self.class.concurrency_group)
@@ -9,15 +9,15 @@ module ActiveJob
9
9
  # Rails.application.config.active_job.queue_adapter = :solid_queue
10
10
  class SolidQueueAdapter
11
11
  def enqueue(active_job) # :nodoc:
12
- SolidQueue::Job.enqueue_active_job(active_job).tap do |job|
13
- active_job.provider_job_id = job.id
14
- end
12
+ SolidQueue::Job.enqueue(active_job)
15
13
  end
16
14
 
17
15
  def enqueue_at(active_job, timestamp) # :nodoc:
18
- SolidQueue::Job.enqueue_active_job(active_job, scheduled_at: Time.at(timestamp)).tap do |job|
19
- active_job.provider_job_id = job.id
20
- end
16
+ SolidQueue::Job.enqueue(active_job, scheduled_at: Time.at(timestamp))
17
+ end
18
+
19
+ def enqueue_all(active_jobs) # :nodoc:
20
+ SolidQueue::Job.enqueue_all(active_jobs)
21
21
  end
22
22
  end
23
23
  end
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class SolidQueue::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
4
6
  class_option :skip_migrations, type: :boolean, default: nil, desc: "Skip migrations"
5
7
 
6
8
  def add_solid_queue
7
- %w[ development test production ].each do |env_name|
8
- if (env_config = Pathname(destination_root).join("config/environments/#{env_name}.rb")).exist?
9
- gsub_file env_config, /(# )?config\.active_job\.queue_adapter\s+=.*/, "config.active_job.queue_adapter = :solid_queue"
10
- end
9
+ if (env_config = Pathname(destination_root).join("config/environments/production.rb")).exist?
10
+ gsub_file env_config, /(# )?config\.active_job\.queue_adapter\s+=.*/, "config.active_job.queue_adapter = :solid_queue"
11
11
  end
12
+
13
+ copy_file "config.yml", "config/solid_queue.yml"
12
14
  end
13
15
 
14
16
  def create_migrations
@@ -0,0 +1,18 @@
1
+ #default: &default
2
+ # dispatchers:
3
+ # - polling_interval: 1
4
+ # batch_size: 500
5
+ # workers:
6
+ # - queues: "*"
7
+ # threads: 5
8
+ # processes: 1
9
+ # polling_interval: 0.1
10
+ #
11
+ # development:
12
+ # <<: *default
13
+ #
14
+ # test:
15
+ # <<: *default
16
+ #
17
+ # production:
18
+ # <<: *default
@@ -6,16 +6,19 @@ Puma::Plugin.create do
6
6
  def start(launcher)
7
7
  @log_writer = launcher.log_writer
8
8
  @puma_pid = $$
9
- @solid_queue_pid = fork do
10
- Thread.new { monitor_puma }
11
- SolidQueue::Supervisor.start(mode: :all)
12
- end
13
9
 
14
- launcher.events.on_stopped { stop_solid_queue }
10
+ launcher.events.on_booted do
11
+ @solid_queue_pid = fork do
12
+ Thread.new { monitor_puma }
13
+ SolidQueue::Supervisor.start(mode: :all)
14
+ end
15
15
 
16
- in_background do
17
- monitor_solid_queue
16
+ in_background do
17
+ monitor_solid_queue
18
+ end
18
19
  end
20
+
21
+ launcher.events.on_stopped { stop_solid_queue }
19
22
  end
20
23
 
21
24
  private
@@ -73,29 +73,37 @@ module SolidQueue
73
73
  .map { |options| options.dup.symbolize_keys }
74
74
  end
75
75
 
76
+
76
77
  def load_config_from(file_or_hash)
77
78
  case file_or_hash
78
- when Pathname then load_config_file file_or_hash
79
- when String then load_config_file Pathname.new(file_or_hash)
80
- when NilClass then load_config_file default_config_file
81
- when Hash then file_or_hash.dup
82
- else raise "Solid Queue cannot be initialized with #{file_or_hash.inspect}"
79
+ when Hash
80
+ file_or_hash.dup
81
+ when Pathname, String
82
+ load_config_from_file Pathname.new(file_or_hash)
83
+ when NilClass
84
+ load_config_from_env_location || load_config_from_default_location
85
+ else
86
+ raise "Solid Queue cannot be initialized with #{file_or_hash.inspect}"
83
87
  end
84
88
  end
85
89
 
86
- def load_config_file(file)
87
- if file.exist?
88
- ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
89
- else
90
- raise "Configuration file not found in #{file}"
90
+ def load_config_from_env_location
91
+ if ENV["SOLID_QUEUE_CONFIG"].present?
92
+ load_config_from_file Rails.root.join(ENV["SOLID_QUEUE_CONFIG"])
91
93
  end
92
94
  end
93
95
 
94
- def default_config_file
95
- path_to_file = ENV["SOLID_QUEUE_CONFIG"] || DEFAULT_CONFIG_FILE_PATH
96
+ def load_config_from_default_location
97
+ Rails.root.join(DEFAULT_CONFIG_FILE_PATH).then do |config_file|
98
+ config_file.exist? ? load_config_from_file(config_file) : {}
99
+ end
100
+ end
96
101
 
97
- Rails.root.join(path_to_file).tap do |config_file|
98
- raise "Configuration for Solid Queue not found in #{config_file}" unless config_file.exist?
102
+ def load_config_from_file(file)
103
+ if file.exist?
104
+ ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
105
+ else
106
+ raise "Configuration file for Solid Queue not found in #{file}"
99
107
  end
100
108
  end
101
109
  end
@@ -44,7 +44,9 @@ module SolidQueue::Processes
44
44
  loop do
45
45
  break if shutting_down?
46
46
 
47
- run
47
+ wrap_in_app_executor do
48
+ run
49
+ end
48
50
  end
49
51
  ensure
50
52
  run_callbacks(:shutdown) { shutdown }
@@ -79,7 +79,7 @@ module SolidQueue
79
79
  end
80
80
 
81
81
  def graceful_termination
82
- procline "terminating gracefully"
82
+ SolidQueue.logger.info("[SolidQueue] Terminating gracefully...")
83
83
  term_forks
84
84
 
85
85
  wait_until(SolidQueue.shutdown_timeout, -> { all_forks_terminated? }) do
@@ -90,7 +90,7 @@ module SolidQueue
90
90
  end
91
91
 
92
92
  def immediate_termination
93
- procline "terminating immediately"
93
+ SolidQueue.logger.info("[SolidQueue] Terminating immediately...")
94
94
  quit_forks
95
95
  end
96
96
 
@@ -1,3 +1,3 @@
1
1
  module SolidQueue
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.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: 2023-12-18 00:00:00.000000000 Z
11
+ date: 2023-12-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 7.0.3.1
19
+ version: '7.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ">="
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 7.0.3.1
26
+ version: '7.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: debug
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: puma
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  description: Database-backed Active Job backend.
56
70
  email:
57
71
  - rosa@37signals.com
@@ -71,6 +85,7 @@ files:
71
85
  - app/models/solid_queue/job/clearable.rb
72
86
  - app/models/solid_queue/job/concurrency_controls.rb
73
87
  - app/models/solid_queue/job/executable.rb
88
+ - app/models/solid_queue/job/schedulable.rb
74
89
  - app/models/solid_queue/pause.rb
75
90
  - app/models/solid_queue/process.rb
76
91
  - app/models/solid_queue/process/prunable.rb
@@ -86,6 +101,7 @@ files:
86
101
  - lib/active_job/queue_adapters/solid_queue_adapter.rb
87
102
  - lib/generators/solid_queue/install/USAGE
88
103
  - lib/generators/solid_queue/install/install_generator.rb
104
+ - lib/generators/solid_queue/install/templates/config.yml
89
105
  - lib/puma/plugin/solid_queue.rb
90
106
  - lib/solid_queue.rb
91
107
  - lib/solid_queue/app_executor.rb
@@ -127,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
127
143
  - !ruby/object:Gem::Version
128
144
  version: '0'
129
145
  requirements: []
130
- rubygems_version: 3.4.20
146
+ rubygems_version: 3.4.10
131
147
  signing_key:
132
148
  specification_version: 4
133
149
  summary: Database-backed Active Job backend.