solid_queue-coalesce 0.2.0 → 0.3.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 +21 -1
- data/README.md +215 -93
- data/UPGRADING.md +28 -1
- data/app/models/solid_queue/coalesced_call.rb +3 -3
- data/app/models/solid_queue/job/coalescible.rb +109 -25
- data/lib/active_job/coalescing.rb +25 -18
- data/lib/solid_queue/coalesce/patches.rb +39 -3
- data/lib/solid_queue/coalesce/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74e791e65276dd0da04b025f1d6e545958eda7f4c09e6ecc6e7babb1450c9af6
|
|
4
|
+
data.tar.gz: c3bb125145fe32176a8c68f38ca3abbf6dad27945b4784c96a5d841a84a6f234
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f018f79efb38543b7cd6f2cde3b728a9f1d03a7d8197af28371280f9ecf51e6adef3cb3e47ac37aaabb5cf86a6cdd8d1a824420e864177be6879f8f1950ca255
|
|
7
|
+
data.tar.gz: a931e752c3b9a6308b0f1637c2b88f36d5460140be05c3db2925acd00959b612a93352103d2418bc2c7ba52b68cfb8466d8f46749ba83d42fb841dfbc791d25c
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-11-06
|
|
10
|
+
|
|
11
|
+
Adds `on_conflict: :replace`, which runs a pending job with its latest call's arguments, and a recipe that combines it with `limits_concurrency` and [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit). Aligns key declarations and bulk enqueue errors with upstream Solid Queue, and prefixes the serialized coalescing marker with `solid_queue_`. Batch coalescing keys also use the shorter `Batch/` prefix. Upgrading takes work: drain coalesced jobs and affected batches, retry or discard failed jobs on the old version, and deploy all processes together; see [UPGRADING.md](https://github.com/dtcristo/solid_queue-coalesce/blob/main/UPGRADING.md).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `on_conflict: :replace` for `coalesce`. A call writes its arguments over the pending job's, which keeps its ID, queue, priority and place in line, and runs `perform` with the latest ones. It reports `:replaced`, works with `debounce:`, `max_wait:`, blocking concurrency limits and batches, and can't be used by a recurring task. ([#47](https://github.com/dtcristo/solid_queue-coalesce/pull/47))
|
|
16
|
+
- README recipe that syncs the latest state one at a time under an API quota, combining `coalesce` with `limits_concurrency` and [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit) 0.2, with a table of which control acts at each step. ([#53](https://github.com/dtcristo/solid_queue-coalesce/pull/53))
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- A replacement keeps the pending job's [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit) key, as it keeps its concurrency key, so the job is charged to the budget of the call that created it. ([#53](https://github.com/dtcristo/solid_queue-coalesce/pull/53))
|
|
21
|
+
- **Breaking:** Serialized job data uses `solid_queue_coalesce_on_conflict` instead of `coalesce_on_conflict`, with no fallback. Drain all coalesced work, including retrying or discarding failures, before deploying. ([#46](https://github.com/dtcristo/solid_queue-coalesce/pull/46))
|
|
22
|
+
- **Breaking:** `coalesce(key:)` accepts declarations without type or blank validation, like `limits_concurrency`. Unsupported truthy types produce a class-wide key, blank declarations coalesce, and `nil` or `false` disables coalescing. Other options retain their validation. ([#46](https://github.com/dtcristo/solid_queue-coalesce/pull/46))
|
|
23
|
+
- **Breaking:** Bulk enqueues propagate original Active Record errors for coalesced jobs, like native bulk enqueue. Single enqueues retain `SolidQueue::Job::EnqueueError`; notification subscriber errors remain unwrapped. ([#46](https://github.com/dtcristo/solid_queue-coalesce/pull/46))
|
|
24
|
+
- **Breaking:** Batched coalescing keys start with `Batch/<id>/` instead of `SolidQueue::Batch/<id>/`, including keys in `coalesce.solid_queue` notifications. Drain affected batches before upgrading so new calls don't create separate jobs under the new keys. ([#43](https://github.com/dtcristo/solid_queue-coalesce/pull/43))
|
|
25
|
+
- CI tests each Solid Queue 1.7 release and Solid Queue main, and runs each Ruby with one Rails instead of with every Rails. ([#54](https://github.com/dtcristo/solid_queue-coalesce/pull/54))
|
|
26
|
+
|
|
27
|
+
|
|
9
28
|
## [0.2.0] - 2026-11-02
|
|
10
29
|
|
|
11
30
|
Adds debouncing with `debounce:` and `max_wait:`, batches that run as soon as they fill with `on_full: :dispatch`, and support for [`SolidQueue::Batch`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#batch-jobs). Collecting jobs now use `perform_calls(calls)`, so upgrading from 0.1.0 needs code changes, a drain and a migration. See [UPGRADING.md](https://github.com/dtcristo/solid_queue-coalesce/blob/main/UPGRADING.md).
|
|
@@ -59,6 +78,7 @@ The first release lets many `perform_later` calls with the same key share one pe
|
|
|
59
78
|
- The `coalesce.solid_queue` and `drop_coalesced_argument.solid_queue` notifications. ([#1](https://github.com/dtcristo/solid_queue-coalesce/pull/1))
|
|
60
79
|
- Requires Solid Queue 1.7. ([#1](https://github.com/dtcristo/solid_queue-coalesce/pull/1))
|
|
61
80
|
|
|
62
|
-
[Unreleased]: https://github.com/dtcristo/solid_queue-coalesce/compare/v0.
|
|
81
|
+
[Unreleased]: https://github.com/dtcristo/solid_queue-coalesce/compare/v0.3.0...HEAD
|
|
82
|
+
[0.3.0]: https://github.com/dtcristo/solid_queue-coalesce/compare/v0.2.0...v0.3.0
|
|
63
83
|
[0.2.0]: https://github.com/dtcristo/solid_queue-coalesce/compare/v0.1.0...v0.2.0
|
|
64
84
|
[0.1.0]: https://github.com/dtcristo/solid_queue-coalesce/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,10 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>Solid Queue Coalesce</h1>
|
|
3
|
+
<p>
|
|
4
|
+
<strong>Dedupe, debounce and batch <a href="https://github.com/rails/solid_queue">Solid Queue</a> jobs</strong>
|
|
5
|
+
</p>
|
|
6
|
+
</div>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
Solid Queue Coalesce merges repeated `perform_later` calls with the same key into one job. You choose whether that job runs with the first call, the latest one, or all of them.
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
Dedupe when a repeat call adds nothing:
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
```ruby
|
|
13
|
+
class ReindexProductJob < ApplicationJob
|
|
14
|
+
coalesce key: ->(product_id) { product_id }
|
|
15
|
+
|
|
16
|
+
def perform(product_id)
|
|
17
|
+
Product.find(product_id).reindex!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ReindexProductJob.perform_later(product.id)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Replace when each call carries the whole current state, and only the latest matters:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class SyncContactJob < ApplicationJob
|
|
28
|
+
coalesce key: ->(user_id, *) { user_id }, on_conflict: :replace, debounce: 10.seconds
|
|
29
|
+
|
|
30
|
+
def perform(user_id, attributes)
|
|
31
|
+
Crm.update_contact(user_id, attributes)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
SyncContactJob.perform_later(user.id, user.slice(:name, :email))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Collect when every call carries data you need. The `wait` gives calls time to join:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class BulkReindexProductsJob < ApplicationJob
|
|
42
|
+
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect
|
|
43
|
+
|
|
44
|
+
def perform_calls(calls)
|
|
45
|
+
product_ids = calls.map { |call| call.arguments.last }.uniq
|
|
46
|
+
SearchIndex.bulk_index(Product.where(id: product_ids))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, product.id)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A job accepts calls until it starts. A collecting job also stops at `max_calls`, 100 by default. A call after that opens a new job, so no change gets missed, but two runs can overlap. Add [`limits_concurrency`](#coalesce-or-limits_concurrency) to stop that.
|
|
54
|
+
|
|
55
|
+
To limit how fast the coalesced jobs run, pair it with [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit). The [last recipe](#sync-the-latest-state-one-at-a-time-under-an-api-quota) uses both.
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
- [Dedupe](#dedupe-jobs-with-one-pending-job-per-key). Calls with the same key share one pending job, which runs with the first call's arguments.
|
|
60
|
+
- [Replace](#run-with-the-latest-arguments). One pending job runs `perform` with the latest call's arguments.
|
|
61
|
+
- [Collect](#collect-calls-into-one-run). One job receives every call's arguments, for bulk APIs and digests.
|
|
62
|
+
- [Debounce](#debounce-until-activity-stops). A job waits until calls stop, with an optional deadline.
|
|
63
|
+
- [Throttle](#throttle-to-one-run-per-window). A key runs at most once per time window.
|
|
64
|
+
- [Run on the leading edge](#run-on-the-leading-edge). The first call runs right away, and calls during that run merge into one follow-up.
|
|
65
|
+
|
|
66
|
+
It also works with [recurring tasks](#coalesce-a-recurring-task), [bulk enqueues](#bulk-enqueues) and [Solid Queue batches](#solid-queue-batches).
|
|
8
67
|
|
|
9
68
|
## Table of contents
|
|
10
69
|
|
|
@@ -15,6 +74,7 @@ It's a separate gem that adds to Solid Queue from the outside, with its own tabl
|
|
|
15
74
|
- [Compatibility](#compatibility)
|
|
16
75
|
- [`coalesce` options](#coalesce-options)
|
|
17
76
|
- [Keys](#keys)
|
|
77
|
+
- [Replacing arguments](#replacing-arguments)
|
|
18
78
|
- [Collecting calls](#collecting-calls)
|
|
19
79
|
- [Collecting jobs and scheduling](#collecting-jobs-and-scheduling)
|
|
20
80
|
- [Debouncing](#debouncing)
|
|
@@ -47,21 +107,7 @@ bin/rails generate solid_queue:coalesce:install
|
|
|
47
107
|
bin/rails db:migrate
|
|
48
108
|
```
|
|
49
109
|
|
|
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.
|
|
110
|
+
Add `coalesce` to a job, with a [key](#keys) that says which calls belong together, then pick a [recipe](#recipes).
|
|
65
111
|
|
|
66
112
|
### Where the migration goes
|
|
67
113
|
|
|
@@ -85,28 +131,35 @@ To upgrade from an earlier version, follow [UPGRADING.md](UPGRADING.md). [CHANGE
|
|
|
85
131
|
|
|
86
132
|
Start from what you want to happen to a burst of calls.
|
|
87
133
|
|
|
88
|
-
|
|
134
|
+
Dedupe, so one run gets the first call's arguments:
|
|
89
135
|
|
|
90
136
|
- [As soon as a worker is free](#dedupe-jobs-with-one-pending-job-per-key)
|
|
91
137
|
- [At most once per time window](#throttle-to-one-run-per-window)
|
|
92
138
|
- [Once calls stop, or by a deadline](#debounce-until-activity-stops)
|
|
93
139
|
- [Now, then once more after the run](#run-on-the-leading-edge)
|
|
94
140
|
- [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)
|
|
96
141
|
|
|
97
|
-
|
|
142
|
+
Replace, so one run gets the latest call's arguments:
|
|
143
|
+
|
|
144
|
+
- [Once calls stop](#run-with-the-latest-arguments)
|
|
98
145
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
- [
|
|
102
|
-
- [At
|
|
103
|
-
- [
|
|
104
|
-
- [
|
|
146
|
+
Collect, so one run gets every call's arguments:
|
|
147
|
+
|
|
148
|
+
- [After a fixed time](#collect-calls-into-one-run)
|
|
149
|
+
- [At N calls or T seconds](#collect-until-n-calls-or-t-seconds)
|
|
150
|
+
- [Once calls stop, or at N calls](#collect-until-calls-stop)
|
|
151
|
+
- [At midnight](#collect-calls-until-midnight)
|
|
152
|
+
- [One job at a time](#run-one-collecting-job-at-a-time)
|
|
153
|
+
- [Resuming after a deploy](#resume-a-long-collecting-job)
|
|
105
154
|
|
|
106
155
|
Run every call:
|
|
107
156
|
|
|
108
157
|
- [One at a time, without `coalesce`](#run-every-call-one-at-a-time)
|
|
109
158
|
|
|
159
|
+
Under an API's quota:
|
|
160
|
+
|
|
161
|
+
- [The latest state, one at a time for each key](#sync-the-latest-state-one-at-a-time-under-an-api-quota)
|
|
162
|
+
|
|
110
163
|
`Product`, `SearchIndex` and the other names in these recipes stand in for your own code.
|
|
111
164
|
|
|
112
165
|
### Dedupe jobs with one pending job per key
|
|
@@ -188,37 +241,27 @@ production:
|
|
|
188
241
|
schedule: every minute
|
|
189
242
|
```
|
|
190
243
|
|
|
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).
|
|
244
|
+
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 `:replace`, `:collect` or `debounce:`. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
192
245
|
|
|
193
|
-
###
|
|
246
|
+
### Run with the latest arguments
|
|
194
247
|
|
|
195
|
-
Use this when
|
|
248
|
+
Use this when each call carries the whole current state, such as a contact to sync, and older calls are out of date.
|
|
196
249
|
|
|
197
250
|
```ruby
|
|
198
|
-
class
|
|
199
|
-
coalesce key: ->(
|
|
251
|
+
class SyncContactJob < ApplicationJob
|
|
252
|
+
coalesce key: ->(user_id, *) { user_id }, on_conflict: :replace, debounce: 10.seconds
|
|
200
253
|
|
|
201
|
-
def perform(
|
|
202
|
-
|
|
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
|
|
254
|
+
def perform(user_id, attributes)
|
|
255
|
+
Crm.update_contact(user_id, attributes)
|
|
211
256
|
end
|
|
212
257
|
end
|
|
213
258
|
|
|
214
|
-
|
|
215
|
-
supplier_ids.each { |supplier_id| ImportSupplierJob.perform_later(supplier_id) }
|
|
216
|
-
end
|
|
259
|
+
SyncContactJob.perform_later(user.id, user.slice(:name, :email))
|
|
217
260
|
```
|
|
218
261
|
|
|
219
|
-
|
|
262
|
+
The job runs 10 seconds after the last call, with that call's arguments. Each call replaces the pending job's arguments, and the job keeps its ID, queue, priority and place in line. A call after the job starts opens the next one. If `perform` can read the current state from an ID, use the default `:discard` instead. See [Replacing arguments](#replacing-arguments).
|
|
220
263
|
|
|
221
|
-
###
|
|
264
|
+
### Collect calls into one run
|
|
222
265
|
|
|
223
266
|
Use this when every call carries data you need, and one run should process them.
|
|
224
267
|
|
|
@@ -235,17 +278,17 @@ end
|
|
|
235
278
|
BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, product.id)
|
|
236
279
|
```
|
|
237
280
|
|
|
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
|
|
281
|
+
`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 run only gets the calls that arrived 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
282
|
|
|
240
|
-
###
|
|
283
|
+
### Collect until N calls or T seconds
|
|
241
284
|
|
|
242
|
-
Use this when a
|
|
285
|
+
Use this when a collecting job should run as soon as it's full, and a quiet key still shouldn't wait forever.
|
|
243
286
|
|
|
244
287
|
```ruby
|
|
245
288
|
class BulkReindexProductsJob < ApplicationJob
|
|
246
289
|
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 100, on_full: :dispatch
|
|
247
290
|
|
|
248
|
-
# perform_calls as in the
|
|
291
|
+
# perform_calls as in the collect recipe
|
|
249
292
|
end
|
|
250
293
|
|
|
251
294
|
BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, product.id)
|
|
@@ -253,9 +296,9 @@ BulkReindexProductsJob.set(wait: 30.seconds).perform_later(product.account_id, p
|
|
|
253
296
|
|
|
254
297
|
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).
|
|
255
298
|
|
|
256
|
-
###
|
|
299
|
+
### Collect until calls stop
|
|
257
300
|
|
|
258
|
-
Use this when a
|
|
301
|
+
Use this when a collecting job should run when it's quiet, when it's full or at the cap, whichever comes first.
|
|
259
302
|
|
|
260
303
|
```ruby
|
|
261
304
|
class BulkReindexProductsJob < ApplicationJob
|
|
@@ -266,7 +309,7 @@ class BulkReindexProductsJob < ApplicationJob
|
|
|
266
309
|
debounce: 30.seconds,
|
|
267
310
|
max_wait: 5.minutes
|
|
268
311
|
|
|
269
|
-
# perform_calls as in the
|
|
312
|
+
# perform_calls as in the collect recipe
|
|
270
313
|
end
|
|
271
314
|
|
|
272
315
|
BulkReindexProductsJob.perform_later(product.account_id, product.id)
|
|
@@ -274,7 +317,7 @@ BulkReindexProductsJob.perform_later(product.account_id, product.id)
|
|
|
274
317
|
|
|
275
318
|
See [Debouncing](#debouncing).
|
|
276
319
|
|
|
277
|
-
###
|
|
320
|
+
### Collect calls until midnight
|
|
278
321
|
|
|
279
322
|
Use this when calls collect through the day, and all of the work should happen overnight.
|
|
280
323
|
|
|
@@ -292,26 +335,26 @@ SendDailyDigestJob.set(wait_until: Date.tomorrow.midnight).perform_later(account
|
|
|
292
335
|
|
|
293
336
|
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
337
|
|
|
295
|
-
### Run one
|
|
338
|
+
### Run one collecting job at a time
|
|
296
339
|
|
|
297
|
-
Use this when
|
|
340
|
+
Use this when a key's collecting jobs shouldn't overlap, such as writes to one account's records.
|
|
298
341
|
|
|
299
342
|
```ruby
|
|
300
343
|
class BulkReindexProductsJob < ApplicationJob
|
|
301
344
|
coalesce key: ->(account_id, *) { account_id }, on_conflict: :collect, max_calls: 100
|
|
302
345
|
limits_concurrency to: 1, key: ->(account_id, *) { account_id }, on_conflict: :block, duration: 15.minutes
|
|
303
346
|
|
|
304
|
-
# perform_calls as in the
|
|
347
|
+
# perform_calls as in the collect recipe
|
|
305
348
|
end
|
|
306
349
|
|
|
307
350
|
BulkReindexProductsJob.perform_later(product.account_id, product.id)
|
|
308
351
|
```
|
|
309
352
|
|
|
310
|
-
The second
|
|
353
|
+
The second job blocks until the first finishes, and keeps collecting until it starts. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
|
|
311
354
|
|
|
312
|
-
### Resume a long
|
|
355
|
+
### Resume a long collecting job
|
|
313
356
|
|
|
314
|
-
Use this when a
|
|
357
|
+
Use this when a collecting job 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
358
|
|
|
316
359
|
```ruby
|
|
317
360
|
class EmbedDocumentsJob < ApplicationJob
|
|
@@ -354,6 +397,37 @@ ApplyLedgerEntryJob.perform_later(account.id, entry.id)
|
|
|
354
397
|
|
|
355
398
|
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
399
|
|
|
400
|
+
### Sync the latest state one at a time, under an API quota
|
|
401
|
+
|
|
402
|
+
Use this when each call carries a contact's whole current state, two syncs for one contact mustn't overlap, and all syncs together have to stay under the CRM's quota. It needs [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit).
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
class SyncContactJob < ApplicationJob
|
|
406
|
+
# One pending and one running sync for each user, and at most 2 claims in any second across them
|
|
407
|
+
coalesce key: ->(user_id, *) { user_id }, on_conflict: :replace, debounce: 10.seconds
|
|
408
|
+
limits_concurrency key: ->(user_id, *) { user_id }
|
|
409
|
+
limits_rate to: 2, per: 1.second, burst: 2, group: "Crm", key: :global
|
|
410
|
+
|
|
411
|
+
def perform(user_id, attributes)
|
|
412
|
+
Crm.update_contact(user_id, attributes)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
SyncContactJob.perform_later(user.id, user.slice(:name, :email))
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Each control acts at a different step of the job's life:
|
|
420
|
+
|
|
421
|
+
| Step | What happens | Control |
|
|
422
|
+
| --- | --- | --- |
|
|
423
|
+
| `perform_later` | The call replaces the user's pending job's arguments, or creates the job. While the job is scheduled, the call can move it later, to 10 seconds after the call, but never earlier. The job keeps the concurrency and rate limit keys of the call that created it | `coalesce` |
|
|
424
|
+
| The job is due | It takes the user's concurrency slot, or is blocked until the running sync finishes | `limits_concurrency` |
|
|
425
|
+
| A worker claims it | The quota admits it, or it waits as a scheduled job and gives its concurrency slot back. When it's due again, it takes a slot or is blocked, and the next claim checks the quota again | `limits_rate` |
|
|
426
|
+
| `perform` starts | The job reads its latest arguments, and the key is free, so the next call creates the next pending job | `coalesce` |
|
|
427
|
+
| `perform` finishes | The concurrency slot is free, and the user's blocked job becomes ready | `limits_concurrency` |
|
|
428
|
+
|
|
429
|
+
A job that's blocked or waiting on the quota hasn't started, so calls keep replacing its arguments. The quota counts claims, not requests to the CRM, so make one request in each run. See Solid Queue Rate Limit's [recipes](https://github.com/dtcristo/solid_queue-rate_limit#recipes) for picking `burst:`, and its recipe that [batches calls under a quota](https://github.com/dtcristo/solid_queue-rate_limit#batch-calls-under-a-quota).
|
|
430
|
+
|
|
357
431
|
## `coalesce` or `limits_concurrency`?
|
|
358
432
|
|
|
359
433
|
`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.
|
|
@@ -377,31 +451,34 @@ With `coalesce`, a retry can't be discarded by the key, and a blocked retry wait
|
|
|
377
451
|
|
|
378
452
|
## Things to know
|
|
379
453
|
|
|
380
|
-
-
|
|
454
|
+
- 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.
|
|
455
|
+
- The first call wins. It sets the job's schedule, queue, priority, concurrency key and rate limit key, and later calls don't change them. With `debounce:`, later calls can move the schedule. With `:replace`, they replace the arguments. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling) and [Debouncing](#debouncing).
|
|
381
456
|
- 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
457
|
- 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
458
|
- `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
459
|
- `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
460
|
- A transaction on your app's database doesn't cover an enqueue to a separate queue database. See [Transactions](#transactions).
|
|
461
|
+
- It works with [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit), which checks its limit when a worker claims the job, after calls have coalesced at enqueue. See [Sync the latest state one at a time, under an API quota](#sync-the-latest-state-one-at-a-time-under-an-api-quota).
|
|
386
462
|
|
|
387
463
|
## Compatibility
|
|
388
464
|
|
|
389
465
|
| Dependency | Supported |
|
|
390
466
|
| --- | --- |
|
|
391
467
|
| Ruby | 3.2 and newer |
|
|
392
|
-
| Rails | 7.1, 7.2, 8.0 and 8.1. CI also runs Rails main
|
|
393
|
-
| Solid Queue | 1.7 |
|
|
468
|
+
| Rails | 7.1, 7.2, 8.0 and 8.1. CI also runs Rails main |
|
|
469
|
+
| Solid Queue | 1.7. CI runs 1.7.0, 1.7.1, 1.7.2 and main |
|
|
394
470
|
| Database | MySQL, PostgreSQL and SQLite |
|
|
471
|
+
| [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit) | Optional. CI tests 0.2 alongside |
|
|
395
472
|
|
|
396
|
-
CI runs Ruby 3.2, 3.3, 3.4 and 4.0
|
|
473
|
+
CI runs each Rails and Solid Queue version against all three databases. Ruby 3.2 runs with Rails 7.1, 3.3 with 7.2, 3.4 with 8.0, and 4.0 with the rest.
|
|
397
474
|
|
|
398
475
|
| Feature | Behaviour |
|
|
399
476
|
| --- | --- |
|
|
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) |
|
|
477
|
+
| `perform_later` | Coalesces, and stores the call with `:replace` or `:collect` |
|
|
478
|
+
| `perform_all_later` | Coalesces in sorted key order, with one transaction per discard admission, replacing key or collecting job. See [Bulk enqueues](#bulk-enqueues) |
|
|
402
479
|
| `perform_now` | Runs the call on its own. `perform_calls` receives one call |
|
|
403
480
|
| `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 |
|
|
481
|
+
| Recurring tasks | `:discard` works. `:replace`, `:collect` and `debounce:` make the task's configuration invalid |
|
|
405
482
|
| Retries | Keep the job's calls, and never coalesce. See [Retries](#retries) |
|
|
406
483
|
| Other adapters | Ignore `coalesce`, including `debounce:` and `max_wait:`. Each call runs on its own. See [Testing](#testing) |
|
|
407
484
|
|
|
@@ -417,20 +494,20 @@ coalesce key:, on_conflict: :discard, max_calls: nil, on_full: nil, debounce: ni
|
|
|
417
494
|
```
|
|
418
495
|
|
|
419
496
|
- `key` is required. See [Keys](#keys).
|
|
420
|
-
- `on_conflict` is `:discard` or `:collect`. Anything else raises `ArgumentError`.
|
|
497
|
+
- `on_conflict` is `:discard`, `:replace` or `:collect`. Anything else raises `ArgumentError`. See [Replacing arguments](#replacing-arguments) and [Collecting calls](#collecting-calls).
|
|
421
498
|
- `max_calls` is only allowed with `:collect`. It's a positive integer, 100 by default.
|
|
422
499
|
- `on_full` is only allowed with `:collect`. It's `:wait`, the default, or `:dispatch`. See [Collecting jobs and scheduling](#collecting-jobs-and-scheduling).
|
|
423
500
|
- `debounce` is a positive duration. Each call pushes a pending job back to that long from now. See [Debouncing](#debouncing).
|
|
424
501
|
- `max_wait` is a duration greater than `debounce`, and needs `debounce`. It's optional. See [Debouncing](#debouncing).
|
|
425
502
|
|
|
426
|
-
|
|
503
|
+
Except for `key:`, an invalid option raises `ArgumentError` when the class loads. The `key:` declaration accepts any value, like `limits_concurrency`.
|
|
427
504
|
|
|
428
505
|
## Keys
|
|
429
506
|
|
|
430
507
|
`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
508
|
|
|
432
509
|
```ruby
|
|
433
|
-
coalesce key: :global #
|
|
510
|
+
coalesce key: :global # Literal class-wide key: JobClass/global
|
|
434
511
|
coalesce key: "global" # Same literal component
|
|
435
512
|
coalesce key: ->(account_id) { account_id } # Distinct per account
|
|
436
513
|
coalesce key: ->(account) { account } # Active Record class and ID
|
|
@@ -441,16 +518,26 @@ coalesce key: ->(account_id, kind) { [ account_id, kind ] } # Composite key
|
|
|
441
518
|
- A Proc receives the job's arguments.
|
|
442
519
|
- The result is joined into a string, so `1` and `"1"` give the same key.
|
|
443
520
|
- 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.
|
|
521
|
+
- A Proc that returns `nil` gives the bare class-wide key, `JobClass`, so all of those calls merge.
|
|
522
|
+
- A declaration of `nil` or `false` disables coalescing, as it disables `limits_concurrency`, so `key: nil` isn't a class-wide key. For one key per class, use a literal such as `:global`, or a Proc that returns `nil`. Other unsupported declaration types, such as `key: 123`, give the class-wide key.
|
|
523
|
+
- Blank declarations still coalesce: `key: ""` gives `JobClass/`, and `key: []` or `key: {}` gives `JobClass`. These keys are scoped to the batch too.
|
|
445
524
|
- Keys longer than 255 characters fail on MySQL.
|
|
446
525
|
|
|
447
526
|
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
527
|
|
|
449
528
|
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
529
|
|
|
530
|
+
## Replacing arguments
|
|
531
|
+
|
|
532
|
+
With `on_conflict: :replace`, a call that finds a pending job writes its whole Active Job payload over the job's, and the job runs `perform` with it. Custom fields from your own `serialize` come from that call too, and ones it doesn't set are gone. Nothing older is kept, so there's no call table to fill and no `max_calls`. The job keeps its ID, `active_job_id`, queue, priority, batch, concurrency key, [Solid Queue Rate Limit](https://github.com/dtcristo/solid_queue-rate_limit) key and schedule, unless [`debounce:`](#debouncing) moves it. Calls with the same coalescing key should give the same concurrency and rate limit keys, because only the first call's are used.
|
|
533
|
+
|
|
534
|
+
The key is released when a worker starts the job, and the job's payload is read again after that, before Active Job deserializes it. So a replacement admitted before the start runs in that run, even if a worker had already claimed the job, and a call after it opens the next job. "Latest" means the last call admitted under the key's lock, not a timestamp or a revision of your data.
|
|
535
|
+
|
|
536
|
+
A replaced call reports `:replaced`, and its `provider_job_id` is the pending job. A retry runs the arguments of the run it retries, with its own progress and counters, and new calls never replace it.
|
|
537
|
+
|
|
451
538
|
## Collecting calls
|
|
452
539
|
|
|
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 [
|
|
540
|
+
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 [collect recipe](#collect-calls-into-one-run):
|
|
454
541
|
|
|
455
542
|
```ruby
|
|
456
543
|
def perform_calls(calls)
|
|
@@ -510,7 +597,7 @@ call D job 3 running running open
|
|
|
510
597
|
|
|
511
598
|
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
599
|
|
|
513
|
-
With `on_full: :dispatch`, a job that fills doesn't wait for the rest of its delay, as in [this recipe](#
|
|
600
|
+
With `on_full: :dispatch`, a job that fills doesn't wait for the rest of its delay, as in [this recipe](#collect-until-n-calls-or-t-seconds). With `.set(wait: 30.seconds)`:
|
|
514
601
|
|
|
515
602
|
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
603
|
|
|
@@ -534,7 +621,7 @@ To raise throughput on one key, use `ActiveJob.perform_all_later`. It stores up
|
|
|
534
621
|
|
|
535
622
|
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
623
|
|
|
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.
|
|
624
|
+
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`, `:replace` 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
625
|
|
|
539
626
|
A call's time comes from these rules, in this order:
|
|
540
627
|
|
|
@@ -550,7 +637,7 @@ Only a scheduled job moves. A job that's ready, blocked or claimed stays as it i
|
|
|
550
637
|
|
|
551
638
|
`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
639
|
|
|
553
|
-
The clock starts when a job opens, and each open job has its own. A new
|
|
640
|
+
The clock starts when a job opens, and each open job has its own. A new job after one fills gets a full `max_wait`, and so does a new job after one starts.
|
|
554
641
|
|
|
555
642
|
With `:collect`, a job can run when it's quiet, when it's full, or at the cap, whichever comes first:
|
|
556
643
|
|
|
@@ -573,7 +660,7 @@ calls A B C
|
|
|
573
660
|
job 1 A----B----C----run
|
|
574
661
|
```
|
|
575
662
|
|
|
576
|
-
|
|
663
|
+
Collecting until 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
664
|
|
|
578
665
|
```text
|
|
579
666
|
seconds 0 10 20 30 40 50
|
|
@@ -605,9 +692,10 @@ job 1 A----B----C---------run
|
|
|
605
692
|
- `:created` means it created a pending job. With `:collect`, that's the first call of each job.
|
|
606
693
|
- `:discarded` means a pending job already held the key, and the call was suppressed.
|
|
607
694
|
- `:collected` means it was stored for the pending job.
|
|
695
|
+
- `:replaced` means its arguments replaced the pending job's.
|
|
608
696
|
- `nil` means this instance has no recorded admission.
|
|
609
697
|
|
|
610
|
-
So a call's data was stored if the status is `:created` or `:collected`, and it wasn't if it's `:discarded
|
|
698
|
+
So a call's data was stored if the status is `:created`, `:replaced` or `:collected`, and it wasn't if it's `:discarded`. A replacement is a snapshot: a later call can replace it in turn.
|
|
611
699
|
|
|
612
700
|
```ruby
|
|
613
701
|
# on_conflict: :collect
|
|
@@ -626,6 +714,8 @@ call.coalesce_status # :created, so a job runs with these arguments
|
|
|
626
714
|
| --- | --- | --- | --- | --- |
|
|
627
715
|
| Created a job | `:created` | The new job | `true` | Yes |
|
|
628
716
|
| Joined with `:collect` | `:collected` | The open job | `true` | Yes |
|
|
717
|
+
| Joined with `:replace` | `:replaced` | The open job | `true` | Yes, until a later call replaces it |
|
|
718
|
+
| Superseded by a later `:replace` call in the same bulk enqueue | `:discarded` | The open job | `false` | No |
|
|
629
719
|
| Joined with `:discard`, single | `:discarded` | The open job | `true`, set by Active Job | No |
|
|
630
720
|
| Joined with `:discard`, bulk | `:discarded` | The open job | `false` | No |
|
|
631
721
|
| Concurrency conflict destroyed the job, single | `nil` | `nil` | `true`, set by Active Job | No |
|
|
@@ -641,7 +731,7 @@ call.coalesce_status # :created, so a job runs with these arguments
|
|
|
641
731
|
|
|
642
732
|
- `false` here includes `nil`, which is what `successfully_enqueued?` returns until an enqueue has been tried.
|
|
643
733
|
- 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
|
|
734
|
+
- A database failure during a single enqueue raises `SolidQueue::Job::EnqueueError`. Bulk enqueues raise the original Active Record error. Only an instance you built yourself can be read afterwards. Nothing resets a reused instance's status or `provider_job_id`.
|
|
645
735
|
- 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
736
|
- 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
737
|
|
|
@@ -659,9 +749,9 @@ jobs = products.map { |product| ReindexProductJob.new(product.id) }
|
|
|
659
749
|
ActiveJob.perform_all_later(jobs)
|
|
660
750
|
```
|
|
661
751
|
|
|
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.
|
|
752
|
+
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, replacing key 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
753
|
|
|
664
|
-
Before it writes anything, the call works out the keys of all coalesced calls, the payloads of all `:collect` calls,
|
|
754
|
+
Before it writes anything, the call works out the keys of all coalesced calls, the payloads of all `:collect` calls, the payload of each key's first `:discard` call, and the payload of each key's last `:replace` call. With `:replace`, only that last call is admitted. If it creates the job, the job's queue, priority and other fields come from it. The calls before it report `:discarded` and don't have to serialize, though they can still move a debounced job. 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
755
|
|
|
666
756
|
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
757
|
|
|
@@ -675,7 +765,7 @@ This stores each call once. Calls destroyed by a `limits_concurrency` conflict w
|
|
|
675
765
|
|
|
676
766
|
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
767
|
|
|
678
|
-
A database failure
|
|
768
|
+
A database failure raises the original Active Record error for both ordinary and coalesced jobs, as native bulk does. A single `perform_later` wraps database failures in `SolidQueue::Job::EnqueueError`. Errors from `coalesce.solid_queue` subscribers propagate unchanged for both APIs, including Active Record errors. A subscriber failure doesn't undo work that has already committed or clear its outcomes.
|
|
679
769
|
|
|
680
770
|
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
771
|
|
|
@@ -715,7 +805,7 @@ If Solid Queue shares your app's database, a `perform_later` inside your transac
|
|
|
715
805
|
|
|
716
806
|
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
807
|
|
|
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
|
|
808
|
+
[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 collecting job](#resume-a-long-collecting-job), but `perform_calls` still receives every call.
|
|
719
809
|
|
|
720
810
|
## Deleted records
|
|
721
811
|
|
|
@@ -725,7 +815,7 @@ Every call row stays until its job is destroyed, including calls skipped because
|
|
|
725
815
|
|
|
726
816
|
## Recurring jobs and concurrency limits
|
|
727
817
|
|
|
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.
|
|
818
|
+
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 `:replace`, `:collect` or `debounce:`: its configuration is invalid. A tick that joined with either of the first two would record the pending job again, and the unique index would roll back what it added. 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
819
|
|
|
730
820
|
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
821
|
|
|
@@ -741,17 +831,45 @@ A runs right away. B arrives during that run and opens job 2, which blocks on th
|
|
|
741
831
|
|
|
742
832
|
`: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
833
|
|
|
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`
|
|
834
|
+
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. `:replace` and `:collect` work with `on_conflict: :block`. They can't be combined with `limits_concurrency` with `on_conflict: :discard`, in either order, because a conflict would destroy a job holding replaced arguments or calls, so that raises `ArgumentError` when the class loads. `:discard` can, and the destroyed job frees the key.
|
|
745
835
|
|
|
746
836
|
## Solid Queue batches
|
|
747
837
|
|
|
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)
|
|
838
|
+
A coalesced job can be part of a [`SolidQueue::Batch`](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#batch-jobs). Batches need Solid Queue's own optional migration, and nothing more from this gem.
|
|
839
|
+
|
|
840
|
+
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.
|
|
841
|
+
|
|
842
|
+
```ruby
|
|
843
|
+
class ReindexProductJob < ApplicationJob
|
|
844
|
+
coalesce key: ->(product_id) { product_id }
|
|
845
|
+
|
|
846
|
+
def perform(product_id)
|
|
847
|
+
Product.find(product_id).reindex!
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
class ImportSupplierJob < ApplicationJob
|
|
852
|
+
def perform(supplier_id)
|
|
853
|
+
Supplier.find(supplier_id).import_products.each do |product_id|
|
|
854
|
+
batch.enqueue { ReindexProductJob.perform_later(product_id) }
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
SolidQueue::Batch.enqueue(on_success: PublishCatalogJob) do
|
|
860
|
+
supplier_ids.each { |supplier_id| ImportSupplierJob.perform_later(supplier_id) }
|
|
861
|
+
end
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
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.
|
|
865
|
+
|
|
866
|
+
A few rules apply:
|
|
749
867
|
|
|
750
868
|
- 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 `
|
|
869
|
+
- `total_jobs` counts jobs, not calls. A call that joins or replaces a job adds nothing, and the job after a `:collect` job seals counts as another.
|
|
870
|
+
- A batched key starts with its batch, as in `Batch/7/ReindexProductJob/42`. `coalesce.solid_queue` reports it that way, and the prefix counts towards MySQL's 255 characters.
|
|
753
871
|
- 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.
|
|
872
|
+
- `batch.enqueue` is a transaction, so a block that raises rolls back everything in it. A deadlock isn't retried inside it. A single enqueue raises `SolidQueue::Job::EnqueueError`, and a bulk enqueue raises `ActiveRecord::Deadlocked`; both roll 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 example.
|
|
755
873
|
- 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
874
|
- Bulk discards delete jobs without callbacks, so a batch left with no jobs finishes when Solid Queue's batch maintenance next runs.
|
|
757
875
|
- A callback job isn't part of its batch, so a coalesced one uses the key it would use outside a batch.
|
|
@@ -764,7 +882,7 @@ A job that fails before starting, for example because its worker died or because
|
|
|
764
882
|
|
|
765
883
|
## Changing a job's configuration
|
|
766
884
|
|
|
767
|
-
A job that gets a key records how it was created in its own payload, as `"
|
|
885
|
+
A job that gets a key records how it was created in its own payload, as `"solid_queue_coalesce_on_conflict": "discard"`, `"replace"` or `"collect"`, 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
886
|
|
|
769
887
|
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
888
|
|
|
@@ -773,6 +891,10 @@ That's a way to recover, not a way to deploy. To remove `:collect` or `coalesce`
|
|
|
773
891
|
| Remove `:collect` or `coalesce` | The job fails with its calls, as above |
|
|
774
892
|
| 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
893
|
| 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 |
|
|
894
|
+
| Switch `:discard` to `:replace` | The next call doesn't replace the discarding job's arguments, and creates a new one. The old job runs with its own arguments |
|
|
895
|
+
| Switch `:collect` to `:replace` | The job fails with its calls, as above. The next call doesn't count on that job, and creates a new one |
|
|
896
|
+
| Switch `:replace` to `:collect` | The next call doesn't join the replacing job, and creates a new one. The old job runs its latest arguments as one call |
|
|
897
|
+
| Switch `:replace` to `:discard`, or remove `coalesce` | Calls are discarded into the replacing job, or run on their own. It runs its latest arguments, and nothing needs draining |
|
|
776
898
|
| 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
899
|
| Change the queue adapter | Waiting jobs still run their calls. A retry of one raises `CollectedJobMismatch`, and the job fails with its calls |
|
|
778
900
|
| Remove `perform_calls` | The run raises `NotImplementedError`, and the job fails with its calls, unless a handler such as `discard_on Exception` takes it |
|
|
@@ -784,7 +906,7 @@ That's a way to recover, not a way to deploy. To remove `:collect` or `coalesce`
|
|
|
784
906
|
|
|
785
907
|
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
908
|
|
|
787
|
-
Jobs enqueued
|
|
909
|
+
Jobs enqueued before 0.3.0 don't have the prefixed marker, and the old name isn't read. Drain coalesced jobs before upgrading, including retrying or discarding failed ones on the old version. See [UPGRADING.md](UPGRADING.md).
|
|
788
910
|
|
|
789
911
|
## Rules and limitations
|
|
790
912
|
|
|
@@ -820,7 +942,7 @@ Two notifications exist:
|
|
|
820
942
|
|
|
821
943
|
| Event | When | Payload |
|
|
822
944
|
| --- | --- | --- |
|
|
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` |
|
|
945
|
+
| `coalesce.solid_queue` | A call was admitted, including the one that created the job | `status` (`:created`, `:discarded`, `:replaced`, `:collected`), `key`, `job_id`, `active_job_id`, `class_name` |
|
|
824
946
|
| `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
947
|
|
|
826
948
|
`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.
|
data/UPGRADING.md
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
|
-
# Upgrading
|
|
1
|
+
# Upgrading from version 0.2.0 to 0.3.0
|
|
2
|
+
|
|
3
|
+
The serialized job marker is now `solid_queue_coalesce_on_conflict`, and the old `coalesce_on_conflict` name is no longer read. Drain all coalesced work on the old version before deploying. No database migration or payload conversion is provided.
|
|
4
|
+
|
|
5
|
+
Batched coalescing keys also start with `Batch/<id>/` instead of `SolidQueue::Batch/<id>/`. New calls won't join pending jobs under the old keys and can create separate jobs for the same work, so affected batches must finish too.
|
|
6
|
+
|
|
7
|
+
1. Pause every producer of coalesced work, including application requests, recurring tasks and batch producers. Keep the old workers and dispatcher running to drain the queue.
|
|
8
|
+
2. Wait for all coalesced jobs to finish, including scheduled, ready, blocked, running and retrying jobs. Retry or discard failed jobs on the old version. Check every class that used `coalesce`, including ones whose declarations have since changed, with the query below.
|
|
9
|
+
3. Wait for affected batches to finish. To conservatively check all batches, wait until `SolidQueue::Batch.where(finished_at: nil).count` is zero.
|
|
10
|
+
4. Stop the old application and Solid Queue processes, deploy the new version to all producers, workers, dispatchers and schedulers together, then resume producers. Don't run both versions against coalesced work or affected batches.
|
|
11
|
+
|
|
12
|
+
For example, replace these names with all your coalesced job classes and wait until the result is zero. Failed jobs are included, so retry or discard them before continuing:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
SolidQueue::Job.where(finished_at: nil, class_name: %w[ ReindexDocumentsJob SendDigestJob ]).count
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Retrying an old failed job after deployment doesn't rewrite its payload and isn't a supported upgrade path. Preserved finished jobs need no drain, but replaying their old payloads after upgrading isn't supported either.
|
|
19
|
+
|
|
20
|
+
Update code that depends on these errors or key declarations:
|
|
21
|
+
|
|
22
|
+
- `ActiveJob.perform_all_later` now raises the original Active Record database error for coalesced calls, including `ActiveRecord::ValueTooLong` and `ActiveRecord::Deadlocked`. Update bulk error handlers that rescue `SolidQueue::Job::EnqueueError`. Single enqueues still wrap database errors in `SolidQueue::Job::EnqueueError`, and subscriber errors remain raw.
|
|
23
|
+
- `coalesce(key:)` no longer rejects unsupported or blank declarations. Unsupported truthy types, such as `key: 123`, give a class-wide key. Blank strings and empty arrays or hashes still coalesce. Only `nil` and `false` disable coalescing. Check declarations that previously relied on `ArgumentError`; all other option validations remain.
|
|
24
|
+
|
|
25
|
+
Update notification subscribers, log parsers or other consumers that match the old batch key prefix. `coalesce.solid_queue` now reports keys such as `Batch/7/ReindexProductJob/42`.
|
|
26
|
+
|
|
27
|
+
# Upgrading from version 0.1.0 to 0.2.0
|
|
28
|
+
|
|
2
29
|
This version replaces the gem's tables and the options of `coalesce`. There's no in-place migration for waiting calls, so drain them first. The generator skips an app that already has its migration, so this upgrade needs a migration of your own.
|
|
3
30
|
|
|
4
31
|
Stop enqueuing jobs that use `coalesce`, and on 0.1.0 wait until none of those jobs are left. This query includes scheduled, blocked, running and failed jobs, so retry or discard the failed ones:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SolidQueue
|
|
4
|
-
# The Active Job payload of one collected perform_later call. It
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# The Active Job payload of one collected perform_later call. It's kept from the
|
|
5
|
+
# moment it's admitted until the job that holds it is destroyed, and a retry moves
|
|
6
|
+
# it to the new job.
|
|
7
7
|
class CoalescedCall < Record
|
|
8
8
|
belongs_to :job
|
|
9
9
|
|
|
@@ -13,13 +13,28 @@ module SolidQueue
|
|
|
13
13
|
before_destroy :release_coalesce_key, prepend: true
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# What a replacement keeps from the job: what identifies it, its place in line,
|
|
17
|
+
# its batch, its marker, the semaphore Solid Queue recorded for it and the key
|
|
18
|
+
# solid_queue-rate_limit charges it to, so the first call's keys win. Solid Queue 1.7.2
|
|
19
|
+
# names the semaphore entry ConcurrencyControls::CONCURRENCY_LOCK_KEY, which 1.7.0 and 1.7.1 lack.
|
|
20
|
+
REPLACEMENT_KEPT_KEYS = %w[ job_class job_id queue_name priority enqueued_at scheduled_at batch_id callback_batch_id solid_queue_coalesce_on_conflict solid_queue_concurrency_lock solid_queue_rate_limit_key ]
|
|
21
|
+
|
|
16
22
|
class_methods do
|
|
17
23
|
# Every call in active_jobs has the same key. Returns the job if a call
|
|
18
|
-
# created one, nil if the calls joined the open job.
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
# created one, nil if the calls joined the open job. A replacing call's
|
|
25
|
+
# payloads are only its last call's.
|
|
26
|
+
def enqueue_coalesced(key, active_jobs, payloads = nil, wrap_errors: false)
|
|
27
|
+
if active_jobs.first.class.coalesce_collect?
|
|
28
|
+
payloads ||= raising_enqueue_errors(wrap_errors) { active_jobs.map(&:serialize) }
|
|
29
|
+
return collect_coalesced(key, active_jobs, payloads, wrap_errors: wrap_errors)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if active_jobs.first.class.coalesce_replace?
|
|
33
|
+
payloads ||= raising_enqueue_errors(wrap_errors) { [ active_jobs.last.serialize ] }
|
|
34
|
+
return replace_coalesced(key, active_jobs, payloads.last, wrap_errors: wrap_errors)
|
|
35
|
+
end
|
|
21
36
|
|
|
22
|
-
enqueued_job, open_job_id = raising_enqueue_errors do
|
|
37
|
+
enqueued_job, open_job_id = raising_enqueue_errors(wrap_errors) do
|
|
23
38
|
retrying_deadlocks { admit_discarding(key, active_jobs) }
|
|
24
39
|
end
|
|
25
40
|
|
|
@@ -31,11 +46,14 @@ module SolidQueue
|
|
|
31
46
|
# So a key or a payload that can't be built raises before anything is written.
|
|
32
47
|
# A repeated :discard call is never stored, so its arguments don't have to
|
|
33
48
|
# serialize, as with perform_later. The first one's only get checked here,
|
|
34
|
-
# because its job is built later, if there's no open job to find.
|
|
49
|
+
# because its job is built later, if there's no open job to find. Only the
|
|
50
|
+
# last :replace call is stored, so the ones it supersedes don't serialize either.
|
|
35
51
|
def prepare_all_coalesced(active_jobs)
|
|
36
52
|
active_jobs.group_by(&:coalesce_key).sort_by(&:first).map do |key, calls|
|
|
37
53
|
if calls.first.class.coalesce_collect?
|
|
38
54
|
[ key, calls, calls.map(&:serialize) ]
|
|
55
|
+
elsif calls.first.class.coalesce_replace?
|
|
56
|
+
[ key, calls, [ calls.last.serialize ] ]
|
|
39
57
|
else
|
|
40
58
|
calls.first.serialize
|
|
41
59
|
[ key, calls ]
|
|
@@ -96,9 +114,11 @@ module SolidQueue
|
|
|
96
114
|
enqueued_job
|
|
97
115
|
end
|
|
98
116
|
|
|
99
|
-
def raising_enqueue_errors
|
|
117
|
+
def raising_enqueue_errors(wrap_errors = true)
|
|
100
118
|
yield
|
|
101
119
|
rescue ActiveRecord::ActiveRecordError => e
|
|
120
|
+
raise unless wrap_errors
|
|
121
|
+
|
|
102
122
|
enqueue_error = EnqueueError.new("#{e.class.name}: #{e.message}").tap do |error|
|
|
103
123
|
error.set_backtrace e.backtrace
|
|
104
124
|
end
|
|
@@ -124,17 +144,19 @@ module SolidQueue
|
|
|
124
144
|
coalesce_key = CoalesceKey.admit(key)
|
|
125
145
|
|
|
126
146
|
unless coalesce_key.collecting?
|
|
127
|
-
|
|
147
|
+
if coalesce_key.job_id.nil?
|
|
148
|
+
attributes = attributes_from_active_job(active_jobs.first)
|
|
149
|
+
enqueued_job = create_for_coalesce_key(coalesce_key, **attributes, arguments: attributes[:arguments].merge("solid_queue_coalesce_on_conflict" => "discard"))
|
|
150
|
+
end
|
|
151
|
+
|
|
128
152
|
debounce_coalesced(coalesce_key, coalesce_key.job_id, enqueued_job ? active_jobs.drop(1) : active_jobs)
|
|
129
153
|
|
|
130
154
|
[ enqueued_job, coalesce_key.job_id ]
|
|
131
155
|
end
|
|
132
156
|
end
|
|
133
157
|
|
|
134
|
-
def create_for_coalesce_key(coalesce_key,
|
|
135
|
-
attributes
|
|
136
|
-
|
|
137
|
-
create!(**attributes, arguments: attributes[:arguments].merge("coalesce_on_conflict" => "discard")).tap do |job|
|
|
158
|
+
def create_for_coalesce_key(coalesce_key, **attributes)
|
|
159
|
+
create!(**attributes).tap do |job|
|
|
138
160
|
# limits_concurrency with on_conflict: :discard destroys the job right away
|
|
139
161
|
if job.persisted?
|
|
140
162
|
coalesce_key.update_columns(job_id: job.id)
|
|
@@ -149,13 +171,13 @@ module SolidQueue
|
|
|
149
171
|
# can't leave one behind. Serialized once and before any job exists, so the
|
|
150
172
|
# first call's payload is like every other call's, and a retry doesn't take
|
|
151
173
|
# a new snapshot.
|
|
152
|
-
def collect_coalesced(key, active_jobs, payloads)
|
|
174
|
+
def collect_coalesced(key, active_jobs, payloads, wrap_errors:)
|
|
153
175
|
max_calls = active_jobs.first.class.coalesce_max_calls
|
|
154
176
|
pending = active_jobs.zip(payloads)
|
|
155
177
|
enqueued_job = nil
|
|
156
178
|
|
|
157
179
|
until pending.empty?
|
|
158
|
-
admitted, created_job, job_id = raising_enqueue_errors do
|
|
180
|
+
admitted, created_job, job_id = raising_enqueue_errors(wrap_errors) do
|
|
159
181
|
retrying_deadlocks { transaction { admit_collected(key, pending, max_calls) } }
|
|
160
182
|
end
|
|
161
183
|
|
|
@@ -177,7 +199,7 @@ module SolidQueue
|
|
|
177
199
|
coalesce_key.destroy!
|
|
178
200
|
[ 0 ]
|
|
179
201
|
else
|
|
180
|
-
created_job =
|
|
202
|
+
created_job = create!(**attributes_from_payload(*calls.first, "collect")) if coalesce_key.job_id.nil?
|
|
181
203
|
job_id = coalesce_key.job_id || created_job.id
|
|
182
204
|
|
|
183
205
|
CoalescedCall.insert_all(calls.map { |_, payload| { job_id: job_id, arguments: payload } })
|
|
@@ -241,31 +263,75 @@ module SolidQueue
|
|
|
241
263
|
|
|
242
264
|
# Not attributes_from_active_job, which serializes the call again, and that
|
|
243
265
|
# could raise after earlier jobs committed.
|
|
244
|
-
def
|
|
266
|
+
def attributes_from_payload(active_job, payload, on_conflict)
|
|
245
267
|
attributes = {
|
|
246
268
|
queue_name: active_job.queue_name || singleton_class::DEFAULT_QUEUE_NAME,
|
|
247
269
|
active_job_id: active_job.job_id,
|
|
248
270
|
priority: active_job.priority || singleton_class::DEFAULT_PRIORITY,
|
|
249
271
|
scheduled_at: active_job.scheduled_at,
|
|
250
272
|
class_name: active_job.class.name,
|
|
251
|
-
arguments: payload.merge("
|
|
273
|
+
arguments: payload.merge("solid_queue_coalesce_on_conflict" => on_conflict),
|
|
252
274
|
concurrency_key: active_job.concurrency_key
|
|
253
275
|
}
|
|
254
276
|
attributes[:batch_id] = active_job.batch_id if Batch.migrated?
|
|
277
|
+
attributes
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Like a collecting job, serialized once and before anything is written. The
|
|
281
|
+
# last call is the one admitted: it creates the job, or its payload replaces
|
|
282
|
+
# the job's. The calls before it are superseded, and only move the job later.
|
|
283
|
+
def replace_coalesced(key, active_jobs, payload, wrap_errors:)
|
|
284
|
+
enqueued_job, job_id = raising_enqueue_errors(wrap_errors) do
|
|
285
|
+
retrying_deadlocks { transaction { admit_replacing(key, active_jobs, payload) } }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
record_coalesced(key, active_jobs, enqueued_job.present?, job_id)
|
|
255
289
|
|
|
256
|
-
|
|
290
|
+
enqueued_job
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A job from when the class discarded or collected runs the way it was
|
|
294
|
+
# created, and can't take a payload, so the call seals it and tries again.
|
|
295
|
+
# It holds the key exclusively, so it can do that without letting go of it.
|
|
296
|
+
def admit_replacing(key, active_jobs, payload)
|
|
297
|
+
loop do
|
|
298
|
+
coalesce_key = CoalesceKey.admit_exclusively(key)
|
|
299
|
+
job = lock.find_by(id: coalesce_key.job_id) if coalesce_key.discarding?
|
|
300
|
+
|
|
301
|
+
if coalesce_key.job_id.nil?
|
|
302
|
+
enqueued_job = create_for_coalesce_key(coalesce_key, **attributes_from_payload(active_jobs.last, payload, "replace"))
|
|
303
|
+
debounce_coalesced(coalesce_key, coalesce_key.job_id, active_jobs[0...-1])
|
|
304
|
+
|
|
305
|
+
return [ enqueued_job, coalesce_key.job_id ]
|
|
306
|
+
elsif job&.created_replacing?
|
|
307
|
+
job.replace_arguments(payload)
|
|
308
|
+
debounce_coalesced(coalesce_key, job.id, active_jobs)
|
|
309
|
+
|
|
310
|
+
return [ nil, job.id ]
|
|
311
|
+
else
|
|
312
|
+
coalesce_key.destroy!
|
|
313
|
+
end
|
|
314
|
+
end
|
|
257
315
|
end
|
|
258
316
|
|
|
259
317
|
# A call has an outcome only if it was admitted: when on_conflict: :discard
|
|
260
|
-
# destroys the job that was just created, nothing holds the key.
|
|
318
|
+
# destroys the job that was just created, nothing holds the key. The first
|
|
319
|
+
# call is the one that creates a job, except with :replace, where it's the last.
|
|
261
320
|
def record_coalesced(key, active_jobs, created, job_id)
|
|
262
|
-
active_jobs.
|
|
263
|
-
|
|
264
|
-
|
|
321
|
+
active_jobs.each do |active_job|
|
|
322
|
+
job_class = active_job.class
|
|
323
|
+
admitted = job_class.coalesce_replace? ? active_jobs.last : active_jobs.first
|
|
324
|
+
|
|
325
|
+
status = if job_id.nil? then nil
|
|
326
|
+
elsif created && active_job.equal?(admitted) then :created
|
|
327
|
+
elsif job_class.coalesce_collect? then :collected
|
|
328
|
+
elsif active_job.equal?(admitted) && job_class.coalesce_replace? then :replaced
|
|
329
|
+
else :discarded
|
|
330
|
+
end
|
|
265
331
|
|
|
266
332
|
active_job.provider_job_id = job_id
|
|
267
|
-
active_job.successfully_enqueued =
|
|
268
|
-
active_job.send(:coalesce_status=,
|
|
333
|
+
active_job.successfully_enqueued = status.in?(%i[ created replaced collected ])
|
|
334
|
+
active_job.send(:coalesce_status=, status)
|
|
269
335
|
end
|
|
270
336
|
|
|
271
337
|
# After every outcome, because a subscriber can raise. These calls are stored,
|
|
@@ -278,6 +344,24 @@ module SolidQueue
|
|
|
278
344
|
end
|
|
279
345
|
end
|
|
280
346
|
|
|
347
|
+
def created_replacing?
|
|
348
|
+
arguments.is_a?(Hash) && arguments["solid_queue_coalesce_on_conflict"] == "replace"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The caller holds the job's lock, and Solid Queue takes it too before it records
|
|
352
|
+
# the job's semaphore, so neither write can undo the other.
|
|
353
|
+
def replace_arguments(payload)
|
|
354
|
+
update_column :arguments, payload.except(*REPLACEMENT_KEPT_KEYS).merge(arguments.slice(*REPLACEMENT_KEPT_KEYS))
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# A replacing job's start step, before its payload is read. Releasing the key
|
|
358
|
+
# commits on its own, so the payload read after it has every replacement
|
|
359
|
+
# admitted before the release, and a call after it opens a new job.
|
|
360
|
+
def start_replacing_run
|
|
361
|
+
self.class.retrying_deadlocks { transaction { release_coalesce_key } }
|
|
362
|
+
reload
|
|
363
|
+
end
|
|
364
|
+
|
|
281
365
|
# The start step. Returns what the Active Job needs to know about this run,
|
|
282
366
|
# which stays in memory instead of going into its serialized data.
|
|
283
367
|
#
|
|
@@ -296,14 +380,14 @@ module SolidQueue
|
|
|
296
380
|
private
|
|
297
381
|
# Whether this job's class coalesces
|
|
298
382
|
def coalesced?
|
|
299
|
-
job_class.try(:coalesce_key)
|
|
383
|
+
!!job_class.try(:coalesce_key)
|
|
300
384
|
end
|
|
301
385
|
|
|
302
386
|
# Admission marks a job before giving it a key, and the class covers jobs from
|
|
303
387
|
# before the marker. Anything that can't answer gets the read, so that failing
|
|
304
388
|
# or discarding a job still works when its payload or its class can't be loaded.
|
|
305
389
|
def may_hold_coalesce_key?
|
|
306
|
-
!arguments.is_a?(Hash) || arguments.key?("
|
|
390
|
+
!arguments.is_a?(Hash) || arguments.key?("solid_queue_coalesce_on_conflict") || coalesced?
|
|
307
391
|
rescue StandardError, ScriptError
|
|
308
392
|
true
|
|
309
393
|
end
|
|
@@ -5,7 +5,7 @@ module ActiveJob
|
|
|
5
5
|
module Coalescing
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
-
COALESCE_ON_CONFLICT_BEHAVIOUR = %i[ discard collect ]
|
|
8
|
+
COALESCE_ON_CONFLICT_BEHAVIOUR = %i[ discard replace collect ]
|
|
9
9
|
COALESCE_ON_FULL_BEHAVIOUR = %i[ wait dispatch ]
|
|
10
10
|
DEFAULT_COALESCE_MAX_CALLS = 100
|
|
11
11
|
|
|
@@ -53,9 +53,9 @@ module ActiveJob
|
|
|
53
53
|
class_attribute :coalesce_max_wait, instance_accessor: false
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# What happened to this call: +:created+, +:discarded+, +:collected+,
|
|
57
|
-
# this instance has no recorded admission. It isn't serialized, so it's
|
|
58
|
-
# a worker's instance.
|
|
56
|
+
# What happened to this call: +:created+, +:discarded+, +:replaced+, +:collected+,
|
|
57
|
+
# or +nil+ if this instance has no recorded admission. It isn't serialized, so it's
|
|
58
|
+
# +nil+ on a worker's instance.
|
|
59
59
|
#
|
|
60
60
|
# job = ReindexProductJob.perform_later(product.id)
|
|
61
61
|
# job.coalesce_status # => :discarded
|
|
@@ -83,23 +83,22 @@ module ActiveJob
|
|
|
83
83
|
#
|
|
84
84
|
# * +:key+ - Required. A String or Symbol literal, or a Proc that receives the job's
|
|
85
85
|
# arguments. It's scoped to the job class, and built like the key of +limits_concurrency+.
|
|
86
|
+
# +nil+ and +false+ disable coalescing; other types give a class-wide key. Blank
|
|
87
|
+
# declarations still coalesce. All keys are scoped to the batch when present.
|
|
86
88
|
# * +:on_conflict+ - +:discard+, the default, suppresses a call that finds a pending
|
|
87
|
-
# job. +:
|
|
89
|
+
# job. +:replace+ gives the pending job the call's arguments, so it runs +perform+
|
|
90
|
+
# with the latest ones. +:collect+ stores every call for #perform_calls.
|
|
88
91
|
# * +:max_calls+ - The most calls one run takes with +:collect+. Defaults to 100.
|
|
89
92
|
# * +:on_full+ - With +:collect+, +:wait+, the default, leaves a full job to wait for
|
|
90
93
|
# its time. +:dispatch+ makes it ready as it fills.
|
|
91
94
|
# * +:debounce+ - A duration. Each call pushes a pending job back to that long from now.
|
|
92
95
|
# * +:max_wait+ - A duration greater than +:debounce+ that caps how far a job can move.
|
|
93
96
|
#
|
|
94
|
-
#
|
|
97
|
+
# Except for +:key+, an invalid option raises +ArgumentError+. The README has recipes:
|
|
95
98
|
# https://github.com/dtcristo/solid_queue-coalesce#recipes
|
|
96
99
|
def coalesce(key:, on_conflict: :discard, max_calls: nil, on_full: nil, debounce: nil, max_wait: nil)
|
|
97
|
-
unless (key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Proc)) && key.present?
|
|
98
|
-
raise ArgumentError, "`key` must be a non-blank String or Symbol, or a Proc"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
100
|
unless on_conflict.in?(COALESCE_ON_CONFLICT_BEHAVIOUR)
|
|
102
|
-
raise ArgumentError, "`on_conflict` must be :discard or :collect"
|
|
101
|
+
raise ArgumentError, "`on_conflict` must be :discard, :replace or :collect"
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
if on_conflict == :collect
|
|
@@ -146,15 +145,20 @@ module ActiveJob
|
|
|
146
145
|
coalesce_on_conflict == :collect
|
|
147
146
|
end
|
|
148
147
|
|
|
148
|
+
def coalesce_replace? # :nodoc:
|
|
149
|
+
coalesce_on_conflict == :replace
|
|
150
|
+
end
|
|
151
|
+
|
|
149
152
|
def limits_concurrency(**) # :nodoc:
|
|
150
153
|
super.tap { ensure_coalesce_compatible_with_concurrency_controls(coalesce_on_conflict, concurrency_on_conflict) }
|
|
151
154
|
end
|
|
152
155
|
|
|
153
156
|
private
|
|
154
|
-
# A conflict destroys the job, and with it the
|
|
157
|
+
# A conflict destroys the job, and with it the arguments that replaced its own
|
|
158
|
+
# or the calls it has collected
|
|
155
159
|
def ensure_coalesce_compatible_with_concurrency_controls(coalesce_on_conflict, concurrency_on_conflict)
|
|
156
|
-
if coalesce_on_conflict
|
|
157
|
-
raise ArgumentError, "`coalesce` with `on_conflict:
|
|
160
|
+
if coalesce_on_conflict.in?(%i[ replace collect ]) && concurrency_on_conflict == :discard
|
|
161
|
+
raise ArgumentError, "`coalesce` with `on_conflict: :#{coalesce_on_conflict}` can't be combined with `limits_concurrency` with `on_conflict: :discard`"
|
|
158
162
|
end
|
|
159
163
|
end
|
|
160
164
|
end
|
|
@@ -171,12 +175,12 @@ module ActiveJob
|
|
|
171
175
|
# outlives the class's declaration. Only the job's row starts with this, when it
|
|
172
176
|
# gets its key, so an instance only has it if it was read from one.
|
|
173
177
|
def serialize # :nodoc:
|
|
174
|
-
@coalesce_on_conflict ? super.merge("
|
|
178
|
+
@coalesce_on_conflict ? super.merge("solid_queue_coalesce_on_conflict" => @coalesce_on_conflict) : super
|
|
175
179
|
end
|
|
176
180
|
|
|
177
181
|
def deserialize(job_data) # :nodoc:
|
|
178
182
|
super
|
|
179
|
-
@coalesce_on_conflict = job_data["
|
|
183
|
+
@coalesce_on_conflict = job_data["solid_queue_coalesce_on_conflict"]
|
|
180
184
|
end
|
|
181
185
|
|
|
182
186
|
# Scoped to the job class name, and built like concurrency_key. In a batch it's
|
|
@@ -208,7 +212,7 @@ module ActiveJob
|
|
|
208
212
|
|
|
209
213
|
# A retry is the same work continuing, so it can't join or suppress anything
|
|
210
214
|
def coalesced_enqueue? # :nodoc:
|
|
211
|
-
self.class.coalesce_key
|
|
215
|
+
self.class.coalesce_key && executions.to_i.zero?
|
|
212
216
|
end
|
|
213
217
|
|
|
214
218
|
# A call's own time if it has one, and otherwise now plus the window. A longer
|
|
@@ -239,13 +243,16 @@ module ActiveJob
|
|
|
239
243
|
|
|
240
244
|
private
|
|
241
245
|
def coalesce_batch_scope
|
|
242
|
-
[
|
|
246
|
+
[ "Batch", batch_id ] if batch_id
|
|
243
247
|
end
|
|
244
248
|
|
|
245
249
|
# Only the run of a job a worker has claimed. perform_now on an instance that
|
|
246
250
|
# went through perform_later also has a provider_job_id. A collecting job always
|
|
247
251
|
# looks for its run, because the marker outlives the class's key and adapter.
|
|
252
|
+
# A replacing job's run started before its payload was read.
|
|
248
253
|
def start_coalesced_run
|
|
254
|
+
return if @coalesce_on_conflict == "replace"
|
|
255
|
+
|
|
249
256
|
ensure_still_collecting if created_collecting?
|
|
250
257
|
|
|
251
258
|
if created_collecting? || (self.class.coalesce_key && provider_job_id && self.class.queue_adapter_name == "solid_queue")
|
|
@@ -32,7 +32,8 @@ module SolidQueue
|
|
|
32
32
|
if active_job.coalesced_enqueue?
|
|
33
33
|
active_job.scheduled_at = active_job.coalesced_scheduled_at(options[:scheduled_at])
|
|
34
34
|
ensure_coalescible
|
|
35
|
-
|
|
35
|
+
key = raising_enqueue_errors { active_job.coalesce_key }
|
|
36
|
+
enqueue_coalesced(key, [ active_job ], wrap_errors: true)
|
|
36
37
|
elsif active_job.class.coalesce_key
|
|
37
38
|
continuing_coalesced_run(active_job) { super }
|
|
38
39
|
else
|
|
@@ -65,11 +66,36 @@ module SolidQueue
|
|
|
65
66
|
|
|
66
67
|
# A class can start discarding conflicts after it stopped collecting, and a
|
|
67
68
|
# conflict destroys the job. A collecting job blocks instead, whatever its class
|
|
68
|
-
# says now, so it reaches a worker and fails with its calls.
|
|
69
|
+
# says now, so it reaches a worker and fails with its calls. A replacing job
|
|
70
|
+
# blocks too, because a conflict would destroy the arguments that replaced its own.
|
|
69
71
|
module ConcurrencyConflicts
|
|
70
72
|
private
|
|
71
73
|
def handle_concurrency_conflict
|
|
72
|
-
arguments["
|
|
74
|
+
arguments["solid_queue_coalesce_on_conflict"].in?(%w[ replace collect ]) ? block : super
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Solid Queue writes the whole payload when it records a semaphore, from a copy
|
|
79
|
+
# read before it locked anything. For a replacing job it reads the payload again
|
|
80
|
+
# under the job's lock, which a replacement holds while it writes.
|
|
81
|
+
module ConcurrencyLocks
|
|
82
|
+
private
|
|
83
|
+
def record_concurrency_lock(**)
|
|
84
|
+
self.arguments = self.class.lock.find(id).arguments if created_replacing?
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A replacing job releases its key and reads its payload again before Active
|
|
90
|
+
# Job deserializes it, so the run has every replacement admitted before the
|
|
91
|
+
# release. Raises like super, into a failed execution.
|
|
92
|
+
module ReplacingExecution
|
|
93
|
+
private
|
|
94
|
+
def execute
|
|
95
|
+
job.start_replacing_run if job.created_replacing?
|
|
96
|
+
super
|
|
97
|
+
rescue Exception => e
|
|
98
|
+
ClaimedExecution::Result.new(false, e)
|
|
73
99
|
end
|
|
74
100
|
end
|
|
75
101
|
|
|
@@ -100,6 +126,7 @@ module SolidQueue
|
|
|
100
126
|
|
|
101
127
|
included do
|
|
102
128
|
validate :ensure_job_class_not_collecting
|
|
129
|
+
validate :ensure_job_class_not_replacing
|
|
103
130
|
validate :ensure_job_class_not_debounced
|
|
104
131
|
end
|
|
105
132
|
|
|
@@ -112,6 +139,13 @@ module SolidQueue
|
|
|
112
139
|
end
|
|
113
140
|
end
|
|
114
141
|
|
|
142
|
+
# A tick that replaces would record the open job again, and roll back its replacement
|
|
143
|
+
def ensure_job_class_not_replacing
|
|
144
|
+
if class_name.present? && job_class&.try(:coalesce_replace?)
|
|
145
|
+
errors.add :class_name, :replacing, message: "can't be a job that uses `coalesce` with `on_conflict: :replace`"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
115
149
|
# A tick passes no time, so the window applies, and a tick that joins pushes
|
|
116
150
|
# the job back. With an interval shorter than the window it would never run.
|
|
117
151
|
def ensure_job_class_not_debounced
|
|
@@ -127,6 +161,8 @@ module SolidQueue
|
|
|
127
161
|
SolidQueue::Job.prepend Failing
|
|
128
162
|
SolidQueue::Job.prepend Finishing
|
|
129
163
|
SolidQueue::Job.prepend ConcurrencyConflicts
|
|
164
|
+
SolidQueue::Job.prepend ConcurrencyLocks
|
|
165
|
+
SolidQueue::ClaimedExecution.prepend ReplacingExecution
|
|
130
166
|
[ ReadyExecution, ScheduledExecution, BlockedExecution, FailedExecution ].each { |execution| execution.singleton_class.prepend BulkDiscarding }
|
|
131
167
|
SolidQueue::RecurringTask.include RecurringValidation
|
|
132
168
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_queue-coalesce
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cristofaro
|
|
@@ -135,6 +135,20 @@ dependencies:
|
|
|
135
135
|
- - ">="
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: solid_queue-rate_limit
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 0.2.1
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: 0.2.1
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: rdoc
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -208,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
208
222
|
- !ruby/object:Gem::Version
|
|
209
223
|
version: '0'
|
|
210
224
|
requirements: []
|
|
211
|
-
rubygems_version: 4.0.
|
|
225
|
+
rubygems_version: 4.0.18
|
|
212
226
|
specification_version: 4
|
|
213
227
|
summary: Dedupe, debounce and batch Solid Queue jobs.
|
|
214
228
|
test_files: []
|