solid_queue 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +230 -0
  4. data/Rakefile +8 -0
  5. data/app/models/solid_queue/blocked_execution.rb +68 -0
  6. data/app/models/solid_queue/claimed_execution.rb +73 -0
  7. data/app/models/solid_queue/execution/job_attributes.rb +24 -0
  8. data/app/models/solid_queue/execution.rb +15 -0
  9. data/app/models/solid_queue/failed_execution.rb +31 -0
  10. data/app/models/solid_queue/job/clearable.rb +19 -0
  11. data/app/models/solid_queue/job/concurrency_controls.rb +50 -0
  12. data/app/models/solid_queue/job/executable.rb +87 -0
  13. data/app/models/solid_queue/job.rb +38 -0
  14. data/app/models/solid_queue/pause.rb +6 -0
  15. data/app/models/solid_queue/process/prunable.rb +20 -0
  16. data/app/models/solid_queue/process.rb +28 -0
  17. data/app/models/solid_queue/queue.rb +52 -0
  18. data/app/models/solid_queue/queue_selector.rb +68 -0
  19. data/app/models/solid_queue/ready_execution.rb +41 -0
  20. data/app/models/solid_queue/record.rb +19 -0
  21. data/app/models/solid_queue/scheduled_execution.rb +65 -0
  22. data/app/models/solid_queue/semaphore.rb +65 -0
  23. data/config/routes.rb +2 -0
  24. data/db/migrate/20231211200639_create_solid_queue_tables.rb +100 -0
  25. data/lib/active_job/concurrency_controls.rb +53 -0
  26. data/lib/active_job/queue_adapters/solid_queue_adapter.rb +24 -0
  27. data/lib/generators/solid_queue/install/USAGE +9 -0
  28. data/lib/generators/solid_queue/install/install_generator.rb +19 -0
  29. data/lib/puma/plugin/solid_queue.rb +63 -0
  30. data/lib/solid_queue/app_executor.rb +21 -0
  31. data/lib/solid_queue/configuration.rb +102 -0
  32. data/lib/solid_queue/dispatcher.rb +73 -0
  33. data/lib/solid_queue/engine.rb +39 -0
  34. data/lib/solid_queue/pool.rb +58 -0
  35. data/lib/solid_queue/processes/base.rb +27 -0
  36. data/lib/solid_queue/processes/interruptible.rb +37 -0
  37. data/lib/solid_queue/processes/pidfile.rb +58 -0
  38. data/lib/solid_queue/processes/poller.rb +24 -0
  39. data/lib/solid_queue/processes/procline.rb +11 -0
  40. data/lib/solid_queue/processes/registrable.rb +69 -0
  41. data/lib/solid_queue/processes/runnable.rb +77 -0
  42. data/lib/solid_queue/processes/signals.rb +69 -0
  43. data/lib/solid_queue/processes/supervised.rb +38 -0
  44. data/lib/solid_queue/supervisor.rb +182 -0
  45. data/lib/solid_queue/tasks.rb +16 -0
  46. data/lib/solid_queue/version.rb +3 -0
  47. data/lib/solid_queue/worker.rb +54 -0
  48. data/lib/solid_queue.rb +52 -0
  49. metadata +134 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1cb50c543011419d9ad0cee9bbf9009088732dd3025b1c03fb89c9155e81b4ee
