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 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
@@ -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
@@ -0,0 +1,5 @@
1
+ module SolidQueue
2
+ module Coalesce
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "solid_queue"
4
+
5
+ require "active_job/coalescing"
6
+ require "solid_queue/coalesce/version"
7
+ require "solid_queue/coalesce/patches"
8
+ require "solid_queue/coalesce/engine"
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: []