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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9832b3432d9f235ba2e8b752017633b5bd974f26277ed30eae8e72651a942cf7
4
- data.tar.gz: 4b74f5be0748dbd2e47d8489fb984b836de4de0d0621710b559e12f7bd98290e
3
+ metadata.gz: 74e791e65276dd0da04b025f1d6e545958eda7f4c09e6ecc6e7babb1450c9af6
4
+ data.tar.gz: c3bb125145fe32176a8c68f38ca3abbf6dad27945b4784c96a5d841a84a6f234
5
5
  SHA512:
6
- metadata.gz: 568cd5b18e36ac02fae073249b54e98546bdca672ecd08bb6db130eb936b5ae47da721e5123067fdee7d6240ef12ae082780c63aeeadab2ea835afe3407cbe7a
7
- data.tar.gz: 0c160c406c984cae3e303c9dcef834f21ae95658fd1456fccb5a57be5c42dcd1d7aae650e1d7ad60c3a402d565c8b7cab8851d674324402bc4c28fff2390f20a
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.2.0...HEAD
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
- # Solid Queue Coalesce
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
- 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.
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
- 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.
10
+ Dedupe when a repeat call adds nothing:
6
11
 
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.
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
- Run once, with the first call's arguments:
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
- Run once, with every call's arguments:
142
+ Replace, so one run gets the latest call's arguments:
143
+
144
+ - [Once calls stop](#run-with-the-latest-arguments)
98
145
 
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)
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
- ### Dedupe jobs in a Solid Queue batch
246
+ ### Run with the latest arguments
194
247
 
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.
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 ReindexProductJob < ApplicationJob
199
- coalesce key: ->(product_id) { product_id }
251
+ class SyncContactJob < ApplicationJob
252
+ coalesce key: ->(user_id, *) { user_id }, on_conflict: :replace, debounce: 10.seconds
200
253
 
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
254
+ def perform(user_id, attributes)
255
+ Crm.update_contact(user_id, attributes)
211
256
  end
212
257
  end
213
258
 
214
- SolidQueue::Batch.enqueue(on_success: PublishCatalogJob) do
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
- 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).
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
- ### Batch calls together
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 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).
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
- ### Run a batch at N calls or T seconds
283
+ ### Collect until N calls or T seconds
241
284
 
242
- Use this when a batch should run as soon as it's full, and a quiet key still shouldn't wait forever.
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 batch recipe
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
- ### Debounce a batch
299
+ ### Collect until calls stop
257
300
 
258
- Use this when a batch should run when it's quiet, when it's full or at the cap, whichever comes first.
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 batch recipe
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
- ### Run a batch at midnight
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 batch at a time
338
+ ### Run one collecting job at a time
296
339
 
297
- Use this when batches for a key shouldn't overlap, such as writes to one account's records.
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 batch recipe
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 batch blocks until the first finishes, and keeps collecting until it starts. See [Recurring jobs and concurrency limits](#recurring-jobs-and-concurrency-limits).
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 batch
355
+ ### Resume a long collecting job
313
356
 
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.
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
- - 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).
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, except on Ruby 3.2 |
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 against all three databases.
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
- An invalid option raises `ArgumentError` when the class loads.
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 # Static job-wide key
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 [batch recipe](#batch-calls-together):
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](#run-a-batch-at-n-calls-or-t-seconds). With `.set(wait: 30.seconds)`:
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 batch after one fills gets a full `max_wait`, and so does a new job after one starts.
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
- 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:
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 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`.
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, 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`.
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 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.
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 batch](#resume-a-long-batch), but `perform_calls` still receives every call.
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` 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.
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), 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.
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 `SolidQueue::Batch/7/ReindexProductJob/42`. `coalesce.solid_queue` reports it that way, and the prefix counts towards MySQL's 255 characters.
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. 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.
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 `"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.
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 by version 0.1 don't have this record, so drain them before you upgrade. See [UPGRADING.md](UPGRADING.md).
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 to version 0.2.x
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 belongs to one
5
- # job from the moment it's admitted, across that job's retries, until the job is
6
- # destroyed.
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
- def enqueue_coalesced(key, active_jobs, payloads = nil)
20
- return collect_coalesced(key, active_jobs, payloads || active_jobs.map(&:serialize)) if active_jobs.first.class.coalesce_collect?
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
- enqueued_job = create_for_coalesce_key(coalesce_key, active_jobs.first) if coalesce_key.job_id.nil?
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, active_job)
135
- attributes = attributes_from_active_job(active_job)
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 = create_collecting(*calls.first) if coalesce_key.job_id.nil?
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 create_collecting(active_job, payload)
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("coalesce_on_conflict" => "collect"),
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
- create!(**attributes)
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.each_with_index do |active_job, index|
263
- collect = active_job.class.coalesce_collect?
264
- created_by_this_call = created && index.zero?
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 = created_by_this_call || collect
268
- active_job.send(:coalesce_status=, job_id && (created_by_this_call ? :created : (collect ? :collected : :discarded)))
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).present?
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?("coalesce_on_conflict") || coalesced?
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+, or +nil+ if
57
- # this instance has no recorded admission. It isn't serialized, so it's +nil+ on
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. +:collect+ stores every call for #perform_calls.
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
- # An invalid option raises +ArgumentError+. The README has recipes for each of these:
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 calls it has collected
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 == :collect && concurrency_on_conflict == :discard
157
- raise ArgumentError, "`coalesce` with `on_conflict: :collect` can't be combined with `limits_concurrency` with `on_conflict: :discard`"
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("coalesce_on_conflict" => @coalesce_on_conflict) : super
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["coalesce_on_conflict"]
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.present? && executions.to_i.zero?
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
- [ SolidQueue::Batch.name, batch_id ] if batch_id
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
- enqueue_coalesced(active_job.coalesce_key, [ active_job ])
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["coalesce_on_conflict"] == "collect" ? block : super
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
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
2
  module Coalesce
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  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.2.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.20
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: []