4
+ data.tar.gz: 97ab2ed127edf72e5cea4ac78f69778f1728b84ef4ec4922dc5fc885ffa123c2
5
+ SHA512:
6
+ metadata.gz: eb2150d9025eba950e409d9b84543cc41e019f997dfb4c65c4d7819365576198ddf0253847c7937594eb6930a0b528415c68456c73f2afa084353ff72fdc5511
7
+ data.tar.gz: 4918c8b1bc0a37ef6b888408ba7fdfdb96401cb5515d24138eb850d726a17913e4544e53c507c6069eaf1f905b6fb5c9e9406877b870eec135d0f62141480962
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2023 37signals
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Solid Queue
2
+
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
+
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.
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
+ ```
24
+
25
+ ## Installation
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem "solid_queue"
30
+ ```
31
+
32
+ And then execute:
33
+ ```bash
34
+ $ bundle
35
+ ```
36
+
37
+ Or install it yourself as:
38
+ ```bash
39
+ $ gem install solid_queue
40
+ ```
41
+
42
+ Add the migration to your app and run it:
43
+ ```
44
+ $ bin/rails solid_queue:install:migrations
45
+ $ bin/rails db:migrate
46
+ ```
47
+
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.
49
+ ```
50
+ $ bundle exec rake solid_queue:start
51
+ ```
52
+
53
+ This will start processing jobs in all queues using the default configuration. See [below](#configuration) to learn more about configuring Solid Queue.
54
+
55
+ ## 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.
57
+
58
+ ## Configuration
59
+
60
+ ### Workers and dispatchers
61
+
62
+ We have three types of processes in Solid Queue:
63
+ - _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.
65
+ - 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
+
67
+ 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:
68
+
69
+ ```yml
70
+ production:
71
+ dispatchers:
72
+ - polling_interval: 1
73
+ batch_size: 500
74
+ workers:
75
+ - queues: *
76
+ threads: 3
77
+ polling_interval: 2
78
+ - queues: real_time,background
79
+ threads: 5
80
+ polling_interval: 0.1
81
+ processes: 3
82
+ ```
83
+
84
+ Everything is optional. If no configuration is provided, Solid Queue will run with one dispatcher and one worker with default settings.
85
+
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
95
+
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.
98
+
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.
100
+ - `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
+ - `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
+
103
+
104
+ ### Queue order and priorities
105
+ As mentioned above, if you specify a list of queues for a worker, these will be polled in the order given, such as for the list `real_time,background`, no jobs will be taken from `background` unless there aren't any more jobs waiting in `real_time`.
106
+
107
+ Active Job also supports positive integer priorities when enqueuing jobs. In Solid Queue, the smaller the value, the higher the priority. The default is `0`.
108
+
109
+ This is useful when you run jobs with different importance or urgency in the same queue. Within the same queue, jobs will be picked in order of priority, but in a list of queues, the queue order takes precedence, so in the previous example with `real_time,background`, jobs in the `real_time` queue will be picked before jobs in the `background` queue, even if those in the `background` queue have a higher priority (smaller value) set.
110
+
111
+ We recommend not mixing queue order with priorities but either choosing one or the other, as that will make job execution order more straightforward for you.
112
+
113
+
114
+ ### Threads, processes and signals
115
+
116
+ 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, configurable via different workers or the `processes` parameter above.
117
+
118
+ The supervisor is in charge of managing these processes, and it responds to the following signals:
119
+ - `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.
120
+ - `QUIT`: starts immediate termination. The supervisor will send a `QUIT` signal to its supervised processes, causing them to exit immediately.
121
+
122
+ When receiving a `QUIT` signal, if workers still have jobs in-flight, these will be returned to the queue when the processes are deregistered.
123
+
124
+ 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, which will release any claimed jobs back to their queues. You can configure both the frequency of heartbeats and the threshold to consider a process dead. See the section below for this.
125
+
126
+ ### Other configuration settings
127
+
128
+ There are several settings that control how Solid Queue works that you can set as well:
129
+ - `logger`: the logger you want Solid Queue to use. Defaults to the app logger.
130
+ - `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
+ - `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
+ ```
135
+ - `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
137
+ # Use a separate DB for Solid Queue
138
+ config.solid_queue.connects_to = { database: { writing: :solid_queue_primary, reading: :solid_queue_replica } }
139
+ ```
140
+ - `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`.
145
+ - `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.
149
+
150
+
151
+ ## Concurrency controls
152
+ Solid Queue extends Active Job with concurrency controls, that allows you to limit how many jobs of a certain type or with certain arguments can run at the same time. When limited in this way, jobs will be blocked from running, and they'll stay blocked until another job finishes and unblocks them, or after the set expiry time (concurrency limit's _duration_) elapses. Jobs are never discarded or lost, only blocked.
153
+
154
+ ```ruby
155
+ class MyJob < ApplicationJob
156
+ limits_concurrency to: max_concurrent_executions, key: ->(arg1, arg2, **) { ... }, duration: max_interval_to_guarantee_concurrency_limit, group: concurrency_group
157
+
158
+ # ...
159
+ ```
160
+ - `key` is the only required parameter, and it can be a symbol, a string or a proc that receives the job arguments as parameters and will be used to identify the jobs that need to be limited together. If the proc returns an Active Record record, the key will be built from its class name and `id`.
161
+ - `to` is `1` by default, and `duration` is set to `SolidQueue.default_concurrency_control_period` by default, which itself defaults to `3 minutes`, but that you can configure as well.
162
+ - `group` is used to control the concurrency of different job classes together. It defaults to the job class name.
163
+
164
+ When a job includes these controls, we'll ensure that, at most, the number of jobs (indicated as `to`) that yield the same `key` will be performed concurrently, and this guarantee will last for `duration` for each job enqueued. Note that there's no guarantee about _the order of execution_, only about jobs being performed at the same time (overlapping).
165
+
166
+ For example:
167
+ ```ruby
168
+ class DeliverAnnouncementToContactJob < ApplicationJob
169
+ limits_concurrency to: 2, key: ->(contact) { contact.account }, duration: 5.minutes
170
+
171
+ def perform(contact)
172
+ # ...
173
+ ```
174
+ Where `contact` and `account` are `ActiveRecord` records. In this case, we'll ensure that at most two jobs of the kind `DeliverAnnouncementToContact` for the same account will run concurrently. If, for any reason, one of those jobs takes longer than 5 minutes or doesn't release its concurrency lock within 5 minutes of acquiring it, a new job with the same key might gain the lock.
175
+
176
+ Let's see another example using `group`:
177
+
178
+ ```ruby
179
+ class Box::MovePostingsByContactToDesignatedBoxJob < ApplicationJob
180
+ limits_concurrency key: ->(contact) { contact }, duration: 15.minutes, group: "ContactActions"
181
+
182
+ def perform(contact)
183
+ # ...
184
+ ```
185
+
186
+ ```ruby
187
+ class Bundle::RebundlePostingsJob < ApplicationJob
188
+ limits_concurrency key: ->(bundle) { bundle.contact }, duration: 15.minutes, group: "ContactActions"
189
+
190
+ def perform(bundle)
191
+ # ...
192
+ ```
193
+
194
+ In this case, if we have a `Box::MovePostingsByContactToDesignatedBoxJob` job enqueued for a contact record with id `123` and another `Bundle::RebundlePostingsJob` job enqueued simultaneously for a bundle record that references contact `123`, only one of them will be allowed to proceed. The other one will stay blocked until the first one finishes (or 15 minutes pass, whatever happens first).
195
+
196
+ Note that the `duration` setting depends indirectly on the value for `concurrency_maintenance_interval` that you set for your dispatcher(s), as that'd be the frequency with which blocked jobs are checked and unblocked. In general, you should set `duration` in a way that all your jobs would finish well under that duration and think of the concurrency maintenance task as a failsafe in case something goes wrong.
197
+
198
+ 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
+
200
+ ## Puma plugin
201
+ 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
+ ```ruby
203
+ plugin :solid_queue
204
+ ```
205
+ to your `puma.rb` configuration.
206
+
207
+
208
+ ## Jobs and transactional integrity
209
+ :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. 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.
210
+
211
+ If you prefer not to rely on this, or avoid relying on it unintentionally, you should make sure that:
212
+ - 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
+ - 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
+
218
+ connects_to database: { writing: :primary, reading: :replica }
219
+ ```
220
+
221
+ ```ruby
222
+ solid_queue.config.connects_to { database: { writing: :primary, reading: :replica } }
223
+ ```
224
+
225
+ ## Inspiration
226
+
227
+ Solid Queue has been inspired by [resque](https://github.com/resque/resque) and [GoodJob](https://github.com/bensheldon/good_job).
228
+
229
+ ## License
230
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class BlockedExecution < Execution
5
+ assume_attributes_from_job :concurrency_key
6
+ before_create :set_expires_at
7
+
8
+ has_one :semaphore, foreign_key: :key, primary_key: :concurrency_key
9
+
10
+ scope :expired, -> { where(expires_at: ...Time.current) }
11
+
12
+ class << self
13
+ def unblock(count)
14
+ expired.distinct.limit(count).pluck(:concurrency_key).then do |concurrency_keys|
15
+ release_many releasable(concurrency_keys)
16
+ end
17
+ end
18
+
19
+ def release_many(concurrency_keys)
20
+ # We want to release exactly one blocked execution for each concurrency key, and we need to do it
21
+ # one by one, locking each record and acquiring the semaphore individually for each of them:
22
+ Array(concurrency_keys).each { |concurrency_key| release_one(concurrency_key) }
23
+ end
24
+
25
+ def release_one(concurrency_key)
26
+ transaction do
27
+ ordered.where(concurrency_key: concurrency_key).limit(1).lock.each(&:release)
28
+ end
29
+ end
30
+
31
+ private
32
+ def releasable(concurrency_keys)
33
+ semaphores = Semaphore.where(key: concurrency_keys).select(:key, :value).index_by(&:key)
34
+
35
+ # Concurrency keys without semaphore + concurrency keys with open semaphore
36
+ (concurrency_keys - semaphores.keys) | semaphores.select { |key, semaphore| semaphore.value > 0 }.map(&:first)
37
+ end
38
+ end
39
+
40
+ def release
41
+ transaction do
42
+ if acquire_concurrency_lock
43
+ promote_to_ready
44
+ destroy!
45
+
46
+ SolidQueue.logger.info("[SolidQueue] Unblocked job #{job.id} under #{concurrency_key}")
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+ def set_expires_at
53
+ self.expires_at = job.concurrency_duration.from_now
54
+ end
55
+
56
+ def acquire_concurrency_lock
57
+ Semaphore.wait(job)
58
+ end
59
+
60
+ def promote_to_ready
61
+ ReadyExecution.create!(ready_attributes)
62
+ end
63
+
64
+ def ready_attributes
65
+ attributes.slice("job_id", "queue_name", "priority")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SolidQueue::ClaimedExecution < SolidQueue::Execution
4
+ belongs_to :process
5
+
6
+ class Result < Struct.new(:success, :error)
7
+ def success?
8
+ success
9
+ end
10
+ end
11
+
12
+ class << self
13
+ def claiming(job_ids, process_id, &block)
14
+ job_data = Array(job_ids).collect { |job_id| { job_id: job_id, process_id: process_id } }
15
+
16
+ insert_all!(job_data)
17
+ where(job_id: job_ids, process_id: process_id).load.tap do |claimed|
18
+ block.call(claimed)
19
+ SolidQueue.logger.info("[SolidQueue] Claimed #{claimed.size} jobs")
20
+ end
21
+ end
22
+
23
+ def release_all
24
+ includes(:job).each(&:release)
25
+ end
26
+ end
27
+
28
+ def perform
29
+ result = execute
30
+
31
+ if result.success?
32
+ finished
33
+ else
34
+ failed_with(result.error)
35
+ end
36
+ ensure
37
+ job.unblock_next_blocked_job
38
+ end
39
+
40
+ def release
41
+ transaction do
42
+ job.prepare_for_execution
43
+ destroy!
44
+ end
45
+ end
46
+
47
+ private
48
+ def execute
49
+ SolidQueue.logger.info("[SolidQueue] Performing job #{job.id} - #{job.active_job_id}")
50
+ ActiveJob::Base.execute(job.arguments)
51
+ Result.new(true, nil)
52
+ rescue Exception => e
53
+ Result.new(false, e)
54
+ end
55
+
56
+ def finished
57
+ transaction do
58
+ job.finished!
59
+ destroy!
60
+ end
61
+
62
+ SolidQueue.logger.info("[SolidQueue] Performed job #{job.id} - #{job.active_job_id}")
63
+ end
64
+
65
+ def failed_with(error)
66
+ transaction do
67
+ job.failed_with(error)
68
+ destroy!
69
+ end
70
+
71
+ SolidQueue.logger.info("[SolidQueue] Failed job #{job.id} - #{job.active_job_id}")
72
+ end
73
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Execution
5
+ module JobAttributes
6
+ extend ActiveSupport::Concern
7
+
8
+ ASSUMIBLE_ATTRIBUTES_FROM_JOB = %i[ queue_name priority ]
9
+
10
+ class_methods do
11
+ def assume_attributes_from_job(*attributes)
12
+ before_create -> { assume_attributes_from_job(ASSUMIBLE_ATTRIBUTES_FROM_JOB | attributes) }
13
+ end
14
+ end
15
+
16
+ private
17
+ def assume_attributes_from_job(attributes)
18
+ attributes.each do |attribute|
19
+ send("#{attribute}=", job.send(attribute))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Execution < Record
5
+ include JobAttributes
6
+
7
+ self.abstract_class = true
8
+
9
+ scope :ordered, -> { order(priority: :asc, job_id: :asc) }
10
+
11
+ belongs_to :job
12
+
13
+ alias_method :discard, :destroy
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
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
9
+
10
+ before_create :expand_error_details_from_exception
11
+
12
+ attr_accessor :exception
13
+
14
+ def retry
15
+ transaction do
16
+ job.prepare_for_execution
17
+ destroy!
18
+ end
19
+ end
20
+
21
+ %i[ exception_class message backtrace ].each do |attribute|
22
+ define_method(attribute) { error.with_indifferent_access[attribute] }
23
+ end
24
+
25
+ private
26
+ def expand_error_details_from_exception
27
+ if exception
28
+ self.error = { exception_class: exception.class.name, message: exception.message, backtrace: exception.backtrace }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module Clearable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ scope :clearable, ->(finished_before: SolidQueue.clear_finished_jobs_after.ago) { where.not(finished_at: nil).where(finished_at: ...finished_before) }
10
+ end
11
+
12
+ class_methods do
13
+ def clear_finished_in_batches(batch_size: 500, finished_before: SolidQueue.clear_finished_jobs_after.ago)
14
+ clearable(finished_before: finished_before).in_batches(of: batch_size).delete_all
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module ConcurrencyControls
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_one :blocked_execution, dependent: :destroy
10
+
11
+ delegate :concurrency_limit, :concurrency_duration, to: :job_class
12
+ end
13
+
14
+ def unblock_next_blocked_job
15
+ if release_concurrency_lock
16
+ release_next_blocked_job
17
+ end
18
+ end
19
+
20
+ def concurrency_limited?
21
+ concurrency_key.present?
22
+ end
23
+
24
+ private
25
+ def acquire_concurrency_lock
26
+ return true unless concurrency_limited?
27
+
28
+ Semaphore.wait(self)
29
+ end
30
+
31
+ def release_concurrency_lock
32
+ return false unless concurrency_limited?
33
+
34
+ Semaphore.signal(self)
35
+ end
36
+
37
+ def block
38
+ BlockedExecution.create_or_find_by!(job_id: id)
39
+ end
40
+
41
+ def release_next_blocked_job
42
+ BlockedExecution.release_one(concurrency_key)
43
+ end
44
+
45
+ def job_class
46
+ @job_class ||= class_name.safe_constantize
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueue
4
+ class Job
5
+ module Executable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include Clearable, ConcurrencyControls
10
+
11
+ has_one :ready_execution, dependent: :destroy
12
+ has_one :claimed_execution, dependent: :destroy
13
+ has_one :failed_execution, dependent: :destroy
14
+
15
+ has_one :scheduled_execution, dependent: :destroy
16
+
17
+ after_create :prepare_for_execution
18
+
19
+ scope :finished, -> { where.not(finished_at: nil) }
20
+ end
21
+
22
+ %w[ ready claimed failed scheduled ].each do |status|
23
+ define_method("#{status}?") { public_send("#{status}_execution").present? }
24
+ end
25
+
26
+ def prepare_for_execution
27
+ if due? then dispatch
28
+ else
29
+ schedule
30
+ end
31
+ end
32
+
33
+ def dispatch
34
+ if acquire_concurrency_lock then ready
35
+ else
36
+ block
37
+ end
38
+ end
39
+
40
+ def finished!
41
+ if preserve_finished_jobs?
42
+ touch(:finished_at)
43
+ else
44
+ destroy!
45
+ end
46
+ end
47
+
48
+ def finished?
49
+ finished_at.present?
50
+ end
51
+
52
+ def failed_with(exception)
53
+ FailedExecution.create_or_find_by!(job_id: id, exception: exception)
54
+ end
55
+
56
+ def discard
57
+ destroy unless claimed?
58
+ end
59
+
60
+ def retry
61
+ failed_execution&.retry
62
+ end
63
+
64
+ def failed_with(exception)
65
+ FailedExecution.create_or_find_by!(job_id: id, exception: exception)
66
+ end
67
+
68
+ 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
+ def ready
78
+ ReadyExecution.create_or_find_by!(job_id: id)
79
+ end
80
+
81
+
82
+ def preserve_finished_jobs?
83
+ SolidQueue.preserve_finished_jobs
84
+ end
85
+ end
86
+ end
87
+ end