solid_queue-coalesce 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +800 -72
- data/UPGRADING.md +76 -0
- data/app/models/solid_queue/coalesce_key.rb +57 -67
- data/app/models/solid_queue/coalesced_call.rb +22 -0
- data/app/models/solid_queue/job/coalescible.rb +201 -89
- data/lib/active_job/coalescing.rb +226 -71
- data/lib/generators/solid_queue/coalesce/install/templates/db/create_solid_queue_coalesce_tables.rb +4 -9
- data/lib/solid_queue/coalesce/engine.rb +1 -1
- data/lib/solid_queue/coalesce/patches.rb +74 -24
- data/lib/solid_queue/coalesce/version.rb +1 -1
- metadata +9 -4
- data/app/models/solid_queue/coalesced_argument.rb +0 -15
data/README.md
CHANGED
|
@@ -1,169 +1,897 @@
|
|
|
1
1
|
# Solid Queue Coalesce
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Dedupe, debounce and batch [Solid Queue](https://github.com/rails/solid_queue) jobs. 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 isn't strict uniqueness. A job can run again for each new call after the last run started, and two runs can overlap. Use [`limits_concurrency`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls) alongside it to stop overlaps, and see [`coalesce` or `limits_concurrency`?](#coalesce-or-limits_concurrency) to choose.
|
|
4
6
|
|
|
5
7
|
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
8
|
|
|
7
|
-
## Table of
|
|
9
|
+
## Table of contents
|
|
8
10
|
|
|
9
|
-
- [
|
|
10
|
-
- [
|
|
11
|
+
- [Getting started](#getting-started)
|
|
12
|
+
- [Recipes](#recipes)
|
|
13
|
+
- [`coalesce` or `limits_concurrency`?](#coalesce-or-limits_concurrency)
|
|
14
|
+
- [Things to know](#things-to-know)
|
|
15
|
+
- [Compatibility](#compatibility)
|
|
16
|
+
- [`coalesce` options](#coalesce-options)
|
|
17
|
+
- [Keys](#keys)
|
|
11
18
|
- [Collecting calls](#collecting-calls)
|
|
12
|
-
- [
|
|
19
|
+
- [Collecting jobs and scheduling](#collecting-jobs-and-scheduling)
|
|
20
|
+
- [Debouncing](#debouncing)
|
|
21
|
+
- [Scheduling timelines](#scheduling-timelines)
|
|
22
|
+
- [Enqueue outcomes](#enqueue-outcomes)
|
|
23
|
+
- [Bulk enqueues](#bulk-enqueues)
|
|
24
|
+
- [Transactions](#transactions)
|
|
25
|
+
- [Retries](#retries)
|
|
26
|
+
- [Deleted records](#deleted-records)
|
|
27
|
+
- [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits)
|
|
28
|
+
- [Solid Queue batches](#solid-queue-batches)
|
|
13
29
|
- [Discarding and retention](#discarding-and-retention)
|
|
30
|
+
- [Changing a job's configuration](#changing-a-jobs-configuration)
|
|
14
31
|
- [Rules and limitations](#rules-and-limitations)
|
|
15
32
|
- [Monitoring](#monitoring)
|
|
16
33
|
- [Testing](#testing)
|
|
17
34
|
- [License](#license)
|
|
18
35
|
|
|
19
|
-
##
|
|
36
|
+
## Getting started
|
|
20
37
|
|
|
21
|
-
Add the gem to your application's Gemfile, alongside Solid Queue:
|
|
38
|
+
Add the gem to your application's Gemfile, alongside Solid Queue, then copy its migration to your queue database's migrations path and run it:
|
|
22
39
|
|
|
23
40
|
```ruby
|
|
24
41
|
gem "solid_queue-coalesce"
|
|
25
42
|
```
|
|
26
43
|
|
|
27
|
-
Then copy its migration to your queue database's migrations path and run it:
|
|
28
|
-
|
|
29
44
|
```bash
|
|
30
45
|
bundle install
|
|
31
46
|
bin/rails generate solid_queue:coalesce:install
|
|
32
47
|
bin/rails db:migrate
|
|
33
48
|
```
|
|
34
49
|
|
|
35
|
-
|
|
50
|
+
Add `coalesce` to a job, with a [key](#keys) that says which calls belong together:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class ReindexProductJob < ApplicationJob
|
|
54
|
+
coalesce key: ->(product_id) { product_id }
|
|
55
|
+
|
|
56
|
+
def perform(product_id)
|
|
57
|
+
Product.find(product_id).reindex!
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ReindexProductJob.perform_later(product.id)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The first call creates a job. Later calls with that key get discarded until the job starts. Pick a [recipe](#recipes) for anything more specific.
|
|
65
|
+
|
|
66
|
+
### Where the migration goes
|
|
67
|
+
|
|
68
|
+
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`. Solid Queue's [database configuration](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#database-configuration) and [single database setup](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#single-database-configuration) cover the options.
|
|
69
|
+
|
|
70
|
+
The generator looks up that database in the current environment's `config/database.yml`. If it isn't there, the generator writes to `db/migrate` without warning, and the migration runs against your primary database. A new Rails app only defines `queue` under `production`, so generate the migration for that environment instead, and let `bin/rails db:prepare` run it when you deploy:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails generate solid_queue:coalesce:install
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The migration should land in `db/queue_migrate`. `db/queue_schema.rb` doesn't pick up the new tables from a production migration. That's fine, because `db:prepare` loads that schema into an empty database and then runs the migration.
|
|
36
77
|
|
|
37
78
|
Until the migration has run, everything else works as before, and enqueuing a job that uses `coalesce` raises `SolidQueue::CoalesceKey::PendingMigrations`.
|
|
38
79
|
|
|
39
|
-
The gem
|
|
80
|
+
The gem relies on private Solid Queue methods, so its gemspec allows one minor version of Solid Queue at a time.
|
|
81
|
+
|
|
82
|
+
To upgrade from an earlier version, follow [UPGRADING.md](UPGRADING.md). [CHANGELOG.md](CHANGELOG.md) lists what changed in each version.
|
|
83
|
+
|
|
84
|
+
## Recipes
|
|
85
|
+
|
|
86
|
+
Start from what you want to happen to a burst of calls.
|
|
40
87
|
|
|
41
|
-
|
|
88
|
+
Run once, with the first call's arguments:
|
|
42
89
|
|
|
43
|
-
|
|
90
|
+
- [As soon as a worker is free](#dedupe-jobs-with-one-pending-job-per-key)
|
|
91
|
+
- [At most once per time window](#throttle-to-one-run-per-window)
|
|
92
|
+
- [Once calls stop, or by a deadline](#debounce-until-activity-stops)
|
|
93
|
+
- [Now, then once more after the run](#run-on-the-leading-edge)
|
|
94
|
+
- [On a schedule, without piling up](#coalesce-a-recurring-task)
|
|
95
|
+
- [With a callback once a group of jobs is done](#dedupe-jobs-in-a-solid-queue-batch)
|
|
44
96
|
|
|
45
|
-
|
|
97
|
+
Run once, with every call's arguments:
|
|
98
|
+
|
|
99
|
+
- [After a fixed time](#batch-calls-together)
|
|
100
|
+
- [At N calls or T seconds](#run-a-batch-at-n-calls-or-t-seconds)
|
|
101
|
+
- [Once calls stop, or at N calls](#debounce-a-batch)
|
|
102
|
+
- [At midnight](#run-a-batch-at-midnight)
|
|
103
|
+
- [One batch at a time](#run-one-batch-at-a-time)
|
|
104
|
+
- [Resuming after a deploy](#resume-a-long-batch)
|
|
105
|
+
|
|
106
|
+
Run every call:
|
|
107
|
+
|
|
108
|
+
- [One at a time, without `coalesce`](#run-every-call-one-at-a-time)
|
|
109
|
+
|
|
110
|
+
`Product`, `SearchIndex` and the other names in these recipes stand in for your own code.
|
|
111
|
+
|
|
112
|
+
### Dedupe jobs with one pending job per key
|
|
113
|
+
|
|
114
|
+
Use this for unique pending jobs, when a duplicate call adds nothing to the work already waiting.
|
|
46
115
|
|
|
47
116
|
```ruby
|
|
48
|
-
class
|
|
49
|
-
coalesce key: ->(
|
|
117
|
+
class ReindexProductJob < ApplicationJob
|
|
118
|
+
coalesce key: ->(product_id) { product_id }
|
|
50
119
|
|
|
51
|
-
def perform(
|
|
52
|
-
|
|
120
|
+
def perform(product_id)
|
|
121
|
+
Product.find(product_id).reindex!
|
|
53
122
|
end
|
|
54
123
|
end
|
|
124
|
+
|
|
125
|
+
ReindexProductJob.perform_later(product.id)
|
|
55
126
|
```
|
|
56
127
|
|
|
57
|
-
|
|
128
|
+
The first call runs as soon as a worker is free. Later calls get discarded until it starts, so the job only sees the first call's arguments. Pass an ID and read fresh data in `perform`. See [`coalesce` options](#coalesce-options) and [Keys](#keys).
|
|
129
|
+
|
|
130
|
+
### Throttle to one run per window
|
|
58
131
|
|
|
59
|
-
|
|
132
|
+
Use this when a short delay lets more calls merge into the same run, and each key should run at most once per window.
|
|
60
133
|
|
|
61
|
-
|
|
134
|
+
```ruby
|
|
135
|
+
ReindexProductJob.set(wait: 30.seconds).perform_later(product.id)
|
|
136
|
+
```
|
|
62
137
|
|
|
63
|
-
|
|
138
|
+
The job runs 30 seconds after the call that opened it, and later calls don't move it. A plain `perform_later` that arrives first opens a job that runs right away, so every caller needs the `wait`. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling) and the [timeline](#scheduling-timelines).
|
|
64
139
|
|
|
65
|
-
###
|
|
140
|
+
### Debounce until activity stops
|
|
66
141
|
|
|
67
|
-
|
|
142
|
+
Use this when work should wait for a burst to finish, such as reindexing after a run of edits.
|
|
68
143
|
|
|
69
144
|
```ruby
|
|
70
|
-
class
|
|
71
|
-
coalesce key: ->(
|
|
145
|
+
class ReindexProductJob < ApplicationJob
|
|
146
|
+
coalesce key: ->(product_id) { product_id }, debounce: 30.seconds, max_wait: 5.minutes
|
|
72
147
|
|
|
73
|
-
def perform(
|
|
74
|
-
|
|
148
|
+
def perform(product_id)
|
|
149
|
+
Product.find(product_id).reindex!
|
|
75
150
|
end
|
|
76
151
|
end
|
|
152
|
+
|
|
153
|
+
ReindexProductJob.perform_later(product.id) # Runs 30 seconds after the last call
|
|
154
|
+
ReindexProductJob.set(wait: 2.minutes).perform_later(product.id) # This call asks for 2 minutes instead
|
|
77
155
|
```
|
|
78
156
|
|
|
79
|
-
|
|
157
|
+
The job comes due 30 seconds after the last call, and no later than 5 minutes after it opened. Without `max_wait`, a key that never goes quiet can delay its job indefinitely. See [Debouncing](#debouncing) and the [timeline](#scheduling-timelines).
|
|
158
|
+
|
|
159
|
+
### Run on the leading edge
|
|
160
|
+
|
|
161
|
+
Use this when the first call should run right away, runs shouldn't overlap, and calls that arrive during a run should merge into one follow-up.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class SyncInventoryJob < ApplicationJob
|
|
165
|
+
coalesce key: :global
|
|
166
|
+
limits_concurrency to: 1, key: :global, on_conflict: :block, duration: 30.minutes
|
|
80
167
|
|
|
81
|
-
|
|
168
|
+
def perform
|
|
169
|
+
InventorySynchronizer.sync!
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
SyncInventoryJob.perform_later
|
|
174
|
+
```
|
|
82
175
|
|
|
83
|
-
|
|
176
|
+
This has no time window. It merges calls while a job is pending, and the next call after the follow-up starts or finishes can run straight away. A cooldown after a run isn't possible, because the key is released when a job starts. Set `duration` longer than your slowest run, because Solid Queue's [concurrency controls](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls) treat it as a failsafe. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
84
177
|
|
|
85
|
-
|
|
178
|
+
`on_conflict` means different things here. On `coalesce`, it decides what a call does when a job is already pending. On `limits_concurrency`, it decides what a job does when the limit is full.
|
|
86
179
|
|
|
87
|
-
|
|
180
|
+
### Coalesce a recurring task
|
|
181
|
+
|
|
182
|
+
Use this when a tick that finds a job still pending should add nothing, and ticks shouldn't overlap a slow run. Use the `SyncInventoryJob` from the leading edge recipe with a [recurring task](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#recurring-tasks):
|
|
183
|
+
|
|
184
|
+
```yaml
|
|
185
|
+
production:
|
|
186
|
+
sync_inventory:
|
|
187
|
+
class: SyncInventoryJob
|
|
188
|
+
schedule: every minute
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A tick during a run creates one blocked job, later ticks merge into it, and it runs when the current run finishes. Without `limits_concurrency`, a slow run can overlap the next tick's. A recurring task can't use `:collect` or `debounce:`. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
192
|
+
|
|
193
|
+
### Dedupe jobs in a Solid Queue batch
|
|
194
|
+
|
|
195
|
+
Use this when a callback should run once a group of jobs is done, and jobs in that group can ask for the same work.
|
|
88
196
|
|
|
89
197
|
```ruby
|
|
90
|
-
class
|
|
91
|
-
coalesce key: ->(
|
|
198
|
+
class ReindexProductJob < ApplicationJob
|
|
199
|
+
coalesce key: ->(product_id) { product_id }
|
|
92
200
|
|
|
93
|
-
def
|
|
94
|
-
|
|
201
|
+
def perform(product_id)
|
|
202
|
+
Product.find(product_id).reindex!
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
class ImportSupplierJob < ApplicationJob
|
|
207
|
+
def perform(supplier_id)
|
|
208
|
+
Supplier.find(supplier_id).import_products.each do |product_id|
|
|
209
|
+
batch.enqueue { ReindexProductJob.perform_later(product_id) }
|
|
210
|
+
end
|
|
95
211
|
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
SolidQueue::Batch.enqueue(on_success: PublishCatalogJob) do
|
|
215
|
+
supplier_ids.each { |supplier_id| ImportSupplierJob.perform_later(supplier_id) }
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Each importer adds to the batch while it runs, and that running job keeps the batch open. Two importers that reach the same pending product share one reindex. If that reindex has already started, the later call opens a new job, so the later change still gets reindexed. `PublishCatalogJob` runs once, after the importers and every reindex are done. See [Solid Queue batches](#solid-queue-batches).
|
|
220
|
+
|
|
221
|
+
### Batch calls together
|
|
222
|
+
|
|
223
|
+
Use this when every call carries data you need, and one run should process them.
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
class BulkReindexProductsJob < ApplicationJob
|
|
227
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 500
|
|
96
228
|
|
|
97
|
-
def
|
|
98
|
-
|
|
229
|
+
def perform_calls(calls)
|
|
230
|
+
product_ids = calls.map { |call| call.arguments.last }.uniq
|
|
231
|
+
SearchIndex.bulk_index(Product.where(id: product_ids))
|
|
99
232
|
end
|
|
100
233
|
end
|
|
234
|
+
|
|
235
|
+
BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, product.id)
|
|
101
236
|
```
|
|
102
237
|
|
|
103
|
-
|
|
238
|
+
`max_calls` is the most calls one run takes, 100 if you don't set it. A full job still waits for its time, and the next call opens a new job. A plain `perform_later` that arrives first opens a job that runs right away, and the batch is as small as what got there in time. A retry runs all of the job's calls again, so `perform_calls` has to be idempotent. See [Collecting calls](#collecting-calls), [Collecting jobs and scheduling](#collecting-jobs-and-scheduling) and [Retries](#retries).
|
|
239
|
+
|
|
240
|
+
### Run a batch at N calls or T seconds
|
|
104
241
|
|
|
105
|
-
|
|
242
|
+
Use this when a batch should run as soon as it's full, and a quiet key still shouldn't wait forever.
|
|
106
243
|
|
|
107
|
-
|
|
244
|
+
```ruby
|
|
245
|
+
class BulkReindexProductsJob < ApplicationJob
|
|
246
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 100, on_full: :dispatch
|
|
247
|
+
|
|
248
|
+
# perform_calls as in the batch recipe
|
|
249
|
+
end
|
|
108
250
|
|
|
109
|
-
|
|
251
|
+
BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, product.id)
|
|
252
|
+
```
|
|
110
253
|
|
|
111
|
-
|
|
254
|
+
The `wait` is the T. A plain `perform_later` that arrives first runs right away, and so doesn't wait for N. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling) and the [timeline](#scheduling-timelines).
|
|
112
255
|
|
|
113
|
-
|
|
256
|
+
### Debounce a batch
|
|
114
257
|
|
|
115
|
-
|
|
258
|
+
Use this when a batch should run when it's quiet, when it's full or at the cap, whichever comes first.
|
|
116
259
|
|
|
117
260
|
```ruby
|
|
118
|
-
class
|
|
261
|
+
class BulkReindexProductsJob < ApplicationJob
|
|
262
|
+
coalesce key: ->(account_id, *) { account_id },
|
|
263
|
+
on_conflict: :collect,
|
|
264
|
+
max_calls: 100,
|
|
265
|
+
on_full: :dispatch,
|
|
266
|
+
debounce: 30.seconds,
|
|
267
|
+
max_wait: 5.minutes
|
|
268
|
+
|
|
269
|
+
# perform_calls as in the batch recipe
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
BulkReindexProductsJob.perform_later(product.account_id, product.id)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
See [Debouncing](#debouncing).
|
|
276
|
+
|
|
277
|
+
### Run a batch at midnight
|
|
278
|
+
|
|
279
|
+
Use this when calls collect through the day, and all of the work should happen overnight.
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
class SendDailyDigestJob < ApplicationJob
|
|
283
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 1000
|
|
284
|
+
|
|
285
|
+
def perform_calls(calls)
|
|
286
|
+
DigestMailer.daily(calls.first.arguments.first, calls.map { |call| call.arguments.last }).deliver_now
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
SendDailyDigestJob.set(wait_until: Date.tomorrow.midnight).perform_later(account.id, event.id)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The first call sets the time, and later calls join without moving it. Past `max_calls`, the next call opens another job for the same midnight, and the two can overlap. A call that arrives after midnight joins a job that hasn't started yet, and otherwise opens tomorrow's. Every caller needs the `wait_until`, because a plain `perform_later` that arrives first opens a job that runs right away. Payloads wait in the queue database all day, so pass IDs.
|
|
294
|
+
|
|
295
|
+
### Run one batch at a time
|
|
296
|
+
|
|
297
|
+
Use this when batches for a key shouldn't overlap, such as writes to one account's records.
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
class BulkReindexProductsJob < ApplicationJob
|
|
301
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 100
|
|
302
|
+
limits_concurrency to: 1, key: ->(account_id, *) { account_id }, on_conflict: :block, duration: 15.minutes
|
|
303
|
+
|
|
304
|
+
# perform_calls as in the batch recipe
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
BulkReindexProductsJob.perform_later(product.account_id, product.id)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The second batch blocks until the first finishes, and keeps collecting until it starts. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
311
|
+
|
|
312
|
+
### Resume a long batch
|
|
313
|
+
|
|
314
|
+
Use this when a batch can outlast a worker's shutdown, and repeating it is expensive, such as with a paid or rate-limited bulk API. It needs Rails 8.1.
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
class EmbedDocumentsJob < ApplicationJob
|
|
119
318
|
include ActiveJob::Continuable
|
|
120
319
|
|
|
121
|
-
coalesce key: ->(
|
|
320
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 5000
|
|
122
321
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
documents.sort_by(&:id).each do |document|
|
|
126
|
-
next if step.cursor && document.id <= step.cursor
|
|
322
|
+
def perform_calls(calls)
|
|
323
|
+
documents = Document.where(id: calls.map { |call| call.arguments.last })
|
|
127
324
|
|
|
128
|
-
|
|
129
|
-
|
|
325
|
+
step :embed do |step|
|
|
326
|
+
documents.in_batches(of: 100, start: step.cursor) do |batch|
|
|
327
|
+
Embeddings.index(batch)
|
|
328
|
+
step.advance! from: batch.maximum(:id)
|
|
130
329
|
end
|
|
131
330
|
end
|
|
132
331
|
end
|
|
133
332
|
end
|
|
333
|
+
|
|
334
|
+
EmbedDocumentsJob.set(wait: 5.minutes).perform_later(document.account_id, document.id)
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
A worker that's stopping interrupts the job at its next `advance!`. The job resumes with its calls and its cursor, and carries on after the last finished slice. Code outside `step` runs again, and so does a slice that hadn't reached `advance!`, so it still has to be idempotent. See [`ActiveJob::Continuation`](https://api.rubyonrails.org/classes/ActiveJob/Continuation.html) and [Retries](#retries).
|
|
338
|
+
|
|
339
|
+
### Run every call, one at a time
|
|
340
|
+
|
|
341
|
+
Use this when every call matters and calls for a key can't overlap, such as applying entries to one ledger. You don't need `coalesce` here.
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
class ApplyLedgerEntryJob < ApplicationJob
|
|
345
|
+
limits_concurrency to: 1, key: ->(account_id, *) { account_id }, on_conflict: :block, duration: 5.minutes
|
|
346
|
+
|
|
347
|
+
def perform(account_id, entry_id)
|
|
348
|
+
Ledger.apply(account_id, entry_id)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
ApplyLedgerEntryJob.perform_later(account.id, entry.id)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Each call creates its own job, and they wait their turn. See Solid Queue's [concurrency controls](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls).
|
|
356
|
+
|
|
357
|
+
## `coalesce` or `limits_concurrency`?
|
|
358
|
+
|
|
359
|
+
`coalesce` limits how many jobs wait. [`limits_concurrency`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls) limits how many run.
|
|
360
|
+
|
|
361
|
+
| You want | Use |
|
|
362
|
+
| --- | --- |
|
|
363
|
+
| One pending run per key | `coalesce` |
|
|
364
|
+
| One pending run per key, and no overlapping runs | `coalesce` with `limits_concurrency on_conflict: :block` |
|
|
365
|
+
| Every call to run, one at a time | `limits_concurrency on_conflict: :block` |
|
|
366
|
+
| To shed load above a limit, to dedupe across classes with `group`, or to drop calls during a run | `limits_concurrency on_conflict: :discard`, if losing that work is fine |
|
|
367
|
+
|
|
368
|
+
Prefer `coalesce` to `limits_concurrency on_conflict: :discard` for deduping. A concurrency discard can delete work you meant to keep, because it applies whenever a job reaches the semaphore:
|
|
369
|
+
|
|
370
|
+
- A retry is discarded if the semaphore is held when it dispatches. With `to: 1`, an immediate `retry_on` conflicts with its own running job, so the retry disappears and the job finishes with no failure recorded.
|
|
371
|
+
- `FailedExecution#retry` deletes the failed job if another job holds the semaphore.
|
|
372
|
+
- A call during a run is dropped, so a change made after the job read its data is never picked up.
|
|
373
|
+
- If a worker dies without releasing the semaphore, calls are dropped until `duration` passes.
|
|
374
|
+
- Limits apply when a job is due, so five calls with `set(wait: 5.minutes)` create five scheduled jobs.
|
|
375
|
+
|
|
376
|
+
With `coalesce`, a retry can't be discarded by the key, and a blocked retry waits for its turn. If you combine `coalesce` with concurrency `on_conflict: :discard` anyway, a retry that conflicts raises `SolidQueue::Job::EnqueueError` and the job fails with its work kept. `:collect` can't be combined with it at all.
|
|
377
|
+
|
|
378
|
+
## Things to know
|
|
379
|
+
|
|
380
|
+
- The first call wins. It sets the job's schedule, queue, priority and concurrency key, and later calls don't change them. With `debounce:`, later calls can move the schedule. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling) and [Debouncing](#debouncing).
|
|
381
|
+
- With `:collect`, a full job is sealed but still waits for its time, and the next call opens a new job. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling).
|
|
382
|
+
- Coalesced jobs can overlap, because the key is free as soon as a job starts. Use `limits_concurrency` to prevent that. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
383
|
+
- `ActiveJob.perform_all_later` can admit some calls and then raise, unless you wrap it in a queue-database transaction. See [Bulk enqueues](#bulk-enqueues).
|
|
384
|
+
- `successfully_enqueued?` doesn't tell you what happened to a call, and `ActiveJob.perform_all_later` skips enqueue callbacks. Read `coalesce_status`. See [Enqueue outcomes](#enqueue-outcomes).
|
|
385
|
+
- A transaction on your app's database doesn't cover an enqueue to a separate queue database. See [Transactions](#transactions).
|
|
386
|
+
|
|
387
|
+
## Compatibility
|
|
388
|
+
|
|
389
|
+
| Dependency | Supported |
|
|
390
|
+
| --- | --- |
|
|
391
|
+
| Ruby | 3.2 and newer |
|
|
392
|
+
| Rails | 7.1, 7.2, 8.0 and 8.1. CI also runs Rails main, except on Ruby 3.2 |
|
|
393
|
+
| Solid Queue | 1.7 |
|
|
394
|
+
| Database | MySQL, PostgreSQL and SQLite |
|
|
395
|
+
|
|
396
|
+
CI runs Ruby 3.2, 3.3, 3.4 and 4.0 against all three databases.
|
|
397
|
+
|
|
398
|
+
| Feature | Behaviour |
|
|
399
|
+
| --- | --- |
|
|
400
|
+
| `perform_later` | Coalesces, and stores the call with `:collect` |
|
|
401
|
+
| `perform_all_later` | Coalesces in sorted key order, with one transaction per discard admission or collecting job. See [Bulk enqueues](#bulk-enqueues) |
|
|
402
|
+
| `perform_now` | Runs the call on its own. `perform_calls` receives one call |
|
|
403
|
+
| `SolidQueue::Batch` | Coalesces within each batch. See [Solid Queue batches](#solid-queue-batches) |
|
|
404
|
+
| Recurring tasks | `:discard` works. `:collect` and `debounce:` make the task's configuration invalid |
|
|
405
|
+
| Retries | Keep the job's calls, and never coalesce. See [Retries](#retries) |
|
|
406
|
+
| Other adapters | Ignore `coalesce`, including `debounce:` and `max_wait:`. Each call runs on its own. See [Testing](#testing) |
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
## `coalesce` options
|
|
410
|
+
|
|
411
|
+
The default is `on_conflict: :discard`. A call that finds a pending job for its key is suppressed, and the pending job keeps its own queue, priority and arguments. It keeps its schedule too, unless the class uses [`debounce:`](#debouncing). The key is free again the moment that job starts, so a call that arrives during the run creates the next pending job.
|
|
412
|
+
|
|
413
|
+
The options are:
|
|
414
|
+
|
|
415
|
+
```ruby
|
|
416
|
+
coalesce key:, on_conflict: :discard, max_calls: nil, on_full: nil, debounce: nil, max_wait: nil
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
- `key` is required. See [Keys](#keys).
|
|
420
|
+
- `on_conflict` is `:discard` or `:collect`. Anything else raises `ArgumentError`.
|
|
421
|
+
- `max_calls` is only allowed with `:collect`. It's a positive integer, 100 by default.
|
|
422
|
+
- `on_full` is only allowed with `:collect`. It's `:wait`, the default, or `:dispatch`. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling).
|
|
423
|
+
- `debounce` is a positive duration. Each call pushes a pending job back to that long from now. See [Debouncing](#debouncing).
|
|
424
|
+
- `max_wait` is a duration greater than `debounce`, and needs `debounce`. It's optional. See [Debouncing](#debouncing).
|
|
425
|
+
|
|
426
|
+
An invalid option raises `ArgumentError` when the class loads.
|
|
427
|
+
|
|
428
|
+
## Keys
|
|
429
|
+
|
|
430
|
+
`key` takes the same forms as the `key` of [`limits_concurrency`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls), and is built the same way:
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
coalesce key: :global # Static job-wide key
|
|
434
|
+
coalesce key: "global" # Same literal component
|
|
435
|
+
coalesce key: ->(account_id) { account_id } # Distinct per account
|
|
436
|
+
coalesce key: ->(account) { account } # Active Record class and ID
|
|
437
|
+
coalesce key: ->(account_id, kind) { [ account_id, kind ] } # Composite key
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- A Symbol or String is a literal. `coalesce key: :account_id` doesn't read a parameter called `account_id`.
|
|
441
|
+
- A Proc receives the job's arguments.
|
|
442
|
+
- The result is joined into a string, so `1` and `"1"` give the same key.
|
|
443
|
+
- An array's items are joined with `/`, so `[ "a/b" ]` and `[ "a", "b" ]` give the same key.
|
|
444
|
+
- A Proc that returns `nil` gives the class-wide key, so all of those calls merge.
|
|
445
|
+
- Keys longer than 255 characters fail on MySQL.
|
|
446
|
+
|
|
447
|
+
Keys are always scoped to the job class name, and there's no `group` option, so two classes can't share a key. In a [Solid Queue batch](#solid-queue-batches) they're scoped to the batch too.
|
|
448
|
+
|
|
449
|
+
The key column is declared like the one on Solid Queue's semaphores, with the database's default collation and a unique index. That's upstream parity, and it isn't a guarantee of bytewise uniqueness. Under a case- and accent-insensitive collation such as MySQL's `utf8mb4_0900_ai_ci`, `Job/a`, `Job/A` and `Job/á` compare equal and coalesce together. Under a case-sensitive or binary collation they stay distinct.
|
|
450
|
+
|
|
451
|
+
## Collecting calls
|
|
452
|
+
|
|
453
|
+
With `on_conflict: :collect`, no call is suppressed. Every accepted call is stored, including the first, and belongs to one job from the moment it's enqueued. That job runs `perform_calls` with its calls, as in the [batch recipe](#batch-calls-together):
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
def perform_calls(calls)
|
|
457
|
+
account_id = calls.first.arguments.first
|
|
458
|
+
product_ids = calls.map { |call| call.arguments.last }.uniq
|
|
459
|
+
|
|
460
|
+
products = Product.where(account_id: account_id, id: product_ids).to_a
|
|
461
|
+
SearchIndex.bulk_index(products)
|
|
462
|
+
end
|
|
134
463
|
```
|
|
135
464
|
|
|
136
|
-
|
|
465
|
+
Pass IDs, and do one bounded load and one bulk operation per run. That's the reason to collect in the first place.
|
|
466
|
+
|
|
467
|
+
A collecting job always runs `perform_calls`, and ignores any `perform` it has. If `perform_calls` is missing, the run raises `NotImplementedError` and the job fails with its calls.
|
|
468
|
+
|
|
469
|
+
`calls` is an array of `ActiveJob::Coalescing::Call`, oldest first, with three readers:
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
call.id # The row's ID, nil for perform_now and other adapters
|
|
473
|
+
call.arguments # The deserialized positional array
|
|
474
|
+
call.job_data # The call's whole serialized Active Job payload
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`arguments` comes from the same deserialization as a normal run. Each call is rebuilt on its own instance of the job, from its payload, through `deserialize` and `deserialize_arguments`. So GlobalID, custom serializers and your own overrides of those methods all apply. Keyword arguments are a trailing hash with Ruby's keyword marker, so `some_method(*call.arguments)` passes them as keywords. A trailing hash that was passed positionally stays a plain hash, and stays positional.
|
|
478
|
+
|
|
479
|
+
If a call refers to a deleted record, that call is skipped for this attempt. Any other error while deserializing, including one from your own override, fails the job with its calls. See [Deleted records](#deleted-records).
|
|
480
|
+
|
|
481
|
+
`job_data` is the raw snapshot taken when the call was enqueued: `job_id`, `queue_name`, `priority`, `locale`, `timezone`, `enqueued_at`, `scheduled_at`, and whatever your `serialize` adds. It's the payload as stored, so it doesn't go through your `deserialize`, and arguments that `serialize` wrapped are still wrapped. It's frozen at the top level.
|
|
482
|
+
|
|
483
|
+
Those fields describe the request, not this run. `perform_calls` runs once, in the context of the job that owns the calls, which is its first call's. Nothing restores another call's locale, timezone or custom fields for you, so read them from `job_data` if they matter:
|
|
484
|
+
|
|
485
|
+
```ruby
|
|
486
|
+
def perform_calls(calls)
|
|
487
|
+
calls.group_by { |call| call.job_data["locale"] }.each do |locale, calls_in_locale|
|
|
488
|
+
I18n.with_locale(locale) { notify(calls_in_locale) }
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
137
492
|
|
|
138
|
-
|
|
493
|
+
`coalesced_calls` holds the same array, and perform callbacks can read it. `arguments` on the job itself keeps holding the arguments of its first call, which is what the `limits_concurrency` key sees.
|
|
139
494
|
|
|
140
|
-
|
|
495
|
+
## Collecting jobs and scheduling
|
|
141
496
|
|
|
142
|
-
A
|
|
497
|
+
A key has at most one open job, and any number of sealed ones. A call joins the open job, or opens a new one if there isn't one. The call that brings a job to `max_calls` seals it, and the next call opens a new job. With `max_calls: 100`, 250 calls make 3 jobs: 2 sealed with 100 calls each, and 1 open with 50. An open job also closes when it starts, so a call that arrives during the run opens a new job.
|
|
143
498
|
|
|
144
|
-
|
|
499
|
+
Here's a key with `on_conflict: :collect` and `max_calls: 2`. A start only releases the key if that job still holds it:
|
|
145
500
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
501
|
+
```text
|
|
502
|
+
event key held by job 1 job 2 job 3
|
|
503
|
+
call A job 1 open
|
|
504
|
+
call B, now full nobody sealed
|
|
505
|
+
call C job 2 sealed open
|
|
506
|
+
job 1 starts job 2 running open
|
|
507
|
+
job 2 starts nobody running running
|
|
508
|
+
call D job 3 running running open
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
By default, sealing doesn't dispatch anything. A full job still waits for its time, and it runs like any other job. `max_calls` is the most calls one run takes, not a limit on how many can wait. The one exception comes from lowering it: a job already over the new size gets sealed as it is, and its run takes all its calls.
|
|
512
|
+
|
|
513
|
+
With `on_full: :dispatch`, a job that fills doesn't wait for the rest of its delay, as in [this recipe](#run-a-batch-at-n-calls-or-t-seconds). With `.set(wait: 30.seconds)`:
|
|
514
|
+
|
|
515
|
+
A job that fills in 5 seconds becomes ready as the call that filled it commits, and one that doesn't fill runs at 30 seconds. The job's `scheduled_at` moves to that moment. Workers still have to be free, and if `limits_concurrency` has no room the job blocks until it does. This only matters for a delayed job, because any other job isn't waiting for its time.
|
|
516
|
+
|
|
517
|
+
Treat it as best effort. A full job keeps its original time if a discard is holding its scheduled execution when it fills, and then rolls back. On MySQL it also keeps it if the call runs in a transaction whose snapshot is older than the job. A job sealed without a new call, after lowering `max_calls`, keeps its time too.
|
|
518
|
+
|
|
519
|
+
`:dispatch` adds a lock on the scheduled execution to the call that fills a job. With `use_skip_locked` off, that call can deadlock with a discard of the same job. The call retries unless it's inside your own transaction, and a discard raises the error to its caller.
|
|
520
|
+
|
|
521
|
+
Each job takes its schedule, queue, priority, concurrency key and own arguments from its first call. Calls that join don't move it, even if they asked for a later time. So any caller that might arrive first needs the right `wait`: a plain `perform_later` opens a job that runs right away. [`debounce:`](#debouncing) changes both rules.
|
|
522
|
+
|
|
523
|
+
A key's jobs are separate, and this has consequences:
|
|
524
|
+
|
|
525
|
+
- They can start out of order. A later job with no wait can overtake an earlier delayed one.
|
|
526
|
+
- They can overlap. Use `limits_concurrency` if they shouldn't.
|
|
527
|
+
- Their concurrency keys, queues and priorities can differ, because each comes from that job's first call.
|
|
528
|
+
|
|
529
|
+
Enqueues for the same key take turns, because each one holds an exclusive lock on the key's row until its transaction commits. Inside your own transaction, that lock lasts until your commit, so a key used in a 50ms transaction takes about 20 calls per second, however many connections you add. Keep those transactions short, or enqueue after them.
|
|
530
|
+
|
|
531
|
+
To raise throughput on one key, use `ActiveJob.perform_all_later`. It stores up to a full job's calls under one lock. It keeps the order of each key's calls, and uses one transaction per job. [Bulk enqueues](#bulk-enqueues) covers what happens if it fails partway.
|
|
532
|
+
|
|
533
|
+
## Debouncing
|
|
534
|
+
|
|
535
|
+
By default a job takes its time from its first call, so during a burst it can run partway through and need another run straight after. With `debounce:`, each call pushes the pending job back, so it runs once calls have stopped. With [this recipe's](#debounce-until-activity-stops) `debounce: 30.seconds, max_wait: 5.minutes`:
|
|
536
|
+
|
|
537
|
+
A job comes due 30 seconds after the last call, and no later than 5 minutes after it opened. It works with `on_conflict: :discard` and `:collect`, and `coalesce_status` reports the same outcomes as before. It adds no table, column, timer or process: the job's `scheduled_at` holds the time, and the dispatcher fires it as usual.
|
|
538
|
+
|
|
539
|
+
A call's time comes from these rules, in this order:
|
|
540
|
+
|
|
541
|
+
1. `.set(wait:)` or `.set(wait_until:)` replaces the window for that call.
|
|
542
|
+
2. Otherwise, the call's time is now plus `debounce`. On a debounced class, a plain `perform_later` waits for the window instead of running right away.
|
|
543
|
+
3. `max_wait` caps both. A call that creates a job can't ask for more than `max_wait` from now, and a call that joins can't move the job past `max_wait` from when it opened. A longer `.set(wait:)` gets capped without raising.
|
|
544
|
+
|
|
545
|
+
`perform_all_later` follows the same rules. One admission moves a job once, to the latest time among its calls.
|
|
546
|
+
|
|
547
|
+
A job only moves later. A call with a shorter wait joins without pulling the job forward, so `.set(wait: 0.seconds)` on a job that's already waiting doesn't run it sooner. The same call opening a new job makes it ready straight away, and nothing moves it after that.
|
|
548
|
+
|
|
549
|
+
Only a scheduled job moves. A job that's ready, blocked or claimed stays as it is, and the call joins as usual. A concurrency-limited job takes its semaphore when it comes due, so a job that's sliding doesn't hold up others.
|
|
550
|
+
|
|
551
|
+
`max_wait` only sets when a job comes due. It doesn't promise when the job runs: workers have to be free, and `limits_concurrency` can block the job, because Solid Queue applies [concurrency limits to scheduled jobs](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#scheduled-jobs) when they're due. It's optional, but without it a key that keeps getting calls can delay its job indefinitely.
|
|
552
|
+
|
|
553
|
+
The clock starts when a job opens, and each open job has its own. A new batch after one fills gets a full `max_wait`, and so does a new job after one starts.
|
|
554
|
+
|
|
555
|
+
With `:collect`, a job can run when it's quiet, when it's full, or at the cap, whichever comes first:
|
|
556
|
+
|
|
557
|
+
- With `on_full: :dispatch`, the call that fills a job dispatches it. It doesn't move the job.
|
|
558
|
+
- With `on_full: :wait`, the call that fills a job moves it, and sealing it keeps that time. Nothing moves a sealed job.
|
|
559
|
+
|
|
560
|
+
Moving is best effort. A call skips a job whose scheduled execution is held by the dispatcher or a discard, so that job can run up to a window early. That's safe, because the key is held until the job starts, so the job still covers the call. A move also has to be more than 1 second later than the job's current time. That saves a hot key from writing on every call, and the dispatcher only polls every second by default.
|
|
561
|
+
|
|
562
|
+
A retry doesn't coalesce, so `debounce` and `max_wait` don't apply to it.
|
|
563
|
+
|
|
564
|
+
## Scheduling timelines
|
|
565
|
+
|
|
566
|
+
Three calls with the same key, A, B and C, arrive at 0, 10 and 20 seconds. `run` marks when the job comes due, and workers still have to be free. With `:discard`, one job takes the calls and runs with A's arguments. With `:collect`, its run gets the calls drawn on its line.
|
|
567
|
+
|
|
568
|
+
A fixed wait, with `.set(wait: 30.seconds)`. The first call sets the time:
|
|
569
|
+
|
|
570
|
+
```text
|
|
571
|
+
seconds 0 10 20 30 40 50
|
|
572
|
+
calls A B C
|
|
573
|
+
job 1 A----B----C----run
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
A batch at N calls or T seconds, with `max_calls: 2`, `on_full: :dispatch` and `.set(wait: 30.seconds)`. B fills job 1, and C opens job 2 with its own wait:
|
|
577
|
+
|
|
578
|
+
```text
|
|
579
|
+
seconds 0 10 20 30 40 50
|
|
580
|
+
calls A B C
|
|
581
|
+
job 1 A----run
|
|
582
|
+
job 2 C--------------run
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
A debounce, with `debounce: 30.seconds`. Each call pushes the job back:
|
|
586
|
+
|
|
587
|
+
```text
|
|
588
|
+
seconds 0 10 20 30 40 50
|
|
589
|
+
calls A B C
|
|
590
|
+
job 1 A----B----C--------------run
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
A debounce with a limit, with `debounce: 30.seconds` and `max_wait: 40.seconds`. C asks for 50 seconds, and the cap holds it to 40:
|
|
594
|
+
|
|
595
|
+
```text
|
|
596
|
+
seconds 0 10 20 30 40 50
|
|
597
|
+
calls A B C
|
|
598
|
+
job 1 A----B----C---------run
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## Enqueue outcomes
|
|
602
|
+
|
|
603
|
+
`coalesce_status` on the Active Job instance says what happened to the call. It's read-only.
|
|
604
|
+
|
|
605
|
+
- `:created` means it created a pending job. With `:collect`, that's the first call of each job.
|
|
606
|
+
- `:discarded` means a pending job already held the key, and the call was suppressed.
|
|
607
|
+
- `:collected` means it was stored for the pending job.
|
|
608
|
+
- `nil` means this instance has no recorded admission.
|
|
609
|
+
|
|
610
|
+
So a call's data was stored if the status is `:created` or `:collected`, and it wasn't if it's `:discarded`:
|
|
611
|
+
|
|
612
|
+
```ruby
|
|
613
|
+
# on_conflict: :collect
|
|
614
|
+
call = BulkReindexProductsJob.perform_later(account_id, product_id)
|
|
615
|
+
call.coalesce_status # :created or :collected. Either way, perform_calls gets this call
|
|
616
|
+
|
|
617
|
+
# on_conflict: :discard, the default
|
|
618
|
+
call = ReindexProductJob.perform_later(product_id)
|
|
619
|
+
call.coalesce_status # :created, so a job runs with these arguments
|
|
620
|
+
# :discarded, so the pending job runs with its own, and these are gone
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
`nil` isn't a failure on its own. It's also what you read before an enqueue, on a class that doesn't coalesce, and on a worker's instance, including inside `perform` and `perform_calls` and on retries, because the status isn't serialized. The table has the rest:
|
|
624
|
+
|
|
625
|
+
| Situation | `coalesce_status` | `provider_job_id` | `successfully_enqueued?` | Call's data stored |
|
|
626
|
+
| --- | --- | --- | --- | --- |
|
|
627
|
+
| Created a job | `:created` | The new job | `true` | Yes |
|
|
628
|
+
| Joined with `:collect` | `:collected` | The open job | `true` | Yes |
|
|
629
|
+
| Joined with `:discard`, single | `:discarded` | The open job | `true`, set by Active Job | No |
|
|
630
|
+
| Joined with `:discard`, bulk | `:discarded` | The open job | `false` | No |
|
|
631
|
+
| Concurrency conflict destroyed the job, single | `nil` | `nil` | `true`, set by Active Job | No |
|
|
632
|
+
| Concurrency conflict destroyed the job, bulk | `nil` | `nil` | `false` | No |
|
|
633
|
+
| `before_enqueue` abort | `nil` | `nil` | `false` | No |
|
|
634
|
+
| Deferred, before the commit | `nil` | `nil` | `true` | Not yet |
|
|
635
|
+
| Failed enqueue, fresh instance | `nil` | `nil` | `false` | No |
|
|
636
|
+
| Failed enqueue or abort, reused instance | Previous value | Previous value | `false` | No |
|
|
637
|
+
| Rollback of your own transaction | Stale | Stale | Unchanged | No |
|
|
638
|
+
| Another adapter | `nil` | The adapter's ID, if it sets one | `true` | The adapter has the job |
|
|
639
|
+
| `perform_now` on a new instance | `nil` | `nil` | `false` | Not enqueued |
|
|
640
|
+
| A worker's instance | `nil` | The current run's job | `false`, and `true` on what `retry_job` returns | Calls move with a retry |
|
|
641
|
+
|
|
642
|
+
- `false` here includes `nil`, which is what `successfully_enqueued?` returns until an enqueue has been tried.
|
|
643
|
+
- After a `before_enqueue` abort, `perform_later` returns `false`, as it does for any job. Pass a block to `perform_later` if you need the instance.
|
|
644
|
+
- A failed enqueue raises `SolidQueue::Job::EnqueueError`, so only an instance you built yourself can be read afterwards. Nothing resets a reused instance's status or `provider_job_id`.
|
|
645
|
+
- A deferred call that gets rolled back stays as it was before the commit, and is never stored. Rails 7.2 with `load_defaults 7.2` defers by default on Solid Queue, so there a rollback looks like this too, instead of leaving stale values.
|
|
646
|
+
- On Rails 7.2 and 8.0, `retry_job` enqueues a copy of the job, so the worker's instance doesn't change at all. Read the result from what `retry_job` returns.
|
|
647
|
+
|
|
648
|
+
Don't read outcomes from `successfully_enqueued?`. Active Job sets it to `true` after `perform_later` even when the call was discarded. It stays that way on purpose: `false` would make `perform_later` return `false`, and you'd lose the instance you read `coalesce_status` from.
|
|
649
|
+
|
|
650
|
+
`provider_job_id` is the job that held the key at admission. It's a snapshot. A retry moves collected calls to a new job with a new ID, and their call IDs stay the same, so the ID you read at enqueue can stop being the job that will run the call. A call that joined also has its own `job_id`, which differs from the `active_job_id` of the job that owns it. That job has the `active_job_id` of its first call.
|
|
651
|
+
|
|
652
|
+
`after_enqueue` can read `coalesce_status` for a single enqueue, including `:collected`. `ActiveJob.perform_all_later` skips enqueue callbacks, as it always does. It sets `coalesce_status` and `provider_job_id` on each job after that call's admission commits, and subscribers to [`coalesce.solid_queue`](#notifications) see every outcome.
|
|
653
|
+
|
|
654
|
+
## Bulk enqueues
|
|
655
|
+
|
|
656
|
+
```ruby
|
|
657
|
+
jobs = products.map { |product| ReindexProductJob.new(product.id) }
|
|
658
|
+
|
|
659
|
+
ActiveJob.perform_all_later(jobs)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
This uses fewer locks and transactions than one `perform_later` per call. Native Solid Queue stores a whole [`ActiveJob.perform_all_later`](https://api.rubyonrails.org/classes/ActiveJob.html#method-c-perform_all_later) in one transaction, so it's all-or-nothing. With coalesced jobs in the list it isn't, unless you ask for it. Outside your own transaction, it stores ordinary jobs first, in one native transaction. Coalesced calls follow, sorted by key, with one transaction per discard admission or collecting job. A key's lock lasts until its transaction commits, and one transaction for the whole call would hold every key's lock until the last job was in. This way it holds one key's lock for one job's calls.
|
|
663
|
+
|
|
664
|
+
Before it writes anything, the call works out the keys of all coalesced calls, the payloads of all `:collect` calls, and the payload of each key's first `:discard` call. If a key Proc raises or an argument can't be serialized, the call raises with nothing written. A repeated `:discard` call is never stored, so an argument on it that can't be serialized stays silently discarded, as with a single `perform_later`.
|
|
665
|
+
|
|
666
|
+
After that, only a database error or a `coalesce.solid_queue` subscriber that raises can fail the call. There's one exception. A job's concurrency key comes from its first call, and which call that is depends on the database, so a concurrency key Proc can raise on a later job's first call. In every case, earlier work stays committed and has its outcomes, and later calls have no outcome.
|
|
667
|
+
|
|
668
|
+
To recover outside a transaction, enqueue the calls that have no job ID again:
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
ActiveJob.perform_all_later(jobs.reject(&:provider_job_id))
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
This stores each call once. Calls destroyed by a `limits_concurrency` conflict with `on_conflict: :discard` have no ID either, so they get retried, which is harmless.
|
|
675
|
+
|
|
676
|
+
Trust outcomes only from a call that didn't raise, or for this recovery. A failed enqueue of an instance you've enqueued before can show its old outcome.
|
|
677
|
+
|
|
678
|
+
A database failure on coalesced calls raises `SolidQueue::Job::EnqueueError`. One on ordinary jobs raises the raw Active Record error, as native bulk does. A single call can raise either.
|
|
679
|
+
|
|
680
|
+
Only the order of each key's calls is guaranteed. Native bulk creates jobs in input order. Here, ordinary jobs come first, then coalesced calls sorted by key.
|
|
681
|
+
|
|
682
|
+
Discarded calls count as not enqueued, like the ones a native concurrency conflict discards, so Active Job logs "Failed enqueuing N jobs" for them. `ActiveJob.perform_all_later` returns `nil`. `SolidQueue::Job.enqueue_all` returns the count.
|
|
683
|
+
|
|
684
|
+
For all-or-nothing, wrap the call in a transaction on the queue database:
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
SolidQueue::Record.transaction do
|
|
688
|
+
ActiveJob.perform_all_later(jobs)
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
It has to be `SolidQueue::Record.transaction`, because an app-database transaction doesn't cover a separate queue database. After a rollback, outcomes stay set, as they do in native Solid Queue, and they mean nothing. Run the whole list again. `coalesce.solid_queue` has already fired by then too.
|
|
693
|
+
|
|
694
|
+
That transaction holds every key's lock and every semaphore's lock until it commits, so other enqueues and workers wait for it. Native bulk has the [same cost](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#performance-considerations) with ordinary concurrency-limited jobs. Enqueue those jobs one at a time or in small slices.
|
|
695
|
+
|
|
696
|
+
## Transactions
|
|
697
|
+
|
|
698
|
+
Read Solid Queue's [jobs and transactional integrity](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#jobs-and-transactional-integrity) and Active Job's [`enqueue_after_transaction_commit`](https://api.rubyonrails.org/classes/ActiveJob/Enqueuing.html#method-c-enqueue_after_transaction_commit) first. If Solid Queue has its own database, your app's transaction doesn't cover the enqueue, and coalescing makes that more likely to matter:
|
|
699
|
+
|
|
700
|
+
1. Your transaction changes a record, and calls `perform_later`.
|
|
701
|
+
2. The call finds a pending job for its key, and is discarded.
|
|
702
|
+
3. A worker starts that job before your transaction commits, and reads the old record.
|
|
703
|
+
4. Your transaction commits, and nothing runs again to pick up the change.
|
|
704
|
+
|
|
705
|
+
To avoid this, set `self.enqueue_after_transaction_commit = true` on the job, so the call waits for your commit and holds no lock on the key during your transaction. For this gem, that also means:
|
|
706
|
+
|
|
707
|
+
- On Rails 7.2, set it to `:always` instead of `true`. Rails 7.1 doesn't have this setting.
|
|
708
|
+
- `ActiveJob.perform_all_later` only waits for your commit from Rails 8.1.2. Before that, call it after your transaction.
|
|
709
|
+
- `coalesce_status` and `provider_job_id` are `nil` until the commit. From Rails 8.1, `after_enqueue` runs after the commit and can read them. Before that it runs too early, so subscribe to `coalesce.solid_queue` instead.
|
|
710
|
+
- A process that dies between your commit and the enqueue loses the call. With `:discard`, the next call for that key covers it, if there is one. With `:collect`, that call's data is gone. If it has to survive, store it in your app's database in the same transaction, and process it from there.
|
|
711
|
+
|
|
712
|
+
If Solid Queue shares your app's database, a `perform_later` inside your transaction commits or rolls back with it, so the job can't start before your commit, and none of this is needed.
|
|
713
|
+
|
|
714
|
+
## Retries
|
|
715
|
+
|
|
716
|
+
A retry is the same work continuing, not a new candidate for coalescing. It can't be suppressed by a pending job, and later calls can't join it. A job keeps its calls through `retry_on`, `rescue_from` with `retry_job`, an `ActiveJob::Continuable` resume, a manual retry of a failed job, and a re-run after its worker was released. If a retry can't be enqueued, the job fails and keeps its calls.
|
|
717
|
+
|
|
718
|
+
[Active Job handles retries](https://guides.rubyonrails.org/active_job_basics.html#retrying-or-discarding-failed-jobs), and Solid Queue keeps [failed jobs](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#failed-jobs-and-retries) until you retry or discard them. Delivery is at-least-once. There's no per-call acknowledgement, so a retry runs all of the job's calls again and `perform_calls` has to be idempotent. A `step` cursor can skip finished work, as in [Resume a long batch](#resume-a-long-batch), but `perform_calls` still receives every call.
|
|
719
|
+
|
|
720
|
+
## Deleted records
|
|
721
|
+
|
|
722
|
+
Each call is deserialized on its own. If a call refers to a deleted record, that call is skipped for this attempt and `drop_coalesced_call.solid_queue` is emitted, while the others run. If that happens to every call in the job, `perform_calls` and its callbacks are skipped and the job finishes normally. The job never falls back to its own arguments. Any other deserialization error fails the job with all of its calls, before any skipped-call events are emitted.
|
|
723
|
+
|
|
724
|
+
Every call row stays until its job is destroyed, including calls skipped because their records are gone. Retries and re-runs after a crash deserialize them all again. A record that's still gone is skipped and reported again, so these events count attempts, not unique calls. If the record becomes resolvable again, its call can run on a later attempt. With `preserve_finished_jobs`, skipped payloads stay with the finished job until it's cleared. See [Discarding and retention](#discarding-and-retention).
|
|
725
|
+
|
|
726
|
+
## Recurring jobs and concurrency limits
|
|
727
|
+
|
|
728
|
+
A [recurring task](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#recurring-tasks) can use `:discard`. A tick that finds a pending job is suppressed and not recorded in `solid_queue_recurring_executions`. A recurring task can't use `:collect` or `debounce:`: its configuration is invalid. A tick passes no time, so it would get the window, and an interval shorter than the window could stop the job from ever running.
|
|
729
|
+
|
|
730
|
+
To keep one follow-up, coalesce pending work and block on the limit, as in the [leading edge](#run-on-the-leading-edge) and [recurring](#coalesce-a-recurring-task) recipes. To drop busy ticks with no follow-up, use `limits_concurrency on_conflict: :discard` alone, after reading [`coalesce` or `limits_concurrency`?](#coalesce-or-limits_concurrency).
|
|
731
|
+
|
|
732
|
+
Here's how that takes calls. `.` is a blocked job and `=` is a running one:
|
|
733
|
+
|
|
734
|
+
```text
|
|
735
|
+
job 1 A ====run====|
|
|
736
|
+
job 2 B .. C .|====run====|
|
|
737
|
+
job 3 D .....|====run====|
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
A runs right away. B arrives during that run and opens job 2, which blocks on the limit and holds the key, so C gets discarded into it. Job 2 starts when job 1 finishes, and that frees the key, so D opens job 3.
|
|
741
|
+
|
|
742
|
+
`:global` is a literal in both APIs, and coalescing and concurrency keep separate database records. A semaphore's [`duration`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#concurrency-controls) can expire, so a concurrency limit isn't an absolute guarantee against overlap.
|
|
743
|
+
|
|
744
|
+
The concurrency key of a coalesced job is the one computed for the call that created it, and a blocked job still holds its coalesce key, since it hasn't started. `:collect` works with `on_conflict: :block`. It can't be combined with `limits_concurrency` with `on_conflict: :discard`, in either order, because a conflict would destroy a job holding calls, so that raises `ArgumentError` when the class loads. `:discard` can, and the destroyed job frees the key.
|
|
745
|
+
|
|
746
|
+
## Solid Queue batches
|
|
747
|
+
|
|
748
|
+
A coalesced job can be part of a [`SolidQueue::Batch`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#batch-jobs), as in [this recipe](#dedupe-jobs-in-a-solid-queue-batch). Batches need Solid Queue's own optional migration, and nothing more from this gem.
|
|
749
|
+
|
|
750
|
+
- Calls with the same key share one job, and that job belongs to that batch alone. The same key in another batch, or outside a batch, runs separately. `limits_concurrency` isn't scoped to a batch, so it still stops those jobs overlapping.
|
|
751
|
+
- `total_jobs` counts jobs, not calls. A call that joins a job adds nothing, and the job after a `:collect` job seals counts as another.
|
|
752
|
+
- A batched key starts with its batch, as in `SolidQueue::Batch/7/ReindexProductJob/42`. `coalesce.solid_queue` reports it that way, and the prefix counts towards MySQL's 255 characters.
|
|
753
|
+
- A running job adds to its batch with `batch.enqueue`. Without that a call isn't in the batch, because Solid Queue doesn't set a current batch while a job runs. Adding from anywhere else races the batch finishing, and raises `SolidQueue::Batch::AlreadyFinished` if it lost.
|
|
754
|
+
- `batch.enqueue` is a transaction, so a block that raises rolls back everything in it. A deadlock isn't retried inside it. It raises `SolidQueue::Job::EnqueueError` and rolls back the block, as in [your own transaction](#rules-and-limitations). A block that opens several keys holds the batch's row while it waits for the next key, so concurrent blocks on one batch can deadlock. If jobs add to a batch at the same time, use one `batch.enqueue` per call, as in the recipe.
|
|
755
|
+
- With `enqueue_after_transaction_commit`, which `load_defaults 8.2` turns on, calls wait until the block commits. A failure then can't roll the block back, and a deferred `perform_all_later` only keeps the batch for jobs built inside the block. Solid Queue's ordinary jobs do the same.
|
|
756
|
+
- Bulk discards delete jobs without callbacks, so a batch left with no jobs finishes when Solid Queue's batch maintenance next runs.
|
|
757
|
+
- A callback job isn't part of its batch, so a coalesced one uses the key it would use outside a batch.
|
|
758
|
+
|
|
759
|
+
## Discarding and retention
|
|
760
|
+
|
|
761
|
+
Calls belong to their job, and are deleted with it. Discarding a pending job deletes it and its calls, and frees the key if the job was open. Other jobs for the key aren't affected. If [`preserve_finished_jobs`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#other-configuration-settings) is on, a job's calls stay until the job is cleared, along with the finished job itself.
|
|
762
|
+
|
|
763
|
+
A job that fails before starting, for example because its worker died or because its class no longer exists, keeps its calls as if it had started. When you bring the class back and retry the job, it runs those same calls.
|
|
764
|
+
|
|
765
|
+
## Changing a job's configuration
|
|
766
|
+
|
|
767
|
+
A job that gets a key records how it was created in its own payload, as `"coalesce_on_conflict": "collect"` or `"discard"`, so a worker knows even if the class has changed since. If you remove `:collect` or `coalesce` while calls are waiting, each of those jobs raises `ActiveJob::Coalescing::CollectedJobMismatch` when it runs. Nothing has run at that point, `retry_on` and `discard_on` don't see the error, and the job fails with all of its calls. Restore the declaration and retry the failed job to run them, or discard the job to delete them.
|
|
768
|
+
|
|
769
|
+
That's a way to recover, not a way to deploy. To remove `:collect` or `coalesce` without failures, stop enqueuing, wait for `SolidQueue::CoalescedCall.waiting` to be empty, and only then deploy the change. Workers on the old and new code can overlap in a rolling deploy, so a job can fail on one of them and need the same recovery.
|
|
770
|
+
|
|
771
|
+
| Change while calls wait | What happens |
|
|
772
|
+
| --- | --- |
|
|
773
|
+
| Remove `:collect` or `coalesce` | The job fails with its calls, as above |
|
|
774
|
+
| Switch `:collect` to `:discard` | The job fails with its calls, as above. The next call doesn't count on that job, and creates a new one |
|
|
775
|
+
| Switch `:discard` to `:collect` | The next call doesn't join the discarding job, and creates a new one. The old job runs one call from its own arguments |
|
|
776
|
+
| Change `limits_concurrency` to `on_conflict: :discard` after removing `:collect` | A conflict blocks the job instead of destroying it, so it reaches a worker and fails with its calls |
|
|
777
|
+
| Change the queue adapter | Waiting jobs still run their calls. A retry of one raises `CollectedJobMismatch`, and the job fails with its calls |
|
|
778
|
+
| Remove `perform_calls` | The run raises `NotImplementedError`, and the job fails with its calls, unless a handler such as `discard_on Exception` takes it |
|
|
779
|
+
| Rename or remove the class | The job fails with its calls |
|
|
780
|
+
| Change `max_calls` | Waiting calls aren't touched. An open job that's now over the new size gets sealed by the next call |
|
|
781
|
+
| Change `on_full` | The class that enqueues the call that fills a job decides, so there's nothing to drain. Jobs already sealed keep their time |
|
|
782
|
+
| Change `debounce` or `max_wait` | The class that enqueues each call decides, so there's nothing to drain. A job that has moved keeps its time |
|
|
783
|
+
| Change the key | Waiting jobs still run their calls. New calls open a job under the new key |
|
|
784
|
+
|
|
785
|
+
Solid Queue only deletes calls along with their job. Deleting call rows yourself isn't supported, and neither is changing `calls_count`. If you do, a collecting job with no calls left raises `CollectedJobMismatch` instead of running its own arguments, which are a copy of its first call. A job that lost only some of its calls runs the rest, and nothing reports the loss.
|
|
786
|
+
|
|
787
|
+
Jobs enqueued by version 0.1 don't have this record, so drain them before you upgrade. See [UPGRADING.md](UPGRADING.md).
|
|
788
|
+
|
|
789
|
+
## Rules and limitations
|
|
790
|
+
|
|
791
|
+
- Calling `perform_later` inside a transaction on the same database as Solid Queue doesn't abort the transaction when the call finds a pending job. 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`. On any database with row locks, the same can happen inside your own transaction to a call that takes over a key from a collecting job, after its class switched to `:discard`.
|
|
792
|
+
- Solid Queue's bulk discards, such as `discard_all_in_batches`, are retried on a deadlock like enqueues are, so the same limit applies inside your own transaction.
|
|
150
793
|
- 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
794
|
|
|
155
|
-
|
|
795
|
+
The invariants behind these rules, and the costs they add, are in [the design notes](https://github.com/dtcristo/solid_queue-coalesce/blob/main/docs/design.md).
|
|
796
|
+
|
|
797
|
+
## Monitoring
|
|
798
|
+
|
|
799
|
+
Queue depth counts jobs, not calls, because a job can hold up to `max_calls` of them. To see the backlog, look at the calls instead:
|
|
800
|
+
|
|
801
|
+
```ruby
|
|
802
|
+
SolidQueue::CoalescedCall.waiting.count
|
|
803
|
+
SolidQueue::CoalescedCall.waiting.minimum(:created_at) # nil when nothing is waiting
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
`waiting` is worked out from the job that owns each call: it hasn't finished, isn't running and hasn't failed. So it includes the calls of scheduled and blocked jobs, and a job that's waiting to retry counts again, with all of its calls. A preserved job's calls stop counting once it finishes, though they stay in the table until it's cleared.
|
|
807
|
+
|
|
808
|
+
If you alert on the age of the oldest call, allow for the wait you expect, because scheduled calls sit there until their time comes. `waiting.minimum` reads a row for every waiting call, so it gets slower as the backlog grows: about 1 second at 1,000,000 waiting calls on PostgreSQL, against about 90 ms for `waiting.count`. See [the benchmarks](https://github.com/dtcristo/solid_queue-coalesce/blob/main/test/benchmarks/README.md). Run these from one process, once a minute, not per request or per job.
|
|
809
|
+
|
|
810
|
+
Failed collected jobs and the calls they hold are separate numbers. A failed job keeps all of its calls, so the second one shows how much work is stuck:
|
|
811
|
+
|
|
812
|
+
```ruby
|
|
813
|
+
SolidQueue::FailedExecution.where(job_id: SolidQueue::CoalescedCall.select(:job_id)).count
|
|
814
|
+
SolidQueue::CoalescedCall.where(job_id: SolidQueue::FailedExecution.select(:job_id)).count
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
### Notifications
|
|
818
|
+
|
|
819
|
+
Two notifications exist:
|
|
820
|
+
|
|
821
|
+
| Event | When | Payload |
|
|
822
|
+
| --- | --- | --- |
|
|
823
|
+
| `coalesce.solid_queue` | A call was admitted, including the one that created the job | `status` (`:created`, `:discarded`, `:collected`), `key`, `job_id`, `active_job_id`, `class_name` |
|
|
824
|
+
| `drop_coalesced_call.solid_queue` | A collected call was skipped on this attempt because its record is gone | `call_id`, `job_id`, `active_job_id`, `class_name`, `exception_class` |
|
|
825
|
+
|
|
826
|
+
`SolidQueue::LogSubscriber` logs the first at debug level and the second at warn level. Neither logs arguments or payloads, and neither logs the key, which your app defines. The key is only in the event's payload.
|
|
827
|
+
|
|
828
|
+
`coalesce.solid_queue` fires right after a call's admission. Outside a transaction on the queue database, that's after the commit, and the outcome is final. Inside one, it's before the commit, so the outcome is provisional: a rollback can undo what it reported, and the event stays fired. No event fires for a retry, for a worker starting a job, or for a call that a `limits_concurrency` conflict destroyed.
|
|
829
|
+
|
|
830
|
+
To count only what committed, register the count on the queue database's transaction. It runs at the outermost commit, straight away if there's no transaction, and never after a rollback, including a rolled-back savepoint. A transaction on your app's own database doesn't affect it, because the queue's write doesn't depend on that one.
|
|
831
|
+
|
|
832
|
+
A job that [waits for your commit](#transactions) has no admission until then, so a rollback drops the call and no event fires.
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
ActiveSupport::Notifications.subscribe("coalesce.solid_queue") do |*, payload|
|
|
836
|
+
SolidQueue::Record.current_transaction.after_commit do
|
|
837
|
+
Metrics.increment("solid_queue.coalesce", tags: payload.slice(:class_name, :status))
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
`current_transaction` needs Rails 7.2. On Rails 7.1 this subscriber raises `NoMethodError`, which fails the enqueue, so count from the event directly there.
|
|
843
|
+
|
|
844
|
+
The payloads' IDs last for different lengths of time:
|
|
845
|
+
|
|
846
|
+
- `class_name` and `status` don't change, and have few values. They're the only ones to use as metric labels.
|
|
847
|
+
- `active_job_id` is the call's own on `coalesce.solid_queue`, and the owning job's on `drop_coalesced_call.solid_queue`. The owning job has the `active_job_id` of its first call, across retries.
|
|
848
|
+
- `job_id` is the job at that moment. A retry moves collected calls to a job with a new ID.
|
|
849
|
+
- `call_id` exists only once a worker reads the calls, because admission inserts them in bulk. A `coalesce.solid_queue` event can't be matched to a later `call_id`.
|
|
850
|
+
|
|
851
|
+
### Metrics
|
|
156
852
|
|
|
157
|
-
|
|
853
|
+
Call counts come from Active Job's own event. `coalesced_calls` is already loaded by then, so reading it adds no query. It's `nil` for a job that doesn't collect, and empty when every record is gone. Each attempt reports its calls, so a retry counts again. A run whose count reaches `coalesce_max_calls` filled up. No event marks a job being sealed, and one sealed because a configuration changed looks like any smaller job.
|
|
158
854
|
|
|
159
855
|
```ruby
|
|
160
|
-
|
|
161
|
-
|
|
856
|
+
# Metrics stands in for whatever client you use. Keep job IDs and keys out of the
|
|
857
|
+
# labels, because there are too many of them, and because your app defines the keys.
|
|
858
|
+
ActiveSupport::Notifications.subscribe("drop_coalesced_call.solid_queue") do |*, payload|
|
|
859
|
+
Metrics.increment("solid_queue.coalesce.skipped", tags: payload.slice(:class_name))
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
ActiveSupport::Notifications.subscribe("perform.active_job") do |*, payload|
|
|
863
|
+
if calls = payload[:job].coalesced_calls
|
|
864
|
+
Metrics.histogram("solid_queue.coalesce.calls", calls.size, tags: { class_name: payload[:job].class.name })
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# Once a minute, from one process
|
|
869
|
+
waiting = SolidQueue::CoalescedCall.waiting
|
|
870
|
+
Metrics.gauge("solid_queue.coalesce.waiting", waiting.count)
|
|
871
|
+
Metrics.gauge("solid_queue.coalesce.oldest_age", Time.current - (waiting.minimum(:created_at) || Time.current))
|
|
872
|
+
Metrics.gauge("solid_queue.coalesce.failed_jobs", SolidQueue::FailedExecution.where(job_id: SolidQueue::CoalescedCall.select(:job_id)).count)
|
|
873
|
+
Metrics.gauge("solid_queue.coalesce.failed_calls", SolidQueue::CoalescedCall.where(job_id: SolidQueue::FailedExecution.select(:job_id)).count)
|
|
162
874
|
```
|
|
163
875
|
|
|
164
|
-
|
|
876
|
+
Count outcomes with the subscriber in [Notifications](#notifications).
|
|
877
|
+
|
|
878
|
+
## Testing
|
|
879
|
+
|
|
880
|
+
```ruby
|
|
881
|
+
class ReindexProductJobTest < ActiveJob::TestCase
|
|
882
|
+
test "enqueues a reindex" do
|
|
883
|
+
assert_enqueued_with(job: ReindexProductJob, args: [ 1 ]) do
|
|
884
|
+
ReindexProductJob.perform_later(1)
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
If your test environment sets `config.active_job.queue_adapter = :solid_queue`, Active Job's helpers use that adapter and this example raises, so set `:test` there. Test how calls coalesce against Solid Queue, because nothing merges here.
|
|
891
|
+
|
|
892
|
+
Everything above needs the Solid Queue adapter, so other adapters ignore `coalesce`. They ignore `debounce` and `max_wait` too, so a plain call runs right away, and an explicit `.set(wait:)` still applies. With the test adapter, the inline adapter or `perform_now`, each call runs on its own, and a collecting job's `perform_calls` receives one call whose `id` is `nil`. `assert_enqueued_with(args: [ product_id ])` still works, and the Solid Queue tables aren't touched.
|
|
165
893
|
|
|
166
|
-
|
|
894
|
+
That call builds `job_data` when you read it, so `perform_now` with arguments that can't be serialized only raises if you read it.
|
|
167
895
|
|
|
168
896
|
## License
|
|
169
897
|
|