solid_queue-coalesce 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +170 -0
- data/app/models/solid_queue/coalesce_key.rb +132 -0
- data/app/models/solid_queue/coalesced_argument.rb +15 -0
- data/app/models/solid_queue/job/coalescible.rb +205 -0
- data/lib/active_job/coalescing.rb +198 -0
- data/lib/generators/solid_queue/coalesce/install/install_generator.rb +16 -0
- data/lib/generators/solid_queue/coalesce/install/templates/db/create_solid_queue_coalesce_tables.rb +28 -0
- data/lib/solid_queue/coalesce/engine.rb +16 -0
- data/lib/solid_queue/coalesce/patches.rb +98 -0
- data/lib/solid_queue/coalesce/version.rb +5 -0
- data/lib/solid_queue/coalesce.rb +8 -0
- metadata +209 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3f395a95881f8639b6abcee94b8e161395cf3026618f63c92ca9612540b30bf0
|
|
4
|
+
data.tar.gz: e5d16c7e02d2a5ae34d18826dfe5dabc42f8ccfcb1802a247fad8eebdd56926e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6acaf7eca6a5915de50cbe9aca92b7c042635de9f32a44e0642927a957bdc2855a691c42d883758bceb8ef4cd6250525b9d3ac633b39b7150c2ecd1a207ac1d7
|
|
7
|
+
data.tar.gz: 11ec3d1ef332b093e1338c6e0dc24ad7ce2ea250c6cc0951981ee637f64908d586885d9dd244034d3abc5842426e6f57bcd30ca479063b4204e261b3065157ad
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) David Cristofaro
|
|
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,170 @@
|
|
|
1
|
+
# Solid Queue Coalesce
|
|
2
|
+
|
|
3
|
+
Coalescing for [Solid Queue](https://github.com/rails/solid_queue). Many `perform_later` calls with the same key share one pending job, and that key is held only until the job starts. A call that arrives after that opens a new job, so anything that happens after a run has started is always covered by another run.
|
|
4
|
+
|
|
5
|
+
It's a separate gem that adds to Solid Queue from the outside, with its own tables. It only works with the Solid Queue adapter, because a foreign key to the job and a shared transaction are what let it work without a time window to configure.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Usage](#usage)
|
|
11
|
+
- [Collecting calls](#collecting-calls)
|
|
12
|
+
- [Retries and partial success](#retries-and-partial-success)
|
|
13
|
+
- [Discarding and retention](#discarding-and-retention)
|
|
14
|
+
- [Rules and limitations](#rules-and-limitations)
|
|
15
|
+
- [Monitoring](#monitoring)
|
|
16
|
+
- [Testing](#testing)
|
|
17
|
+
- [License](#license)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add the gem to your application's Gemfile, alongside Solid Queue:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "solid_queue-coalesce"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then copy its migration to your queue database's migrations path and run it:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
bin/rails generate solid_queue:coalesce:install
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If Solid Queue doesn't use a database called `queue`, pass the right one with `--database`. For example, `bin/rails generate solid_queue:coalesce:install --database primary`.
|
|
36
|
+
|
|
37
|
+
Until the migration has run, everything else works as before, and enqueuing a job that uses `coalesce` raises `SolidQueue::CoalesceKey::PendingMigrations`.
|
|
38
|
+
|
|
39
|
+
The gem requires Solid Queue 1.7. It relies on private Solid Queue methods, so its gemspec allows one minor version at a time.
|
|
40
|
+
|
|
41
|
+
A dispatcher with `concurrency_maintenance` on, which is the default, is what releases a coalesce key left on a job that finished or failed. If you run several dispatchers, at least one needs it.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
With coalescing, many `perform_later` calls with the same key share one pending job. The key has to be open for this to happen: it's taken when the first call creates a job, and it's free again the moment that job starts. A call that arrives after that opens a new job, so anything that happens after a run has started is always covered by another run.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class RefreshAccountJob < ApplicationJob
|
|
49
|
+
coalesce key: ->(account) { account }
|
|
50
|
+
|
|
51
|
+
def perform(account)
|
|
52
|
+
account.refresh!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`key` is the only required parameter, and it works like the one in `limits_concurrency`: a symbol, a string or a proc that receives the job arguments. If the proc returns an Active Record record, the key is built from its class name and `id`. Keys are always scoped to the job class name.
|
|
58
|
+
|
|
59
|
+
By default a call that finds an open job for its key is dropped. It sets `coalesced?` to `true` on the Active Job instance and `provider_job_id` to the job it found, and emits a `coalesce.solid_queue` event with `key`, `job_id`, `active_job_id` and `action` (`:dropped` or `:collected`). Bear in mind that Active Job sets `successfully_enqueued?` to `true` after `perform_later` regardless, so `coalesced?` is what tells you the call was dropped. `ActiveJob.perform_all_later` counts dropped calls as not enqueued, as it does for `on_conflict: :discard`.
|
|
60
|
+
|
|
61
|
+
The first call's schedule, queue and priority win. A later call's `wait:` doesn't move the open job.
|
|
62
|
+
|
|
63
|
+
Coalescing doesn't change `limits_concurrency`, and you can use both in the same job. The concurrency key is the one computed for the call that created the job, and a job that's blocked still holds its coalesce key, since it hasn't started. If `on_conflict: :discard` destroys the job, that frees the key.
|
|
64
|
+
|
|
65
|
+
### Collecting calls
|
|
66
|
+
|
|
67
|
+
With `collect: true`, no call is dropped. Each one stores its arguments, and the job receives them all:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class ReindexDocumentsJob < ApplicationJob
|
|
71
|
+
coalesce key: ->(document) { document.account_id }, collect: true, limit: 100
|
|
72
|
+
|
|
73
|
+
def perform(documents)
|
|
74
|
+
Search.index(documents)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`perform` receives an array with one element per call, in the order they were stored. For a call with one argument the element is that argument, as above. For a call with several it's an array of them, and keyword arguments are a hash at the end of that array, so `perform_later(document, force: true)` gives `[ document, { force: true } ]`.
|
|
80
|
+
|
|
81
|
+
`arguments` isn't part of this: it keeps holding the arguments of the call that created the job, which is what callbacks and the `limits_concurrency` key see.
|
|
82
|
+
|
|
83
|
+
`limit` is 100 by default, and each run gets at most that many calls. If more are waiting when a job starts, it takes the oldest `limit` and a new job is created for the rest, in the same queue and with the same priority and concurrency key. It's due when the oldest call left asked to run, or right away if at least `limit` calls are left, unless you turn off `flush_when_full` (see below).
|
|
84
|
+
|
|
85
|
+
Reaching `limit` also flushes a job that's scheduled: it's dispatched right away instead of waiting for its time. So you can use `wait:` to collect calls for a while, with `limit` as the point where you stop waiting. Concurrency limits apply to a flushed job as usual, and jobs already ready or blocked aren't affected. A call that arrives after a flush still has its own `wait:` stored, so if it ends up in the next job, that's the time the next job will run. Concurrent calls can leave slightly more than `limit` calls waiting, but that doesn't matter because the limit is enforced when the job starts.
|
|
86
|
+
|
|
87
|
+
Use `flush_when_full: false` for a job that has to wait for its time, such as one that runs at a fixed time of day. Solid Queue can't work this out on its own, because `wait:` and `wait_until:` both end up as the same `scheduled_at`.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class SendDigestJob < ApplicationJob
|
|
91
|
+
coalesce key: ->(entry) { entry.account_id }, collect: true, limit: 100, flush_when_full: false
|
|
92
|
+
|
|
93
|
+
def self.perform_tonight(entry)
|
|
94
|
+
set(wait_until: Time.current.tomorrow.change(hour: 2)).perform_later(entry)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def perform(entries)
|
|
98
|
+
DigestMailer.with(entries: entries).digest.deliver_now
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With this option, reaching `limit` doesn't dispatch the job, and it stays scheduled. When it starts with more than `limit` calls waiting, the new job for the rest is due when the oldest call left asked to run, however many are left. So a backlog that's already due drains in batches of `limit`, one after another, and calls dated in the future wait for their time.
|
|
104
|
+
|
|
105
|
+
The first call since the previous job started sets the schedule, and later calls' times don't move an open job. A later call that joins a job still waiting to start runs with that job, even if it asked for a later time. A call that arrives after the last batch started opens a new job for its own time. This means any caller that might arrive first needs the right time: a plain `perform_later` would open a job that runs right away. That's why the example wraps `set(wait_until:)` in a class method.
|
|
106
|
+
|
|
107
|
+
If you alert on the age of the oldest row in `solid_queue_coalesced_arguments`, allow for the wait you expect, because these calls sit there until their time comes.
|
|
108
|
+
|
|
109
|
+
Each call is deserialized on its own. If a call refers to a record that has been deleted, that call is deleted and a `drop_coalesced_argument.solid_queue` event is emitted, while the others run. If every call refers to a deleted record, the events are emitted, `perform` is skipped and the job finishes normally. Those calls stay until the job is destroyed, so a re-run after a crash skips `perform` too. Any other deserialization error fails the job and keeps all of its calls.
|
|
110
|
+
|
|
111
|
+
### Retries and partial success
|
|
112
|
+
|
|
113
|
+
A retry is the same work continuing. It never coalesces: it can't be dropped because another job is open for its key, and later calls can't join it. A job that's retried or resumed keeps the exact calls it started with, including after `retry_on`, `rescue_from` with `retry_job`, and `ActiveJob::Continuable`. If the retry can't be enqueued, the job fails and keeps its calls. Retrying a failed job manually runs the same calls again, and so does a job that gets released after its worker was shut down.
|
|
114
|
+
|
|
115
|
+
To avoid repeating calls that have already been processed, use `ActiveJob::Continuable` with a cursor. Use a record's `id` as the cursor instead of a position in the array, because calls for deleted records are dropped between runs and positions move:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
class ReindexDocumentsJob < ApplicationJob
|
|
119
|
+
include ActiveJob::Continuable
|
|
120
|
+
|
|
121
|
+
coalesce key: ->(document) { document.account_id }, collect: true
|
|
122
|
+
|
|
123
|
+
def perform(documents)
|
|
124
|
+
step :index do |step|
|
|
125
|
+
documents.sort_by(&:id).each do |document|
|
|
126
|
+
next if step.cursor && document.id <= step.cursor
|
|
127
|
+
|
|
128
|
+
Search.index(document)
|
|
129
|
+
step.set! document.id
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Coalescing has no per-call acknowledgement. Without a cursor a retry processes every call in the job again.
|
|
137
|
+
|
|
138
|
+
### Discarding and retention
|
|
139
|
+
|
|
140
|
+
Calls belong to their job, and are deleted with it. Discarding an open job deletes it, its key and every call waiting on it. If `preserve_finished_jobs` is on, a job's calls stay until the job is cleared, along with the finished job itself.
|
|
141
|
+
|
|
142
|
+
A job that fails before starting, for example because its worker died or because its class no longer exists, takes care of its calls in the same way as if it had started. If the class is gone, there's no way to know the limit, so the failed job takes all the waiting calls. When you bring the class back and retry the job, it will run them all, even if that's more than `limit`.
|
|
143
|
+
|
|
144
|
+
### Rules and limitations
|
|
145
|
+
|
|
146
|
+
- `limit` and `flush_when_full` need `collect: true`, and `collect: true` can't be combined with `limits_concurrency` with `on_conflict: :discard`, because a conflict would destroy a job holding calls. Both raise `ArgumentError` when the class loads. Without `collect`, `on_conflict: :discard` is fine, and the job being discarded frees the key.
|
|
147
|
+
- A coalesced job can't be enqueued inside `SolidQueue::Batch.enqueue`, either alone or in bulk. A call that joins an open job adds no job to the batch, which could finish early. Trying raises `SolidQueue::Job::CoalescedInBatchError`.
|
|
148
|
+
- A recurring task can't use a job with `collect: true`: its configuration is invalid. Without `collect`, it works. A tick that finds an open job is dropped and isn't recorded.
|
|
149
|
+
- Calling `perform_later` inside a transaction on the same database as Solid Queue is safe: a call for an open job doesn't abort the transaction. On MySQL, however, a deadlock rolls back the entire transaction, so we can only retry it automatically outside of one. Inside your own transaction it raises `SolidQueue::Job::EnqueueError`.
|
|
150
|
+
- On MySQL, a deadlock while failing a job isn't retried, and it can stop the worker with the error Solid Queue raises for a job it can't finalize.
|
|
151
|
+
- Keys longer than 255 characters fail on MySQL. `"a"` and `"a "` count as the same key there, because `utf8mb4_bin` pads spaces.
|
|
152
|
+
- Concurrent calls that reach `limit` together can miss the early flush. The next call or the job's schedule covers it.
|
|
153
|
+
- Removing `collect: true` from a job, or `coalesce` altogether, isn't safe while calls are waiting for it: they wouldn't be processed. Drain them first: stop enqueuing and wait for `SolidQueue::CoalescedArgument.waiting` to be empty before you deploy the change.
|
|
154
|
+
|
|
155
|
+
### Monitoring
|
|
156
|
+
|
|
157
|
+
Queue depth hides the backlog, because any number of calls waiting may be a single job. To see it, look at the calls instead:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
SolidQueue::CoalescedArgument.waiting.count
|
|
161
|
+
SolidQueue::CoalescedArgument.waiting.minimum(:created_at) # nil when nothing is waiting
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Testing
|
|
165
|
+
|
|
166
|
+
Everything above needs the Solid Queue adapter, so other adapters ignore `coalesce`. With the test adapter, the inline adapter or `perform_now`, each call runs on its own, and `perform` receives a batch of one built from that call's arguments. `assert_enqueued_with(args: [ document ])` still works, and the Solid Queue tables aren't touched.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
# A row means "this job is the open job for this key": it exists only while
|
|
5
|
+
# its job exists and hasn't started. job_id is null only between the insert and
|
|
6
|
+
# the end of the admission that inserted it, so it's never visible to others.
|
|
7
|
+
class CoalesceKey < Record
|
|
8
|
+
class PendingMigrations < StandardError
|
|
9
|
+
def initialize(message = "The coalescing schema hasn't been installed yet. Run `bin/rails generate solid_queue:coalesce:install` to copy the migration to your application, and then `bin/rails db:migrate` to run it")
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
belongs_to :job, optional: true
|
|
15
|
+
has_many :coalesced_arguments
|
|
16
|
+
|
|
17
|
+
scope :stranded, -> { left_joins(job: :failed_execution).merge(Job.finished.or(Job.where.not(failed_execution: { id: nil }))) }
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Until the app has run the gem's migration everything else works as before.
|
|
21
|
+
def migrated?
|
|
22
|
+
@migrated ||= table_exists? && CoalescedArgument.table_exists?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the row for this key, inserted if nobody holds it, and locked so
|
|
26
|
+
# a job start can't claim or delete it until this transaction commits. A row
|
|
27
|
+
# with a null job_id is ours: the caller has to create the job for it.
|
|
28
|
+
def admit(key)
|
|
29
|
+
loop do
|
|
30
|
+
insert_unless_held(key)
|
|
31
|
+
|
|
32
|
+
admitted = lock(share_lock).find_by(key: key)
|
|
33
|
+
return admitted if admitted
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Locks through the key's index, like admission. Going through the job_id
|
|
38
|
+
# index, even by deleting by it, takes InnoDB's locks on this row's indexes in
|
|
39
|
+
# the opposite order to admission, and that deadlocks.
|
|
40
|
+
def lock_for_release(job_id)
|
|
41
|
+
if key = where(job_id: job_id).pick(:key)
|
|
42
|
+
lock.find_by(key: key, job_id: job_id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def release_stranded(batch_size)
|
|
47
|
+
stranded.limit(batch_size).pluck(:key).each do |key|
|
|
48
|
+
transaction do
|
|
49
|
+
coalesce_key = lock.find_by(key: key)
|
|
50
|
+
coalesce_key.release(claiming: !coalesce_key.job.finished?) if coalesce_key&.stranded?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
# MariaDB and MySQL before 8 don't have FOR SHARE, and this means the same on MySQL 8
|
|
57
|
+
def share_lock
|
|
58
|
+
connection_pool.with_connection { |connection| connection.adapter_name == "PostgreSQL" } ? "FOR SHARE" : "LOCK IN SHARE MODE"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Needs the unique index on key. Skips a conflict instead of raising, because
|
|
62
|
+
# a duplicate would abort an enclosing transaction on PostgreSQL. On MySQL it
|
|
63
|
+
# also avoids a deadlock: a plain INSERT that hits a duplicate takes a shared
|
|
64
|
+
# lock, and two of those on a row being deleted can't both go on to insert.
|
|
65
|
+
def insert_unless_held(key)
|
|
66
|
+
if supports_insert_conflict_target?
|
|
67
|
+
insert({ key: key }, unique_by: :key)
|
|
68
|
+
else
|
|
69
|
+
insert({ key: key })
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stranded?
|
|
75
|
+
job.finished? || job.failed?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect(active_jobs)
|
|
79
|
+
CoalescedArgument.insert_all(active_jobs.map { |active_job| argument_attributes_from(active_job) })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Early flush. Bounded by the limit, however big the backlog is. A concurrent
|
|
83
|
+
# dispatcher holding the scheduled execution is dispatching it already, and if
|
|
84
|
+
# it's been dispatched before we got here there's nothing to lock.
|
|
85
|
+
def flush_if_full(limit)
|
|
86
|
+
if at_least?(limit)
|
|
87
|
+
scheduled = ScheduledExecution.where(job_id: job_id).non_blocking_lock.pluck(:job_id)
|
|
88
|
+
ScheduledExecution.dispatch_jobs(scheduled) if scheduled.any?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Hands the waiting rows over as the job leaves the queue. Requires this row
|
|
93
|
+
# to be locked: that's what waits for in-flight appends, and it serializes this
|
|
94
|
+
# with other starts. Reads of the rows lock too, because on MySQL a plain read
|
|
95
|
+
# could use a snapshot older than the appends we waited for, and deleting this
|
|
96
|
+
# row with rows we didn't see would cascade to them.
|
|
97
|
+
#
|
|
98
|
+
# A finished job won't run again, so it claims nothing when there's a next job
|
|
99
|
+
# that can process the rows. Without a limit there can't be one: the class that
|
|
100
|
+
# would process them is gone, or stopped collecting.
|
|
101
|
+
def release(claiming: true)
|
|
102
|
+
limit = job.coalesce_limit
|
|
103
|
+
claim(claiming || limit.nil? ? limit : 0)
|
|
104
|
+
|
|
105
|
+
if oldest = coalesced_arguments.order(:id).lock.first
|
|
106
|
+
next_job = job.enqueue_next_coalesced(oldest, due_now: next_job_due_now?(limit))
|
|
107
|
+
update_columns(job_id: next_job.id)
|
|
108
|
+
else
|
|
109
|
+
destroy!
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
def next_job_due_now?(limit)
|
|
115
|
+
limit.present? && job.coalesce_flush_when_full? && at_least?(limit)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def argument_attributes_from(active_job)
|
|
119
|
+
{ coalesce_key_id: id, arguments: active_job.serialize["arguments"], scheduled_at: active_job.scheduled_at }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def at_least?(count)
|
|
123
|
+
coalesced_arguments.order(:id).offset(count - 1).limit(1).pick(:id).present?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A nil limit means we can't know it, so nothing is left behind
|
|
127
|
+
def claim(limit)
|
|
128
|
+
claimed = limit ? coalesced_arguments.where(id: coalesced_arguments.order(:id).limit(limit).lock.pluck(:id)) : coalesced_arguments
|
|
129
|
+
claimed.update_all(job_id: job_id, coalesce_key_id: nil)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
# The serialized arguments of one collected perform_later call. It waits on a
|
|
5
|
+
# CoalesceKey until a job starts and claims it, and from then on it belongs to
|
|
6
|
+
# that job, across retries, until the job is destroyed.
|
|
7
|
+
class CoalescedArgument < Record
|
|
8
|
+
belongs_to :coalesce_key, optional: true
|
|
9
|
+
belongs_to :job, optional: true
|
|
10
|
+
|
|
11
|
+
serialize :arguments, coder: JSON
|
|
12
|
+
|
|
13
|
+
scope :waiting, -> { where.not(coalesce_key_id: nil) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class Job
|
|
5
|
+
class CoalescedInBatchError < EnqueueError; end
|
|
6
|
+
|
|
7
|
+
module Coalescible
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
has_many :coalesced_arguments
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
# Every call in active_jobs has the same key. Returns the job if the first
|
|
16
|
+
# call created one, nil if the calls joined the open job.
|
|
17
|
+
def enqueue_coalesced(key, active_jobs)
|
|
18
|
+
enqueued_job, open_job_id = raising_enqueue_errors do
|
|
19
|
+
retrying_deadlocks { transaction { admit_coalesced(key, active_jobs) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
joined = enqueued_job ? active_jobs.drop(1) : active_jobs
|
|
23
|
+
record_coalesced(key, joined, open_job_id)
|
|
24
|
+
|
|
25
|
+
enqueued_job
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enqueue_all_coalesced(active_jobs)
|
|
29
|
+
active_jobs.group_by(&:coalesce_key).sort_by(&:first).each do |key, calls|
|
|
30
|
+
enqueue_coalesced(key, calls)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ensure_coalescible(active_jobs)
|
|
35
|
+
raise CoalesceKey::PendingMigrations unless CoalesceKey.migrated?
|
|
36
|
+
|
|
37
|
+
if Batch.current_batch_id || active_jobs.any?(&:batch_id)
|
|
38
|
+
raise CoalescedInBatchError, "#{active_jobs.first.class.name} coalesces its calls, so it can't be enqueued as part of a batch: a call that joins an open job adds no job to the batch"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
MAX_COALESCE_DEADLOCK_RETRIES = 5
|
|
43
|
+
|
|
44
|
+
# InnoDB can pick one of these transactions as a deadlock victim. Admissions
|
|
45
|
+
# waiting on a key that doesn't exist yet hold gap locks that block each other,
|
|
46
|
+
# creating a job can do the same on a new semaphore, and a job being destroyed
|
|
47
|
+
# cascades into the key's indexes in a different order. Nothing is lost by
|
|
48
|
+
# running ours again, unless it's part of a bigger transaction. In that case
|
|
49
|
+
# InnoDB has rolled all of it back, and only the caller can run it again.
|
|
50
|
+
def retrying_deadlocks
|
|
51
|
+
nested = connection_pool.with_connection(&:transaction_open?)
|
|
52
|
+
attempts = 0
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
yield
|
|
56
|
+
rescue ActiveRecord::Deadlocked
|
|
57
|
+
raise if nested || (attempts += 1) > MAX_COALESCE_DEADLOCK_RETRIES
|
|
58
|
+
retry
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
# A retry of a collect job is a new job with the same active_job_id. The
|
|
64
|
+
# calls it owes move with it, so the old job finishes owning nothing. The
|
|
65
|
+
# block creates the new job, and sets provider_job_id to it.
|
|
66
|
+
def continuing_coalesced_run(active_job)
|
|
67
|
+
previous_job_id = active_job.provider_job_id
|
|
68
|
+
moving_calls = previous_job_id && active_job.class.coalesce_collect && CoalesceKey.migrated?
|
|
69
|
+
|
|
70
|
+
enqueued_job = raising_enqueue_errors do
|
|
71
|
+
transaction do
|
|
72
|
+
yield.tap do |job|
|
|
73
|
+
CoalescedArgument.where(job_id: previous_job_id).update_all(job_id: job.id) if moving_calls && job.persisted?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Active Job marks the call as enqueued once we return, so only raising
|
|
79
|
+
# stops on_conflict: :discard from silently dropping a coalesced job's retry
|
|
80
|
+
unless enqueued_job.persisted?
|
|
81
|
+
raise EnqueueError, "The retry of #{active_job.class.name} (#{active_job.job_id}) was discarded by its concurrency limit"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
enqueued_job
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def raising_enqueue_errors
|
|
88
|
+
yield
|
|
89
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
90
|
+
enqueue_error = EnqueueError.new("#{e.class.name}: #{e.message}").tap do |error|
|
|
91
|
+
error.set_backtrace e.backtrace
|
|
92
|
+
end
|
|
93
|
+
raise enqueue_error
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def admit_coalesced(key, active_jobs)
|
|
97
|
+
job_class = active_jobs.first.class
|
|
98
|
+
coalesce_key = CoalesceKey.admit(key)
|
|
99
|
+
enqueued_job = create_for_coalesce_key(coalesce_key, active_jobs.first) if coalesce_key.job_id.nil?
|
|
100
|
+
|
|
101
|
+
if job_class.coalesce_collect && coalesce_key.job_id
|
|
102
|
+
coalesce_key.collect(active_jobs)
|
|
103
|
+
coalesce_key.flush_if_full(job_class.coalesce_limit) if job_class.coalesce_flush_when_full
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[ enqueued_job, coalesce_key.job_id ]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def create_for_coalesce_key(coalesce_key, active_job)
|
|
110
|
+
create!(**attributes_from_active_job(active_job)).tap do |job|
|
|
111
|
+
# limits_concurrency with on_conflict: :discard destroys the job right away
|
|
112
|
+
if job.persisted?
|
|
113
|
+
coalesce_key.update_columns(job_id: job.id)
|
|
114
|
+
else
|
|
115
|
+
coalesce_key.destroy!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
active_job.provider_job_id = job.id if job.persisted?
|
|
119
|
+
active_job.successfully_enqueued = job.persisted?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def record_coalesced(key, active_jobs, open_job_id)
|
|
124
|
+
active_jobs.each do |active_job|
|
|
125
|
+
collected = active_job.class.coalesce_collect
|
|
126
|
+
|
|
127
|
+
active_job.provider_job_id = open_job_id
|
|
128
|
+
active_job.successfully_enqueued = collected
|
|
129
|
+
active_job.coalesced = open_job_id.present?
|
|
130
|
+
|
|
131
|
+
if open_job_id
|
|
132
|
+
SolidQueue.instrument(:coalesce, key: key, job_id: open_job_id, active_job_id: active_job.job_id, action: collected ? :collected : :dropped)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def coalesced?
|
|
139
|
+
job_class.try(:coalesce_key).present?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def coalesce_limit
|
|
143
|
+
job_class.try(:coalesce_limit)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def coalesce_flush_when_full?
|
|
147
|
+
job_class.try(:coalesce_flush_when_full)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The start step. Returns what the Active Job needs to know about this run,
|
|
151
|
+
# which stays in memory instead of going into its serialized data.
|
|
152
|
+
def start_coalesced_run
|
|
153
|
+
return unless coalesced? && CoalesceKey.migrated?
|
|
154
|
+
|
|
155
|
+
self.class.retrying_deadlocks do
|
|
156
|
+
transaction do
|
|
157
|
+
release_coalesce_key
|
|
158
|
+
{ "calls" => coalesced_arguments.order(:id).pluck(:id, :arguments), "concurrency_key" => concurrency_key }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The next job for the same key, picking up from the rows the job that's
|
|
164
|
+
# starting left waiting. Its arguments are the raw ones in the oldest row:
|
|
165
|
+
# deserializing them here would let a deleted record break the handover.
|
|
166
|
+
def enqueue_next_coalesced(oldest, due_now:)
|
|
167
|
+
due_at = (oldest.scheduled_at unless due_now) || Time.current
|
|
168
|
+
active_job_id = SecureRandom.uuid
|
|
169
|
+
|
|
170
|
+
self.class.create!(
|
|
171
|
+
queue_name: queue_name,
|
|
172
|
+
class_name: class_name,
|
|
173
|
+
priority: priority,
|
|
174
|
+
active_job_id: active_job_id,
|
|
175
|
+
scheduled_at: due_at,
|
|
176
|
+
concurrency_key: concurrency_key,
|
|
177
|
+
arguments: next_coalesced_arguments(active_job_id, oldest, due_at)
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
def release_coalesce_key
|
|
183
|
+
return unless CoalesceKey.migrated?
|
|
184
|
+
|
|
185
|
+
if job_class&.coalesce_key && !job_class.coalesce_collect
|
|
186
|
+
CoalesceKey.lock_for_release(id)&.destroy!
|
|
187
|
+
elsif job_class.nil? || coalesced?
|
|
188
|
+
CoalesceKey.lock_for_release(id)&.release
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def next_coalesced_arguments(active_job_id, oldest, due_at)
|
|
193
|
+
arguments.except("continuation", "resumptions").merge(
|
|
194
|
+
"job_id" => active_job_id,
|
|
195
|
+
"provider_job_id" => nil,
|
|
196
|
+
"arguments" => oldest.arguments,
|
|
197
|
+
"executions" => 0,
|
|
198
|
+
"exception_executions" => {},
|
|
199
|
+
"enqueued_at" => Time.now.utc.iso8601(9),
|
|
200
|
+
"scheduled_at" => due_at.utc.iso8601(9)
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module Coalescing
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
DEFAULT_COALESCE_LIMIT = 100
|
|
8
|
+
|
|
9
|
+
# Sits in front of the job's own #perform, whichever class defines it, so
|
|
10
|
+
# #arguments can keep holding this call's own arguments.
|
|
11
|
+
module CollectedPerform
|
|
12
|
+
def perform(*)
|
|
13
|
+
super(@coalesced_calls)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
class_attribute :coalesce_key, instance_accessor: false
|
|
19
|
+
class_attribute :coalesce_collect, default: false, instance_accessor: false
|
|
20
|
+
class_attribute :coalesce_limit, instance_accessor: false
|
|
21
|
+
class_attribute :coalesce_flush_when_full, instance_accessor: false
|
|
22
|
+
|
|
23
|
+
attr_writer :coalesced
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class_methods do
|
|
27
|
+
def coalesce(key:, collect: false, limit: nil, flush_when_full: nil)
|
|
28
|
+
unless (key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Proc)) && key.present?
|
|
29
|
+
raise ArgumentError, "`key` must be a non-blank String or Symbol, or a Proc"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if limit && !collect
|
|
33
|
+
raise ArgumentError, "`limit` needs `collect: true`"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if !flush_when_full.nil? && !collect
|
|
37
|
+
raise ArgumentError, "`flush_when_full` needs `collect: true`"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
limit ||= DEFAULT_COALESCE_LIMIT if collect
|
|
41
|
+
|
|
42
|
+
if limit && !(limit.is_a?(Integer) && limit.positive?)
|
|
43
|
+
raise ArgumentError, "`limit` must be a positive integer"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
flush_when_full = true if collect && flush_when_full.nil?
|
|
47
|
+
|
|
48
|
+
unless flush_when_full.nil? || flush_when_full == true || flush_when_full == false
|
|
49
|
+
raise ArgumentError, "`flush_when_full` must be true or false"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ensure_coalesce_compatible_with_concurrency_controls(collect, concurrency_on_conflict)
|
|
53
|
+
|
|
54
|
+
self.coalesce_key = key
|
|
55
|
+
self.coalesce_collect = collect
|
|
56
|
+
self.coalesce_limit = limit
|
|
57
|
+
self.coalesce_flush_when_full = flush_when_full
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def limits_concurrency(**)
|
|
61
|
+
super.tap { ensure_coalesce_compatible_with_concurrency_controls(coalesce_collect, concurrency_on_conflict) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
# A conflict destroys the job, and with it the calls it has collected
|
|
66
|
+
def ensure_coalesce_compatible_with_concurrency_controls(collect, on_conflict)
|
|
67
|
+
if collect && on_conflict == :discard
|
|
68
|
+
raise ArgumentError, "`coalesce` with `collect: true` can't be combined with `limits_concurrency` with `on_conflict: :discard`"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Runs before the arguments are deserialized. Raising here reaches the rescue
|
|
74
|
+
# in ClaimedExecution#execute, which turns it into a failed execution.
|
|
75
|
+
def perform_now
|
|
76
|
+
start_coalesced_run
|
|
77
|
+
super
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Scoped to the job class name, and built like concurrency_key
|
|
81
|
+
def coalesce_key
|
|
82
|
+
if self.class.coalesce_key
|
|
83
|
+
param = compute_concurrency_parameter(self.class.coalesce_key)
|
|
84
|
+
|
|
85
|
+
case param
|
|
86
|
+
when ActiveRecord::Base
|
|
87
|
+
[ self.class.name, param.class.name, param.id ]
|
|
88
|
+
else
|
|
89
|
+
[ self.class.name, param ]
|
|
90
|
+
end.compact.join("/")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Whether this call joined a job that was already open
|
|
95
|
+
def coalesced?
|
|
96
|
+
!!@coalesced
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A retry is the same work continuing, so it can't join or suppress anything
|
|
100
|
+
def coalesced_enqueue?
|
|
101
|
+
self.class.coalesce_key.present? && executions.to_i.zero?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The stored key, in case the arguments it was computed from are gone
|
|
105
|
+
def concurrency_key
|
|
106
|
+
@coalesced_arguments_gone ? @coalesced_run["concurrency_key"] : super
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A retry that doesn't get enqueued has to fail the run: finishing would
|
|
110
|
+
# destroy the job and the calls it owns, with no job left to process them.
|
|
111
|
+
def retry_job(options = {})
|
|
112
|
+
super.tap do |enqueued|
|
|
113
|
+
if @coalesced_run && !enqueued
|
|
114
|
+
raise enqueue_error || SolidQueue::Job::EnqueueError.new("Couldn't enqueue the retry of #{self.class.name} (#{job_id})")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
# Only the run of a job a worker has claimed. perform_now on an instance that
|
|
121
|
+
# went through perform_later also has a provider_job_id.
|
|
122
|
+
def start_coalesced_run
|
|
123
|
+
if self.class.coalesce_key && provider_job_id && self.class.queue_adapter_name == "solid_queue"
|
|
124
|
+
@coalesced_run = SolidQueue::Job.joins(:claimed_execution).find_by(id: provider_job_id)&.start_coalesced_run
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def _perform_job
|
|
129
|
+
return super unless self.class.coalesce_collect
|
|
130
|
+
|
|
131
|
+
@coalesced_calls = coalesced_calls
|
|
132
|
+
|
|
133
|
+
if @coalesced_calls.any?
|
|
134
|
+
extend CollectedPerform
|
|
135
|
+
super
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The job's own arguments are a copy of its first call's, which may be for a
|
|
140
|
+
# record that's gone by now. That call's row decides what happens to it.
|
|
141
|
+
def deserialize_arguments_if_needed
|
|
142
|
+
super
|
|
143
|
+
rescue ActiveJob::DeserializationError => error
|
|
144
|
+
raise unless worker_coalesced_rows.any? && record_not_found?(error)
|
|
145
|
+
|
|
146
|
+
@coalesced_arguments_gone = true
|
|
147
|
+
@arguments = []
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def worker_coalesced_rows
|
|
151
|
+
@coalesced_run&.fetch("calls") || []
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def coalesced_calls
|
|
155
|
+
if worker_coalesced_rows.any?
|
|
156
|
+
calls_from_coalesced_rows
|
|
157
|
+
else
|
|
158
|
+
[ coalesced_call_from(arguments) ]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Anything but a deleted record raises on the way, before any row has been
|
|
163
|
+
# dropped, and fails the run with its rows. When every record is gone the rows
|
|
164
|
+
# stay until the job is destroyed: if this run crashes before finishing, the
|
|
165
|
+
# re-run needs them to tell it has nothing to do, because a job without rows
|
|
166
|
+
# runs its own arguments.
|
|
167
|
+
def calls_from_coalesced_rows
|
|
168
|
+
loaded, missing = worker_coalesced_rows.map { |id, serialized| [ id, load_coalesced_row(serialized) ] }.partition(&:last)
|
|
169
|
+
drop_coalesced_rows(missing, delete: loaded.any?)
|
|
170
|
+
|
|
171
|
+
loaded.map { |_, call_arguments| coalesced_call_from(call_arguments) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def load_coalesced_row(serialized)
|
|
175
|
+
ActiveJob::Arguments.deserialize(serialized)
|
|
176
|
+
rescue ActiveJob::DeserializationError => error
|
|
177
|
+
raise unless record_not_found?(error)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def drop_coalesced_rows(rows, delete:)
|
|
181
|
+
rows.each do |id, _|
|
|
182
|
+
SolidQueue.instrument(:drop_coalesced_argument, argument_id: id, job_id: provider_job_id, active_job_id: job_id) do
|
|
183
|
+
SolidQueue::CoalescedArgument.where(id: id).delete_all if delete
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Rails main has its own error for this, and its cause isn't Active Record's
|
|
189
|
+
def record_not_found?(error)
|
|
190
|
+
error.cause.is_a?(ActiveRecord::RecordNotFound) ||
|
|
191
|
+
(defined?(ActiveJob::DeserializationError::RecordNotFound) && error.is_a?(ActiveJob::DeserializationError::RecordNotFound))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def coalesced_call_from(call_arguments)
|
|
195
|
+
call_arguments.size == 1 ? call_arguments.first : call_arguments
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
class SolidQueue::Coalesce::InstallGenerator < Rails::Generators::Base
|
|
6
|
+
include ActiveRecord::Generators::Migration
|
|
7
|
+
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :database, type: :string, aliases: %i[ --db ], default: "queue",
|
|
11
|
+
desc: "The database that Solid Queue uses. Defaults to `queue`"
|
|
12
|
+
|
|
13
|
+
def copy_migration
|
|
14
|
+
migration_template "db/create_solid_queue_coalesce_tables.rb", File.join(db_migrate_path, "create_solid_queue_coalesce_tables.rb"), skip: true
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/generators/solid_queue/coalesce/install/templates/db/create_solid_queue_coalesce_tables.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class CreateSolidQueueCoalesceTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :solid_queue_coalesce_keys do |t|
|
|
4
|
+
# MySQL's default collations treat keys that differ by case or accent as the
|
|
5
|
+
# same key, which would drop a call that should open its own job
|
|
6
|
+
t.string :key, null: false, collation: ("utf8mb4_bin" if connection.adapter_name.match?(/mysql|trilogy/i))
|
|
7
|
+
t.bigint :job_id
|
|
8
|
+
t.datetime :created_at, null: false
|
|
9
|
+
|
|
10
|
+
t.index :key, unique: true
|
|
11
|
+
t.index :job_id, unique: true
|
|
12
|
+
t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_table :solid_queue_coalesced_arguments do |t|
|
|
16
|
+
t.bigint :coalesce_key_id
|
|
17
|
+
t.bigint :job_id
|
|
18
|
+
t.text :arguments, null: false
|
|
19
|
+
t.datetime :scheduled_at
|
|
20
|
+
t.datetime :created_at, null: false
|
|
21
|
+
|
|
22
|
+
t.index :coalesce_key_id
|
|
23
|
+
t.index :job_id
|
|
24
|
+
t.foreign_key :solid_queue_coalesce_keys, column: :coalesce_key_id, on_delete: :cascade
|
|
25
|
+
t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
module Coalesce
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
initializer "solid_queue.coalesce.active_job", after: "solid_queue.active_job.extensions" do
|
|
7
|
+
ActiveSupport.on_load :active_job do
|
|
8
|
+
include ActiveJob::Coalescing
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Job and RecurringTask are reloadable, so a reload needs the patches again
|
|
13
|
+
config.to_prepare { Patches.apply }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every place this gem reaches into Solid Queue. All of it depends on private
|
|
4
|
+
# details of the version pinned in the gemspec.
|
|
5
|
+
module SolidQueue
|
|
6
|
+
module Coalesce
|
|
7
|
+
module Patches
|
|
8
|
+
# Prepended rather than added to the adapter, because RecurringTask calls
|
|
9
|
+
# Job.enqueue directly.
|
|
10
|
+
module Enqueuing
|
|
11
|
+
def enqueue_all(active_jobs)
|
|
12
|
+
coalesced_jobs, other_jobs = active_jobs.partition(&:coalesced_enqueue?)
|
|
13
|
+
return super if coalesced_jobs.empty?
|
|
14
|
+
|
|
15
|
+
coalesced_jobs.each { |active_job| active_job.scheduled_at ||= Time.current }
|
|
16
|
+
ensure_coalescible(coalesced_jobs)
|
|
17
|
+
|
|
18
|
+
super(other_jobs) if other_jobs.any?
|
|
19
|
+
enqueue_all_coalesced(coalesced_jobs)
|
|
20
|
+
|
|
21
|
+
active_jobs.count(&:successfully_enqueued?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enqueue(active_job, scheduled_at: Time.current)
|
|
25
|
+
if active_job.coalesced_enqueue?
|
|
26
|
+
active_job.scheduled_at = scheduled_at
|
|
27
|
+
ensure_coalescible([ active_job ])
|
|
28
|
+
enqueue_coalesced(active_job.coalesce_key, [ active_job ])
|
|
29
|
+
elsif active_job.class.coalesce_key
|
|
30
|
+
continuing_coalesced_run(active_job) { super }
|
|
31
|
+
else
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module Failing
|
|
38
|
+
def failed_with(exception)
|
|
39
|
+
FailedExecution.transaction(requires_new: true) do
|
|
40
|
+
release_coalesce_key
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Maintenance#start runs its steps from an inline block, so this follows
|
|
47
|
+
# the only concurrency step it can reach.
|
|
48
|
+
module Maintaining
|
|
49
|
+
private
|
|
50
|
+
def expire_semaphores
|
|
51
|
+
super
|
|
52
|
+
release_stranded_coalesce_keys
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def release_stranded_coalesce_keys
|
|
56
|
+
wrap_in_app_executor do
|
|
57
|
+
CoalesceKey.release_stranded(batch_size) if CoalesceKey.migrated?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module RecurringValidation
|
|
63
|
+
extend ActiveSupport::Concern
|
|
64
|
+
|
|
65
|
+
included do
|
|
66
|
+
validate :ensure_job_class_not_collecting
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
# A tick that joins an open job would record that job again, and the unique
|
|
71
|
+
# index on recurring_executions.job_id would roll back the call it collected.
|
|
72
|
+
def ensure_job_class_not_collecting
|
|
73
|
+
if class_name.present? && job_class&.try(:coalesce_collect)
|
|
74
|
+
errors.add :class_name, :collecting, message: "can't be a job that uses `coalesce` with `collect: true`"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.apply
|
|
80
|
+
SolidQueue::Job.include SolidQueue::Job::Coalescible
|
|
81
|
+
SolidQueue::Job.singleton_class.prepend Enqueuing
|
|
82
|
+
SolidQueue::Job.prepend Failing
|
|
83
|
+
SolidQueue::RecurringTask.include RecurringValidation
|
|
84
|
+
SolidQueue::Dispatcher::Maintenance.prepend Maintaining
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class SolidQueue::LogSubscriber
|
|
91
|
+
def coalesce(event)
|
|
92
|
+
debug formatted_event(event, action: "Coalesce call", **event.payload.slice(:key, :job_id, :active_job_id), outcome: event.payload[:action])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def drop_coalesced_argument(event)
|
|
96
|
+
warn formatted_event(event, action: "Drop coalesced call for a deleted record", **event.payload.slice(:argument_id, :job_id, :active_job_id))
|
|
97
|
+
end
|
|
98
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: solid_queue-coalesce
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- David Cristofaro
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: solid_queue
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.7.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 1.7.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: appraisal
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: debug
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.9'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.9'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: mocha
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: mysql2
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: pg
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: sqlite3
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: rubocop-rails-omakase
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rdoc
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: logger
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '0'
|
|
159
|
+
type: :development
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - ">="
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: '0'
|
|
166
|
+
description: Lets many perform_later calls with the same key share one pending Solid
|
|
167
|
+
Queue job.
|
|
168
|
+
email:
|
|
169
|
+
- david@dtcristo.com
|
|
170
|
+
executables: []
|
|
171
|
+
extensions: []
|
|
172
|
+
extra_rdoc_files: []
|
|
173
|
+
files:
|
|
174
|
+
- MIT-LICENSE
|
|
175
|
+
- README.md
|
|
176
|
+
- app/models/solid_queue/coalesce_key.rb
|
|
177
|
+
- app/models/solid_queue/coalesced_argument.rb
|
|
178
|
+
- app/models/solid_queue/job/coalescible.rb
|
|
179
|
+
- lib/active_job/coalescing.rb
|
|
180
|
+
- lib/generators/solid_queue/coalesce/install/install_generator.rb
|
|
181
|
+
- lib/generators/solid_queue/coalesce/install/templates/db/create_solid_queue_coalesce_tables.rb
|
|
182
|
+
- lib/solid_queue/coalesce.rb
|
|
183
|
+
- lib/solid_queue/coalesce/engine.rb
|
|
184
|
+
- lib/solid_queue/coalesce/patches.rb
|
|
185
|
+
- lib/solid_queue/coalesce/version.rb
|
|
186
|
+
homepage: https://github.com/dtcristo/solid_queue-coalesce
|
|
187
|
+
licenses:
|
|
188
|
+
- MIT
|
|
189
|
+
metadata:
|
|
190
|
+
homepage_uri: https://github.com/dtcristo/solid_queue-coalesce
|
|
191
|
+
source_code_uri: https://github.com/dtcristo/solid_queue-coalesce
|
|
192
|
+
rdoc_options: []
|
|
193
|
+
require_paths:
|
|
194
|
+
- lib
|
|
195
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
196
|
+
requirements:
|
|
197
|
+
- - ">="
|
|
198
|
+
- !ruby/object:Gem::Version
|
|
199
|
+
version: '3.2'
|
|
200
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
201
|
+
requirements:
|
|
202
|
+
- - ">="
|
|
203
|
+
- !ruby/object:Gem::Version
|
|
204
|
+
version: '0'
|
|
205
|
+
requirements: []
|
|
206
|
+
rubygems_version: 4.0.20
|
|
207
|
+
specification_version: 4
|
|
208
|
+
summary: Coalescing for Solid Queue.
|
|
209
|
+
test_files: []
|