solid_queue-rate_limit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f903a1ef2190109dff2e08ad8e57357534a45228f2079097ede82055e095aac2
4
- data.tar.gz: 395d34ca629026778e2c6cb96838bedf46250ff4e40d2231e6a63987e49c8288
3
+ metadata.gz: 494dc86905183d3c7e43d93794d8529a7cee72b8039422137c98601c1c3f9793
4
+ data.tar.gz: 1926904a64c22e7bccc4f94066ffe308fd9a250700931ee5082b710fcb9dc607
5
5
  SHA512:
6
- metadata.gz: 8eadc6cd4a9276e3ffd0f8246c28be9c5da25dbe77398abbd0d2f0ac6c4f08814e1447fd69935e3994a8a17d4ee0767e80b84641be72e7c9d6548c01e87ba5ac
7
- data.tar.gz: 6296c9c4d330e516b4ee0c89f57adc84409418b9fea6ba13671010f0e5c4369fa2eb84b6d9894574bbf6957407a64950db605021b40770bafc750794134dac68
6
+ metadata.gz: 01afb44f70f3b73d76b29966687ee7da5876e41c9f70523f9c56f2f4741dcd2d88d6c0d1255a3ff70cd728e966fff93634b5e49f8f72b8626ada29b5272e1e68
7
+ data.tar.gz: b496de40a83ea8169222ddc5c2bc4e3f3900daeff6c55c71b7fc883e9e816ced70535bb10fc1cf70fe54e83c7af715424de3b873c3e69a2d9eb04632363c0517
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-11-06
10
+
11
+ Adds a budget for each key with `key:` and `group:`, `limits_rate` and `limits_concurrency` on one class, a cleanup for idle budgets, and a retry for claims and dispatches that deadlock. Upgrading takes code changes but no migration: every `limits_rate` needs a `key:`, `to:` now includes the burst, and Solid Queue 1.7.2 is required. See [UPGRADING.md](https://github.com/dtcristo/solid_queue-rate_limit/blob/main/UPGRADING.md).
12
+
13
+ ### Added
14
+
15
+ - `key:` and `group:` for `limits_rate`, which take what `limits_concurrency` takes. `key:` gives each value a budget of its own, such as one for each account, and `group:` shares a budget between the classes that name it. The key is built like Solid Queue's concurrency key, worked out once when Solid Queue enqueues the job, and kept by its retries. Upgrading needs no migration. ([#19](https://github.com/dtcristo/solid_queue-rate_limit/pull/19), [#24](https://github.com/dtcristo/solid_queue-rate_limit/pull/24))
16
+ - `rate_limit_key` and `rate_limit_group` on job classes. ([#19](https://github.com/dtcristo/solid_queue-rate_limit/pull/19))
17
+ - `SolidQueue::RateLimitState.clear_idle_in_batches(batch_size: 500, sleep_between_batches: 0)`, which deletes the state rows of budgets that are full and have no slots ahead, so per-key rows don't pile up. Clearing never changes what's admitted, and a claim that runs while it clears inserts the row again. The README has a `recurring.yml` entry to run it every hour. ([#20](https://github.com/dtcristo/solid_queue-rate_limit/pull/20))
18
+ - `keys` in the `defer_rate_limited.solid_queue` payload and its log line, listing the budgets that deferred jobs. ([#19](https://github.com/dtcristo/solid_queue-rate_limit/pull/19))
19
+ - `limits_rate` and `limits_concurrency` on one class, in either order and through inheritance. A job over the rate waits as a scheduled job, and gives its concurrency slot back and wakes the next blocked job for its key once the claim commits. When the dispatcher brings it back, it takes a slot again or is blocked. `limits_concurrency(on_conflict: :discard)` with `limits_rate` still raises `ArgumentError`. The README has a recipe for one job at a time for each account under a global rate. ([#22](https://github.com/dtcristo/solid_queue-rate_limit/pull/22))
20
+ - README recipes that combine `limits_rate` with [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce) 0.2: collapsing calls for each account with `limits_concurrency` under a global rate, with a table of which control acts at each step, and batching calls for a bulk API under a quota. ([#23](https://github.com/dtcristo/solid_queue-rate_limit/pull/23))
21
+
22
+ ### Changed
23
+
24
+ - **Breaking:** `limits_rate`'s `to:` is the most a budget admits in any `per`, burst included, as `to:` is in Rails' `rate_limit` and `limits_concurrency`. It was the steady rate, with the burst on top, so `to: 100, per: 1.minute, burst: 10` could admit 109 in a minute. After a burst, jobs are now spaced `per / (to - burst + 1)` apart, and a backlog drains at `to - burst + 1` per `per`. To keep a limit as 0.1.0 enforced it, add `burst - 1` to `to`. A `burst` above `to` raises `ArgumentError`, and the README's recipes and backlog rule use the new meaning. ([#25](https://github.com/dtcristo/solid_queue-rate_limit/pull/25))
25
+ - **Breaking:** `limits_rate` requires `key:`, as `limits_concurrency` and Solid Queue Coalesce's `coalesce` do, and `group:` defaults to Solid Queue's `->(*) { self.class.name }`. Add `key: :global` for one budget per class, stored as `JobClass/global`, or `key: ->(*) {}` to keep the 0.1.0 budget, stored as `JobClass`. A `nil` or `false` key turns the limit off, as it turns off `limits_concurrency`, and a job whose key comes out blank isn't limited, as a job whose concurrency key is blank isn't. With `:global`, jobs enqueued by 0.1.0 drain on the old budget while new ones use the new one, so the limit can admit up to twice its rate until they're gone. Every limited job stores its key, so each claim with limited jobs reads their keys in one more query. ([#24](https://github.com/dtcristo/solid_queue-rate_limit/pull/24))
26
+ - **Breaking:** Requires Solid Queue 1.7.2 or newer within 1.7, because giving a deferred job's concurrency slot back relies on 1.7.2 recording which semaphore a job holds. ([#22](https://github.com/dtcristo/solid_queue-rate_limit/pull/22))
27
+ - A job that still has a concurrency key after its class dropped `limits_concurrency` is checked against the rate, instead of being admitted without a check. ([#22](https://github.com/dtcristo/solid_queue-rate_limit/pull/22))
28
+ - `defer_rate_limited.solid_queue` includes jobs of classes that also declare `limits_concurrency`. Each blocked job a deferral wakes instruments Solid Queue's `release_blocked.solid_queue`. ([#22](https://github.com/dtcristo/solid_queue-rate_limit/pull/22))
29
+ - `name:` still raises `ArgumentError`, and now links to [#14](https://github.com/dtcristo/solid_queue-rate_limit/issues/14), which tracks multiple limits. ([#19](https://github.com/dtcristo/solid_queue-rate_limit/pull/19))
30
+ - Enqueueing a limited job before the migration has run raises `SolidQueue::RateLimitState::PendingMigrations` from every path into Solid Queue, including Solid Queue Coalesce's coalesced enqueues, which skipped the check when that gem's patch loaded after this one's. ([#23](https://github.com/dtcristo/solid_queue-rate_limit/pull/23))
31
+ - A claim whose first pass deadlocks, or a dispatcher batch that deadlocks, runs once more instead of raising, so a deadlock no longer stops the worker or the dispatcher. On MySQL, claims that insert a new budget's row at the same time can deadlock, which `key:` makes common, and so can a claim that defers jobs and a dispatcher batch. A second deadlock still raises. ([#19](https://github.com/dtcristo/solid_queue-rate_limit/pull/19), [#21](https://github.com/dtcristo/solid_queue-rate_limit/pull/21))
32
+
9
33
  ## [0.1.0] - 2026-11-04
10
34
 
11
35
  The first release adds `limits_rate`, so workers claim a job class's jobs no faster than a declared rate, and jobs over it wait as scheduled jobs. Install it with `bin/rails generate solid_queue:rate_limit:install`, and there's nothing to upgrade from. The [README](https://github.com/dtcristo/solid_queue-rate_limit/blob/main/README.md#what-the-limit-guarantees) states what the limit guarantees and how to pick a `burst` for a backlog.
@@ -18,3 +42,7 @@ The first release adds `limits_rate`, so workers claim a job class's jobs no fas
18
42
  - The `defer_rate_limited.solid_queue` event, with `class_name`, `job_ids` and `size`, logged at debug. ([#12](https://github.com/dtcristo/solid_queue-rate_limit/pull/12))
19
43
  - Requires Solid Queue 1.7. ([#11](https://github.com/dtcristo/solid_queue-rate_limit/pull/11))
20
44
  - A README with recipes, the guarantee and a rule for picking `burst`, and [design notes](https://github.com/dtcristo/solid_queue-rate_limit/blob/main/docs/design.md). ([#13](https://github.com/dtcristo/solid_queue-rate_limit/pull/13))
45
+
46
+ [Unreleased]: https://github.com/dtcristo/solid_queue-rate_limit/compare/v0.2.0...HEAD
47
+ [0.2.0]: https://github.com/dtcristo/solid_queue-rate_limit/compare/v0.1.0...v0.2.0
48
+ [0.1.0]: https://github.com/dtcristo/solid_queue-rate_limit/releases/tag/v0.1.0
data/README.md CHANGED
@@ -5,11 +5,11 @@
5
5
  </p>
6
6
  </div>
7
7
 
8
- Solid Queue Rate Limit adds `limits_rate` to Active Job classes. Workers claim a class's jobs no faster than the rate you declare, and a job over the limit waits as a scheduled job instead of holding a worker thread, sleeping in `perform` or retrying after a 429.
8
+ Solid Queue Rate Limit adds `limits_rate` to Active Job classes. Workers claim no more of a class's jobs in any period than you declare, and a job over the limit waits as a scheduled job instead of holding a worker thread, sleeping in `perform` or retrying after a 429.
9
9
 
10
10
  ```ruby
11
11
  class SyncStripeJob < ApplicationJob
12
- limits_rate to: 100, per: 1.minute, burst: 10
12
+ limits_rate to: 100, per: 1.minute, burst: 10, key: :global
13
13
 
14
14
  def perform(account_id)
15
15
  Account.find(account_id).sync_to_stripe!
@@ -19,6 +19,8 @@ end
19
19
 
20
20
  The limit is checked when a worker claims the job, inside Solid Queue's own claim transaction, so it holds across any number of workers and processes on SQLite, PostgreSQL and MySQL. Unlimited jobs in the same queue keep running while limited ones wait.
21
21
 
22
+ To collapse repeated calls into one job before the limit sees them, pair it with [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce). The [last two recipes](#collapse-calls-for-each-account-and-run-them-one-at-a-time-under-a-global-rate) use both.
23
+
22
24
  ## Table of contents
23
25
 
24
26
  - [Getting started](#getting-started)
@@ -27,6 +29,7 @@ The limit is checked when a worker claims the job, inside Solid Queue's own clai
27
29
  - [What the limit guarantees](#what-the-limit-guarantees)
28
30
  - [Picking a burst for a backlog](#picking-a-burst-for-a-backlog)
29
31
  - [Things to know](#things-to-know)
32
+ - [Clearing idle budgets](#clearing-idle-budgets)
30
33
  - [Compatibility](#compatibility)
31
34
  - [`limits_rate` options](#limits_rate-options)
32
35
  - [Rules and limitations](#rules-and-limitations)
@@ -69,9 +72,9 @@ Until the migration has run, everything else works as before, and enqueuing a jo
69
72
 
70
73
  Each process caches whether the table exists, so restart workers and web processes after you migrate. A deploy usually does that for you.
71
74
 
72
- The gem relies on private Solid Queue methods, so its gemspec allows one minor version of Solid Queue at a time.
75
+ The gem relies on private Solid Queue methods, so its gemspec allows one minor version of Solid Queue at a time, from 1.7.2.
73
76
 
74
- [CHANGELOG.md](CHANGELOG.md) lists what changed in each version.
77
+ To upgrade from an earlier version, follow [UPGRADING.md](UPGRADING.md). [CHANGELOG.md](CHANGELOG.md) lists what changed in each version.
75
78
 
76
79
  ## Recipes
77
80
 
@@ -80,8 +83,14 @@ Start from what the API on the other end allows.
80
83
  - [A quota per minute](#stay-under-a-quota-per-minute)
81
84
  - [A fixed gap between calls](#space-jobs-out-evenly)
82
85
  - [A high rate, with a backlog that has to keep up](#drain-a-backlog-at-the-full-rate)
86
+ - [A quota for each account](#give-each-account-a-budget)
87
+ - [One quota for several job classes](#share-a-budget-between-classes)
88
+ - [A quota for each account, used by several job classes](#share-a-budget-for-each-account-between-classes)
89
+ - [One job at a time for each account, under one quota](#run-one-job-at-a-time-for-each-account-under-a-global-rate)
90
+ - [One pending and one running job for each account, under one quota](#collapse-calls-for-each-account-and-run-them-one-at-a-time-under-a-global-rate)
91
+ - [A bulk API, with calls batched while they wait](#batch-calls-under-a-quota)
83
92
 
84
- `Account`, `Address`, `Webhook` and the other names in these recipes stand in for your own code.
93
+ `Account`, `Address`, `Shop`, `Webhook` and the other names in these recipes stand in for your own code.
85
94
 
86
95
  ### Stay under a quota per minute
87
96
 
@@ -89,8 +98,8 @@ Use this when an API allows a number of calls in any minute and you want to stay
89
98
 
90
99
  ```ruby
91
100
  class SyncStripeCustomerJob < ApplicationJob
92
- # At most 10 + 90 = 100 claims in any minute
93
- limits_rate to: 90, per: 1.minute, burst: 10
101
+ # At most 100 claims in any minute, 10 of them at once
102
+ limits_rate to: 100, per: 1.minute, burst: 10, key: :global
94
103
 
95
104
  def perform(account_id)
96
105
  Account.find(account_id).sync_to_stripe!
@@ -98,7 +107,7 @@ class SyncStripeCustomerJob < ApplicationJob
98
107
  end
99
108
  ```
100
109
 
101
- A quiet budget lets 10 jobs through at once, then one every 0.67 seconds. The limit isn't a rolling-window counter, so `to:` alone isn't the most a minute can hold. In any window of `w` seconds, workers claim at most `burst + floor(w / (per / to))` jobs. For a quota of `N` per `per`, keep `to + burst` at or below `N`. See [What the limit guarantees](#what-the-limit-guarantees).
110
+ A quiet budget lets 10 jobs through at once, then one every 60 / 91 seconds, about 0.66. That spacing leaves room for the burst, so no minute holds more than 100, and a backlog drains at 91 a minute. See [What the limit guarantees](#what-the-limit-guarantees).
102
111
 
103
112
  ### Space jobs out evenly
104
113
 
@@ -107,7 +116,7 @@ Use this when an API wants a gap between calls, and two calls close together wou
107
116
  ```ruby
108
117
  class GeocodeAddressJob < ApplicationJob
109
118
  # The default burst: 1 never claims two jobs less than 5 seconds apart
110
- limits_rate to: 1, per: 5.seconds
119
+ limits_rate to: 1, per: 5.seconds, key: :global
111
120
 
112
121
  def perform(address_id)
113
122
  Address.find(address_id).geocode!
@@ -123,8 +132,9 @@ Use this when a backlog builds up and has to drain at the declared rate.
123
132
 
124
133
  ```ruby
125
134
  class SendWebhookJob < ApplicationJob
135
+ # At most 10 in any second, draining a backlog at 10 - 6 + 1 = 5 a second.
126
136
  # 5 × (1 + 0.1) / 1 = 5.5, rounded up, for the default 1-second dispatcher poll and 0.1-second worker poll
127
- limits_rate to: 5, per: 1.second, burst: 6
137
+ limits_rate to: 10, per: 1.second, burst: 6, key: :global
128
138
 
129
139
  def perform(webhook_id)
130
140
  Webhook.find(webhook_id).deliver!
@@ -132,7 +142,7 @@ class SendWebhookJob < ApplicationJob
132
142
  end
133
143
  ```
134
144
 
135
- If 6 jobs at once is too many for the API, make the dispatcher poll more often instead. With a 0.2-second poll, `5 × (0.2 + 0.1) / 1 = 1.5`, which rounds up to 2, so `limits_rate to: 5, per: 1.second, burst: 2` is enough:
145
+ If 6 jobs at once is too many for the API, make the dispatcher poll more often instead. With a 0.2-second poll, `limits_rate to: 10, per: 1.second, burst: 3, key: :global` is enough, and leaves more of the 10 for the backlog, which drains at `10 - 3 + 1 = 8` a second. `8 × (0.2 + 0.1) / 1 = 2.4`, which rounds up to 3:
136
146
 
137
147
  ```yaml
138
148
  # config/queue.yml
@@ -144,11 +154,143 @@ production:
144
154
 
145
155
  A shorter poll means more queries from the dispatcher. Solid Queue's [configuration docs](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#configuration) cover `polling_interval` and `batch_size`.
146
156
 
157
+ ### Give each account a budget
158
+
159
+ Use this when an API's quota is per account, per API key or per tenant, and one busy account shouldn't use up the others' share.
160
+
161
+ ```ruby
162
+ class SyncShopOrdersJob < ApplicationJob
163
+ # At most 2 calls in any second for each shop
164
+ limits_rate to: 2, per: 1.second, burst: 2, key: ->(shop) { shop }
165
+
166
+ def perform(shop)
167
+ shop.sync_orders!
168
+ end
169
+ end
170
+ ```
171
+
172
+ `key:` gets the job's arguments, as in `limits_concurrency`. A record becomes its class name and id, so the shop with id 42 gets a budget of its own, stored under `SyncShopOrdersJob/Shop/42`. A Proc that returns `nil` sends the job to the class's bare budget, `SyncShopOrdersJob`. `burst: 2` follows the [backlog rule](#picking-a-burst-for-a-backlog), and applies to each shop, whose backlog drains at 1 a second.
173
+
174
+ ### Share a budget between classes
175
+
176
+ Use this when several job classes call one API, and together they have to stay under its quota.
177
+
178
+ ```ruby
179
+ class SyncStripeCustomerJob < ApplicationJob
180
+ limits_rate to: 100, per: 1.minute, burst: 10, group: "Stripe", key: :global
181
+
182
+ def perform(account_id)
183
+ Account.find(account_id).sync_to_stripe!
184
+ end
185
+ end
186
+
187
+ class RefundStripeChargeJob < ApplicationJob
188
+ limits_rate to: 100, per: 1.minute, burst: 10, group: "Stripe", key: :global
189
+
190
+ def perform(charge_id)
191
+ Charge.find(charge_id).refund!
192
+ end
193
+ end
194
+ ```
195
+
196
+ Both classes draw on one budget, stored under `Stripe/global`. Declare the same limit in every class of a group, because each job is charged with its own class's settings. See [Rules and limitations](#rules-and-limitations).
197
+
198
+ ### Share a budget for each account between classes
199
+
200
+ Use this when several job classes call an API whose quota is per account.
201
+
202
+ ```ruby
203
+ class SyncStripeConnectAccountJob < ApplicationJob
204
+ # At most 20 calls in any second for each account, with a 0.2-second dispatcher poll
205
+ limits_rate to: 20, per: 1.second, burst: 5, group: "StripeConnect", key: ->(account) { account }
206
+
207
+ def perform(account)
208
+ account.sync_to_stripe!
209
+ end
210
+ end
211
+
212
+ class CreateStripeTransferJob < ApplicationJob
213
+ limits_rate to: 20, per: 1.second, burst: 5, group: "StripeConnect", key: ->(account, _amount) { account }
214
+
215
+ def perform(account, amount)
216
+ account.transfer!(amount)
217
+ end
218
+ end
219
+ ```
220
+
221
+ The account with id 7 gets one budget for both classes, stored under `StripeConnect/Account/7`. Each class's Proc gets its own job's arguments, so they can take different ones, as long as they return the same key for the same account. With the [0.2-second dispatcher poll](#drain-a-backlog-at-the-full-rate), `burst: 5` follows the [backlog rule](#picking-a-burst-for-a-backlog), and each account's backlog drains at 16 a second.
222
+
223
+ ### Run one job at a time for each account, under a global rate
224
+
225
+ Use this when two syncs for one account mustn't run at once, and all accounts together have to stay under an API's quota.
226
+
227
+ ```ruby
228
+ class SyncStripeAccountJob < ApplicationJob
229
+ # One sync at a time for each account, and at most 100 claims in any minute across all of them
230
+ limits_concurrency key: ->(account) { account }
231
+ limits_rate to: 100, per: 1.minute, burst: 10, group: "Stripe", key: :global
232
+
233
+ def perform(account)
234
+ account.sync_to_stripe!
235
+ end
236
+ end
237
+ ```
238
+
239
+ Solid Queue gives each account one concurrency slot, and the gem checks the rate when a worker claims the job. A job over the rate gives its concurrency slot back while it waits, so the account's next blocked job can take it, and takes one again when it comes back. If there's none free by then, the job is blocked, as any job would be. `limits_concurrency` with `on_conflict: :discard` can't be combined with `limits_rate`, because a job that came back to a taken concurrency slot would be discarded.
240
+
241
+ ### Collapse calls for each account and run them one at a time, under a global rate
242
+
243
+ Use this when an account's sync can be asked for many times, only the latest needs to run, two syncs for one account mustn't overlap, and all accounts together have to stay under an API's quota. It needs [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce).
244
+
245
+ ```ruby
246
+ class SyncShopProductsJob < ApplicationJob
247
+ # One pending and one running sync for each shop, and at most 2 claims in any second across them
248
+ coalesce key: ->(shop) { shop }
249
+ limits_concurrency key: ->(shop) { shop }
250
+ limits_rate to: 2, per: 1.second, burst: 2, group: "Shopify", key: :global
251
+
252
+ def perform(shop)
253
+ shop.sync_products!
254
+ end
255
+ end
256
+ ```
257
+
258
+ Each control acts at a different step of the job's life:
259
+
260
+ | Step | What happens | Control |
261
+ | --- | --- | --- |
262
+ | `perform_later` | The call joins the shop's pending job, or creates one. The job stores the concurrency and rate limit keys of the call that created it | `coalesce` |
263
+ | The job becomes ready | It takes the shop's concurrency slot, or is blocked until the running sync finishes | `limits_concurrency` |
264
+ | A worker claims it | The budget admits it, or it waits as a scheduled job and gives its concurrency slot back | `limits_rate` |
265
+ | `perform` starts | The coalesce key is free, so the next call creates the next pending job | `coalesce` |
266
+ | `perform` finishes | The concurrency slot is free, and the shop's blocked job becomes ready | `limits_concurrency` |
267
+
268
+ A job waiting on the rate still holds its coalesce key, so calls keep joining it. With `debounce:`, a call can move it later than its rate check, but never earlier. With `on_full: :dispatch`, a full job that's waiting becomes ready, and the next claim sends it back to wait.
269
+
270
+ ### Batch calls under a quota
271
+
272
+ Use this when an API has a bulk endpoint with a quota on requests, and calls arrive one item at a time. It needs [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce).
273
+
274
+ ```ruby
275
+ class PushInventoryJob < ApplicationJob
276
+ # Up to 100 SKUs in each request, and at most 10 requests in any minute for each shop
277
+ coalesce key: ->(shop, _sku) { shop }, on_conflict: :collect, max_calls: 100
278
+ limits_rate to: 10, per: 1.minute, burst: 2, key: ->(shop, _sku) { shop }
279
+
280
+ def perform_calls(calls)
281
+ shop = calls.first.arguments.first
282
+ shop.push_inventory(calls.map { |call| call.arguments.last }.uniq)
283
+ end
284
+ end
285
+ ```
286
+
287
+ Each call joins the shop's pending job until it holds 100, and the limit is checked when a worker claims that job. A job waiting on the rate hasn't started, so calls keep joining it, and batches grow as the budget runs out. The limit counts claims, so make one bulk request in each run. A retry runs all of the job's calls again, and is charged again.
288
+
147
289
  ## How waiting works
148
290
 
149
- When a worker claims jobs, the gem checks each limited job against its class's budget, in queue order, inside Solid Queue's claim transaction. A job the budget admits is claimed and runs like any other. A job over the limit moves from `solid_queue_ready_executions` to `solid_queue_scheduled_executions`, with a time to check again. It doesn't take a worker thread, and Active Job doesn't see a retry. When that time comes, the dispatcher makes it ready as usual, and the next claim checks it again.
291
+ When a worker claims jobs, the gem checks each limited job against its budget, in queue order, inside Solid Queue's claim transaction. A job the budget admits is claimed and runs like any other. A job over the limit moves from `solid_queue_ready_executions` to `solid_queue_scheduled_executions`, with a time to check again. It doesn't take a worker thread, and Active Job doesn't see a retry. When that time comes, the dispatcher makes it ready as usual, and the next claim checks it again.
150
292
 
151
- The checks are staggered. Each waiting job gets the next free slot, one interval `per / to` after the last slot handed out, so 100 waiting jobs come back at 100 different times instead of all on the next poll. Slots stop 5 minutes ahead. A job past that checks again in 5 minutes without taking a slot. If waiting jobs get discarded, or you loosen the limit, the slots they held come free within 5 minutes.
293
+ The checks are staggered. Each waiting job gets the next free slot, one interval `per / (to - burst + 1)` after the last slot handed out, so 100 waiting jobs come back at 100 different times instead of all on the next poll. Each budget hands out its own slots, so a backlog on one account's key doesn't push back another account's jobs. Slots stop 5 minutes ahead. A job past that checks again in 5 minutes without taking a slot. If waiting jobs get discarded, or you loosen the limit, the slots they held come free within 5 minutes.
152
294
 
153
295
  A slot is a hint, and never charges the budget. A job that comes back and finds the budget spent waits again, with the next free slot.
154
296
 
@@ -160,13 +302,16 @@ Unlimited jobs keep running. Only limited jobs move to scheduled executions, and
160
302
 
161
303
  The limit counts claims at the worker gate, the moment a worker takes a job from `solid_queue_ready_executions`. It doesn't count job starts, finishes or the API calls a job makes.
162
304
 
163
- With `T = per / to`, in any window of `w` seconds, workers claim at most `burst + floor(w / T)` of a class's jobs. With `burst: 1` that's even spacing, at least `T` apart. With a larger burst it isn't an exact rolling-window quota. `limits_rate to: 100, per: 1.minute, burst: 10` allows 110 claims in a minute that follows a quiet spell.
305
+ Workers claim at most `to` of the jobs that share a budget in any window of `per`, starting at any moment. A quiet budget admits up to `burst` of them at once, and then one every `T = per / (to - burst + 1)`, which is how fast a backlog drains. With `burst: 1` that's even spacing, at least `per / to` apart. `limits_rate to: 100, per: 1.minute, burst: 10, key: :global` allows 10 claims at once, and 100 in any minute.
306
+
307
+ The limit isn't a rolling-window counter. It spaces claims so that a burst at the start of a window and the claims after it add up to `to`. So a larger burst leaves less of `to` for the rest of the window: `to: 100, burst: 100` admits 100 at once, then one a minute.
164
308
 
165
309
  Some things fall outside it:
166
310
 
167
311
  - A claim is charged when it happens, whatever happens next. A worker that crashes after claiming a job has used that budget.
168
312
  - A job that comes back to ready is charged again. That covers a `retry_on` retry, a failed job you retry, and a claim released when a worker shuts down.
169
313
  - A job can start a moment after its claim, make several API calls, or make one late in a long `perform`. Leave headroom if the API counts something else.
314
+ - A window that spans a change to the limit can hold more than either limit allows. See [`limits_rate` options](#limits_rate-options). So can a group whose classes declare different limits. See [Rules and limitations](#rules-and-limitations).
170
315
  - Each worker reads the time from its own clock. Skew between hosts can let claims through closer together than `T`, so keep their clocks in sync, as Solid Queue's scheduled jobs already need.
171
316
  - It's not FIFO. Jobs are checked in queue order within a claim, but a waiting job can be overtaken by one that arrives after the budget recovers, or one whose slot comes sooner.
172
317
 
@@ -174,35 +319,50 @@ Some things fall outside it:
174
319
 
175
320
  A job that reaches a worker while the budget has room runs straight away, whatever the burst. A backlog is different. Its jobs come back from waiting through the dispatcher, which promotes due jobs once per `polling_interval` (1 second by default), and a worker claims them shortly after.
176
321
 
177
- `burst` also sets how early a job can reach the gate and still get in. Each burst above 1 allows one more interval `per / to`. With `burst: 1` there's no slack. A waiting job that arrives even a millisecond before the interval since the last claim has passed waits again, behind the rest of the backlog. Dispatch and claim delays vary by tens of milliseconds, so this happens to a good share of waiting jobs. Polling more often doesn't help, because the delays still vary.
322
+ `burst` also sets how early a job can reach the gate and still get in. Each burst above 1 allows one more interval `T`. With `burst: 1` there's no slack. A waiting job that arrives even a millisecond before the interval since the last claim has passed waits again, behind the rest of the backlog. Dispatch and claim delays vary by tens of milliseconds, so this happens to a good share of waiting jobs. Polling more often doesn't help, because the delays still vary.
178
323
 
179
- For a backlog to drain at the full rate, `burst` has to cover two things. A dispatcher poll makes about `to × polling_interval / per` jobs due at once, and one claim takes them together. On top of that, the slack has to cover how much a worker's claim delay varies, which is up to the worker's own `polling_interval`. So set `burst` to at least `to × (dispatcher polling_interval + worker polling_interval) / per`, rounded up, and to at least 2. With Solid Queue's defaults of 1 and 0.1 seconds, that's `burst: 2` for 1 job a second, and `burst: 6` for 5. The cost is that a quiet budget lets that many jobs through at once.
324
+ A backlog drains at one job every `T`, that is `to - burst + 1` per `per`. To drain at that rate, `burst` has to cover two things. A dispatcher poll makes about `polling_interval / T` jobs due at once, and one claim takes them together. On top of that, the slack has to cover how much a worker's claim delay varies, which is up to the worker's own `polling_interval`. So set `burst` to at least `(dispatcher polling_interval + worker polling_interval) / T`, rounded up, and to at least 2.
180
325
 
181
- This is a rule of thumb that fits the measurements below and the recipe test's simulation, not a bound. Slow claims or busy workers can need more. In the simulation, 100 jobs a second with a 0.1-second dispatcher poll fell behind with `burst: 11` and kept up with `burst: 20`.
326
+ For a limit of `to` per `per`, with `p` for the two polling intervals added up, that's `burst` of at least `(to + 1) × p / (per + p)`, rounded up, and at least 2. With Solid Queue's defaults of 1 and 0.1 seconds, that's `burst: 2` for `to: 2, per: 1.second`, which drains at 1 a second, and `burst: 6` for `to: 10`, which drains at 5. If the result is more than `to`, `per` is too short for the polls. Make the dispatcher poll more often, or accept a backlog that drains more slowly. The cost of a larger burst is that a quiet budget lets that many jobs through at once, and the backlog drains more slowly.
327
+
328
+ This is a rule of thumb that fits the measurements below and the recipe test's simulation, not a bound. Slow claims or busy workers can need more. In the simulation, a backlog draining at 100 jobs a second with a 0.1-second dispatcher poll fell behind with `to: 110, burst: 11` and kept up with `to: 119, burst: 20`.
182
329
 
183
330
  These numbers come from 30 to 60 waiting jobs, with one worker polling every 0.1 seconds, on SQLite:
184
331
 
185
- | Limit | Dispatcher poll | `burst` | Backlog drained at |
186
- | --- | --- | --- | --- |
187
- | 1 per second | 1 s | 1 | 0.55 per second |
188
- | 1 per second | 1 s | 2 | 0.95 per second |
189
- | 1 per second | 0.1 s | 1 | 0.53 per second |
190
- | 1 per second | 0.1 s | 2 | 1.0 per second |
191
- | 2 per second | 1 s | 2 | 1.53 per second |
192
- | 2 per second | 1 s | 3 | 1.9 per second |
193
- | 5 per second | 1 s | 5 | 4.61 per second |
194
- | 5 per second | 1 s | 6 | 4.91 per second |
195
- | 1 per 3 seconds | 1 s | 1 | 0.30 per second |
196
- | 1 per 3 seconds | 1 s | 2 | 0.33 per second |
332
+ | Limit | `burst` | Dispatcher poll | Drains at, at most | Backlog drained at |
333
+ | --- | --- | --- | --- | --- |
334
+ | 1 per second | 1 | 1 s | 1 per second | 0.55 per second |
335
+ | 2 per second | 2 | 1 s | 1 per second | 0.95 per second |
336
+ | 1 per second | 1 | 0.1 s | 1 per second | 0.53 per second |
337
+ | 2 per second | 2 | 0.1 s | 1 per second | 1.0 per second |
338
+ | 3 per second | 2 | 1 s | 2 per second | 1.53 per second |
339
+ | 4 per second | 3 | 1 s | 2 per second | 1.9 per second |
340
+ | 9 per second | 5 | 1 s | 5 per second | 4.61 per second |
341
+ | 10 per second | 6 | 1 s | 5 per second | 4.91 per second |
342
+ | 1 per 3 seconds | 1 | 1 s | 1 per 3 seconds | 0.30 per second |
343
+ | 2 per 3 seconds | 2 | 1 s | 1 per 3 seconds | 0.33 per second |
197
344
 
198
345
  ## Things to know
199
346
 
200
347
  - It's a separate gem that adds to Solid Queue from the outside, with its own table. It only works with the Solid Queue adapter, because it checks the limit inside Solid Queue's claim.
201
- - The limit counts claims, not API calls, and isn't an exact rolling-window quota once `burst` is above 1. See [What the limit guarantees](#what-the-limit-guarantees).
202
- - With `burst: 1`, a backlog drains slower than the rate. See [Picking a burst for a backlog](#picking-a-burst-for-a-backlog).
348
+ - It works with [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce), which coalesces calls when they're enqueued, before the limit checks the job at claim. See [Collapse calls for each account and run them one at a time, under a global rate](#collapse-calls-for-each-account-and-run-them-one-at-a-time-under-a-global-rate).
349
+ - The limit counts claims, not API calls. A larger `burst` leaves less of `to` for a backlog, which drains at `to - burst + 1` per `per`. See [What the limit guarantees](#what-the-limit-guarantees).
350
+ - With `burst: 1`, a backlog drains slower than `to` per `per`. See [Picking a burst for a backlog](#picking-a-burst-for-a-backlog).
203
351
  - A waiting job is a scheduled job with its own `scheduled_at` kept, and checks again at most 5 minutes later. See [How waiting works](#how-waiting-works).
204
- - Each job class has its own budget, and so does each subclass. See [Rules and limitations](#rules-and-limitations).
205
- - When the app is eager-loaded and no class declares `limits_rate`, claims run exactly Solid Queue's queries. Otherwise each claim reads the class names of the jobs it locked, and locks one row per limited class.
352
+ - Each job class has its own budgets, and so does each subclass, unless it declares `group:`. See [Rules and limitations](#rules-and-limitations).
353
+ - When the app is eager-loaded and no class declares `limits_rate`, claims run exactly Solid Queue's queries. Otherwise each claim reads the class names of the jobs it locked, reads the limited jobs' keys in one more query, and locks one row per budget. When it defers jobs that hold a concurrency slot, it loads them, gives their concurrency slots back and wakes the next blocked job for each.
354
+
355
+ ## Clearing idle budgets
356
+
357
+ A budget gets a row in `solid_queue_rate_limits` the first time a claim needs it, and the row stays. With `key:`, that's a row for every account, shop or API key your jobs have used. `SolidQueue::RateLimitState.clear_idle_in_batches` deletes the rows of budgets that are full and have no slots handed out ahead. Such a row admits exactly as a missing one does, so clearing never changes what's admitted, and a claim that runs while it clears inserts the row again. Nothing runs it for you, so add it to your `recurring.yml`, as Solid Queue's installer does for finished jobs:
358
+
359
+ ```yml
360
+ clear_solid_queue_rate_limits:
361
+ command: "SolidQueue::RateLimitState.clear_idle_in_batches(sleep_between_batches: 0.3)"
362
+ schedule: every hour at minute 27
363
+ ```
364
+
365
+ It takes `batch_size:`, 500 by default, and `sleep_between_batches:`, like Solid Queue's `clear_finished_in_batches`.
206
366
 
207
367
  ## Compatibility
208
368
 
@@ -210,8 +370,9 @@ These numbers come from 30 to 60 waiting jobs, with one worker polling every 0.1
210
370
  | --- | --- |
211
371
  | Ruby | 3.2 and newer |
212
372
  | Rails | 7.1, 7.2, 8.0 and 8.1. CI also runs Rails main, except on Ruby 3.2 |
213
- | Solid Queue | 1.7 |
373
+ | Solid Queue | 1.7.2 and newer within 1.7 |
214
374
  | Database | MySQL, PostgreSQL and SQLite |
375
+ | [Solid Queue Coalesce](https://github.com/dtcristo/solid_queue-coalesce) | Optional. CI tests 0.2 alongside |
215
376
 
216
377
  CI runs Ruby 3.2, 3.3, 3.4 and 4.0 against all three databases.
217
378
 
@@ -227,24 +388,50 @@ CI runs Ruby 3.2, 3.3, 3.4 and 4.0 against all three databases.
227
388
  ## `limits_rate` options
228
389
 
229
390
  ```ruby
230
- limits_rate to:, per:, burst: 1
391
+ limits_rate to:, per:, key:, burst: 1, group: ->(*) { self.class.name }
392
+ ```
393
+
394
+ - `to` is required, and is a positive integer: the most jobs any `per` admits.
395
+ - `per` is required, and is a positive duration.
396
+ - `key` is required, and identifies the jobs that share a budget, as in `limits_concurrency`.
397
+ - `burst` is a positive integer, 1 by default, and at most `to`: how many jobs a quiet budget admits at once. After a burst, workers admit jobs `per / (to - burst + 1)` apart, which has to be at least 1 millisecond.
398
+ - `group` shares the budget with every class that names the same group. It takes the same values as `key`. Without it, the budget belongs to the class.
399
+
400
+ `key` and `group` work like the ones `limits_concurrency` takes, and the gem builds a budget's key the way Solid Queue builds a concurrency key: the group, or the class's name, then the key's value, joined with `/`.
401
+
402
+ ```ruby
403
+ limits_rate to: 1, per: 1.second, key: :global # Literal class-wide key: JobClass/global
404
+ limits_rate to: 1, per: 1.second, key: "global" # Same literal component
405
+ limits_rate to: 1, per: 1.second, key: ->(account_id) { account_id } # Distinct per account
406
+ limits_rate to: 1, per: 1.second, key: ->(account) { account } # Active Record class and ID
407
+ limits_rate to: 1, per: 1.second, key: ->(account_id, kind) { [ account_id, kind ] } # Composite key
231
408
  ```
232
409
 
233
- - `to` is required, and is a positive integer: how many jobs a `per` admits.
234
- - `per` is required, and is a positive duration. Workers admit jobs `per / to` apart, which has to be at least 1 millisecond.
235
- - `burst` is a positive integer, 1 by default: how many jobs a quiet budget admits at once.
410
+ - A Symbol or String is a literal. `key: :account_id` doesn't read a parameter called `account_id`.
411
+ - A Proc receives the job's arguments.
412
+ - The result is joined into a string, so `1` and `"1"` give the same key.
413
+ - An array's items are joined with `/`, so `[ "a/b" ]` and `[ "a", "b" ]` give the same key.
414
+ - A Proc that returns `nil` gives the bare class-wide key, `JobClass`, or the group's, so all of those jobs share a budget.
415
+ - A declaration of `nil` or `false` disables the limit, 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.
416
+ - A key that comes out blank, such as with `group: nil` and a Proc that returns `nil`, leaves the job unlimited, as a blank concurrency key does.
417
+
418
+ Except for `key:`, an invalid option raises `ArgumentError` when the class loads. The class gets `rate_limit_to`, `rate_limit_per`, `rate_limit_burst`, `rate_limit_key`, `rate_limit_group` and `rate_limited?` to read them back.
236
419
 
237
- An invalid option raises `ArgumentError` when the class loads. The class gets `rate_limit_to`, `rate_limit_per`, `rate_limit_burst` and `rate_limited?` to read them back.
420
+ The key is computed once, when Solid Queue enqueues the job, and stored with it, so a claim never deserializes arguments or loads records. A key Proc that raises fails the enqueue. `perform_now` and other adapters never run it.
238
421
 
239
- Changing a limit and deploying applies to the next claim. A looser limit doesn't pay off all the debt the old one built up. The claim caps it at `burst` intervals of the new limit, rounded up to the microsecond, so new admissions start within one new interval and a microsecond. Jobs already waiting keep the next checks the old limit gave them, and new waiting jobs get slots after those, so a backlog can take up to 5 minutes to speed up. A tighter limit starts from the budget as the old one left it.
422
+ Changing a limit and deploying applies to the next claim. A looser limit doesn't pay off all the debt the old one built up. The claim caps it at `burst` intervals of the new limit, rounded up to the microsecond, so new admissions start within one new interval and a microsecond. Jobs already waiting keep the next checks the old limit gave them, and new waiting jobs get slots after those, so a backlog can take up to 5 minutes to speed up. A tighter limit starts from the budget as the old one left it, so a window that spans the deploy can hold more than the new limit allows, until one new `per` has passed.
240
423
 
241
424
  ## Rules and limitations
242
425
 
243
- - A class has one limit, and its budget belongs to that class. A second `limits_rate` in the same class raises `ArgumentError`.
244
- - A subclass inherits its parent's limit, with a budget of its own. Declaring `limits_rate` on `ApplicationJob` gives each job class its own budget, not one shared between them. A subclass can redeclare the limit.
245
- - `key:`, `group:` and `name:`, for keyed, shared and multiple limits, raise `ArgumentError` until [#3](https://github.com/dtcristo/solid_queue-rate_limit/issues/3).
246
- - `limits_rate` and `limits_concurrency` on the same class raise `ArgumentError` in either order, and through inheritance, until [#2](https://github.com/dtcristo/solid_queue-rate_limit/issues/2). A job that still has a concurrency key, after its class switched from one to the other, is admitted without a rate check, because it holds its semaphore while it waits.
247
- - The budget is stored under the class's name. A job enqueued under an alias, such as a constant kept after a rename, shares the class's budget. On MySQL, whose default collation ignores case, two classes whose names differ only in case share one budget.
426
+ - A class has one limit. A second `limits_rate` in the same class raises `ArgumentError`. `name:`, for multiple limits, raises `ArgumentError` until [#14](https://github.com/dtcristo/solid_queue-rate_limit/issues/14).
427
+ - A subclass inherits its parent's limit, `key:` and `group:`. Without a group, it has budgets of its own, so declaring `limits_rate` on `ApplicationJob` gives each job class its own budget, not one shared between them. A subclass can redeclare the limit, and then has only the `key:` and `group:` it declares. Redeclaring with `key: nil` turns the limit off.
428
+ - Every class in a group has to declare the same `to:`, `per:` and `burst:`. The gem doesn't check, as Solid Queue doesn't for concurrency groups. Each job is charged with its own class's settings against the shared budget, so with mixed settings a group can admit more than the stricter one allows.
429
+ - Keys aren't escaped, as concurrency keys aren't. A group named after a job class shares that class's budget, and `group: "Stripe", key: "a/b"` shares one with `group: "Stripe/a", key: "b"`. On MySQL, whose default collation ignores case, keys that differ only in case share one budget.
430
+ - A key longer than `solid_queue_rate_limits.key` allows, 255 characters on MySQL, raises `ActiveRecord::ValueTooLong` at enqueue, as an over-long concurrency key does. `perform_later` raises it wrapped in `SolidQueue::Job::EnqueueError`, and `perform_all_later` raises it as it is. PostgreSQL and SQLite have no limit.
431
+ - A retry keeps the key its job was enqueued with. That covers a `retry_on` retry, a failed job you retry, and a retry after the arguments failed to deserialize.
432
+ - A job keeps the key it was enqueued with while it waits, even if its class changes `key:` or `group:`. A job enqueued without a key, before its class declared a limit or by 0.1.0, uses the bare class-wide budget, `JobClass`, and gets the current key if it's retried.
433
+ - `limits_rate` combines with `limits_concurrency`, in either order and through inheritance. A job over the rate gives its concurrency slot back and wakes the next blocked job for its key once the claim commits, and takes one again when the dispatcher brings it back, or is blocked. `limits_concurrency` with `on_conflict: :discard` raises `ArgumentError` with `limits_rate`, because a job that came back to a taken concurrency slot would be discarded. A job that still has a concurrency key after its class dropped `limits_concurrency` is checked like any other.
434
+ - Without `group:`, the budget's key starts with the class's name. A job enqueued under an alias, such as a constant kept after a rename, keeps the key it was enqueued with, and shares the class's budget. On MySQL, two classes whose names differ only in case share one budget.
248
435
  - A job whose class doesn't load, or whose `class_name` isn't a job class, is claimed without a check and fails as it would without the gem.
249
436
  - A class that drops its limit has its waiting jobs claimed when they next check, at most 5 minutes later.
250
437
 
@@ -254,14 +441,14 @@ The design, and the costs behind these rules, are in [the design notes](https://
254
441
 
255
442
  | Event | When | Payload |
256
443
  | --- | --- | --- |
257
- | `defer_rate_limited.solid_queue` | A claim sent a class's jobs to wait | `class_name`, `job_ids`, `size` |
444
+ | `defer_rate_limited.solid_queue` | A claim sent a class's jobs to wait | `class_name`, `keys`, `job_ids`, `size` |
258
445
 
259
- A claim instruments one event for each class it deferred, inside its transaction. If the claim then rolls back, the event has still fired. A job can wait more than once, so `size` counts deferrals, not distinct jobs.
446
+ A claim instruments one event for each class it deferred, inside its transaction. If the claim then rolls back, the event has still fired. `keys` lists the budgets that deferred them, once each. A job can wait more than once, so `size` counts deferrals, not distinct jobs. Jobs of a class that also declares `limits_concurrency` are included, and each blocked job one of them wakes instruments Solid Queue's own `release_blocked.solid_queue`.
260
447
 
261
448
  `SolidQueue::LogSubscriber` logs it at debug level:
262
449
 
263
450
  ```text
264
- SolidQueue-1.7.2 Defer rate-limited jobs (0.1ms) class_name: "SyncStripeJob", job_ids: [1, 2], size: 2
451
+ SolidQueue-1.7.2 Defer rate-limited jobs (0.1ms) class_name: "SyncStripeJob", keys: ["SyncStripeJob"], job_ids: [1, 2], size: 2
265
452
  ```
266
453
 
267
454
  A backlog can defer jobs on every claim, so the line isn't built at all above debug.
@@ -280,7 +467,7 @@ Tests that run jobs through Solid Queue need the table in the test queue databas
280
467
 
281
468
  ## Roadmap
282
469
 
283
- [#10](https://github.com/dtcristo/solid_queue-rate_limit/issues/10) tracks what comes after 0.1.0, including keyed and shared limits, combining with `limits_concurrency`, and feedback from an API's 429 responses.
470
+ [#10](https://github.com/dtcristo/solid_queue-rate_limit/issues/10) tracks what's planned, including multiple limits per job and feedback from an API's 429 responses.
284
471
 
285
472
  ## License
286
473
 
data/UPGRADING.md ADDED
@@ -0,0 +1,18 @@
1
+ # Upgrading from version 0.1.0 to 0.2.0
2
+
3
+ There's no migration. Update Solid Queue and every `limits_rate` declaration, then deploy.
4
+
5
+ 1. Update Solid Queue to 1.7.2 or newer within 1.7. The gemspec requires it.
6
+ 2. Add `key:` to every `limits_rate`. A declaration without it raises `ArgumentError` when its class loads.
7
+ 3. If a declaration sets `burst` above 1, add `burst - 1` to `to` to keep the limit 0.1.0 enforced. `to:` is now the most a budget admits in any `per`, burst included. A declaration with the default `burst: 1` needs no change.
8
+
9
+ | 0.1.0 | 0.2.0, same limit and budget |
10
+ | --- | --- |
11
+ | `limits_rate to: 10, per: 1.minute` | `limits_rate to: 10, per: 1.minute, key: ->(*) {}` |
12
+ | `limits_rate to: 100, per: 1.minute, burst: 10` | `limits_rate to: 109, per: 1.minute, burst: 10, key: ->(*) {}` |
13
+
14
+ `key: ->(*) {}` keeps 0.1.0's budget, stored as the class name, so jobs enqueued by 0.1.0 share it with new ones. `key: :global` reads better, but it's a new budget, stored as `JobClass/global`. Jobs enqueued by 0.1.0 drain on the old budget while new ones use the new one, so the limit can admit up to twice its rate until they're gone. To avoid that, drain those jobs before you switch, or keep `key: ->(*) {}`.
15
+
16
+ A key for each value, such as `key: ->(account) { account }`, gives each value a budget of its own. Jobs enqueued by 0.1.0 have no key and drain on the old class-wide budget, which doesn't count against the new ones.
17
+
18
+ Deploy every worker together. A 0.1.0 worker checks every job against its class's old budget, whatever key the job stores, so while both versions run, a job with a new key can be admitted on both budgets.
@@ -19,12 +19,25 @@ module SolidQueue
19
19
  end
20
20
  end
21
21
 
22
+ # A full budget with no slots ahead admits as a missing row does, so it can go
23
+ scope :idle, ->(now = Time.current) { where(tat: ...now, deferred_until: [ nil, ...now ]) }
24
+
22
25
  class << self
23
26
  # Until the app has run the gem's migration everything else works as before.
24
27
  def migrated?
25
28
  @migrated ||= table_exists?
26
29
  end
27
30
 
31
+ # The delete checks each row again, because a claim can charge it once it's been
32
+ # picked. By key, so on MySQL it locks rows in the order claims do.
33
+ def clear_idle_in_batches(batch_size: 500, sleep_between_batches: 0)
34
+ loop do
35
+ records_deleted = idle.where(key: idle.limit(batch_size).pluck(:key)).delete_all
36
+ sleep(sleep_between_batches) if sleep_between_batches > 0
37
+ break if records_deleted == 0
38
+ end
39
+ end
40
+
28
41
  # Returns the state of each key, by key, locked until the claim commits. A new
29
42
  # key starts with a full budget. They're inserted and locked in key order, so
30
43
  # claims that share keys can't deadlock on them.
@@ -32,16 +45,27 @@ module SolidQueue
32
45
  keys = keys.sort
33
46
 
34
47
  # On PostgreSQL even a skipped insert takes a value from the id sequence
35
- if (missing = keys - where(key: keys).pluck(:key)).any?
36
- insert_all(missing.map { |key| { key: key, tat: Time.current } })
37
- end
38
-
48
+ insert_missing(keys - where(key: keys).pluck(:key))
39
49
  states = where(key: keys).order(:key).lock.index_by(&:key)
40
50
 
41
- # Under a case-insensitive collation, like MySQL's default, a key can match
42
- # a row spelled differently, and then the two share a budget
43
- keys.index_with { |key| states[key] || states.fetch(where(key: key).pick(:key)) }
51
+ keys.index_with { |key| states[key] || lock_one(key, states) }
44
52
  end
53
+
54
+ private
55
+ def insert_missing(keys)
56
+ insert_all(keys.map { |key| { key: key, tat: Time.current } }) if keys.any?
57
+ end
58
+
59
+ # Under a case-insensitive collation, like MySQL's default, a key can match
60
+ # a row spelled differently, and then the two share a budget. A row the cleanup
61
+ # deleted since the check is inserted again, out of key order.
62
+ def lock_one(key, states)
63
+ until (state = where(key: key).lock.first)
64
+ insert_missing([ key ])
65
+ end
66
+
67
+ states[state.key] ||= state
68
+ end
45
69
  end
46
70
 
47
71
  # Charges one admission and returns true, or returns false if the budget is spent.
@@ -8,38 +8,60 @@ module SolidQueue
8
8
  class_methods do
9
9
  # Moves the candidates whose class is over its rate to scheduled executions,
10
10
  # leaving their jobs' own scheduled_at as it is, and returns them. Called inside
11
- # the claim's transaction, after the candidates have been locked.
11
+ # the claim's transaction, after the candidates have been locked. The deferred
12
+ # jobs that hold a semaphore are kept for release_concurrency_locks.
12
13
  def defer_rate_limited(executions)
13
14
  jobs = Job.where(id: executions.map(&:job_id)).select(:id, :class_name, :queue_name, :priority, :concurrency_key).index_by(&:id)
14
15
  job_classes = jobs.values.map(&:class_name).uniq.index_with { |class_name| rate_limited_job_class(class_name) }.compact
15
16
 
16
- # A job with a concurrency key holds its semaphore, so it can't wait here
17
- limited = executions.select { |execution| (job = jobs[execution.job_id]) && job.concurrency_key.nil? && job_classes[job.class_name] }
18
- return [] if limited.empty?
17
+ limited = executions.select { |execution| (job = jobs[execution.job_id]) && job_classes[job.class_name] }
18
+ keys = limited.any? ? rate_limit_keys(limited, jobs, job_classes) : {}
19
+ return [] if keys.empty?
19
20
 
20
21
  next_checks = if RateLimitState.migrated?
21
- check_rate_limits(limited, jobs, job_classes)
22
+ check_rate_limits(keys, jobs, job_classes)
22
23
  else
23
24
  log_pending_rate_limit_migrations
24
- limited.to_h { |execution| [ execution, 1.minute.from_now ] }
25
+ keys.transform_values { 1.minute.from_now }
25
26
  end
26
27
 
27
- schedule_rate_limited(next_checks, jobs)
28
+ schedule_rate_limited(next_checks, jobs, keys)
29
+ hold_concurrency_locks(next_checks.keys.map(&:job_id).select { |job_id| jobs[job_id].concurrency_key })
28
30
  next_checks.keys
29
31
  end
30
32
 
33
+ # A deferred job gives its semaphore back while it waits, so the semaphore can't expire
34
+ # and let a second job with its key run. It takes one again when it's dispatched.
35
+ #
36
+ # Called once the claim has committed, as Solid Queue unblocks a finished job's next
37
+ # one after its own transaction. Inside the claim, the release and the wake would lock
38
+ # semaphores and insert ready executions while holding ready executions and rate
39
+ # limits, and on MySQL that deadlocks with other claims and the dispatcher.
40
+ #
41
+ # The claim has committed, so an error is logged rather than raised, and the claim
42
+ # returns what it claimed.
43
+ def release_concurrency_locks
44
+ ActiveSupport::IsolatedExecutionState.delete(:solid_queue_rate_limit_releases)&.each do |job|
45
+ job.unblock_next_blocked_job
46
+ rescue StandardError, ScriptError => error
47
+ raise if error.is_a?(ActiveRecord::ActiveRecordError) && connection.transaction_open?
48
+
49
+ SolidQueue.logger&.error("Couldn't release the semaphore of job #{job.id}, or the next job blocked on #{job.concurrency_key}: #{error.class}: #{error.message}")
50
+ end
51
+ end
52
+
31
53
  private
32
54
  # Locks every limit the candidates need, in key order, then charges them in queue
33
- # order. Returns when each rejected candidate should look again. The time is
34
- # read once the locks are held, so claims that share a limit see it move on.
35
- # A limit's key is its class's name, which a job enqueued under an alias shares.
36
- def check_rate_limits(executions, jobs, job_classes)
37
- states = RateLimitState.lock_all(executions.map { |execution| job_classes[jobs[execution.job_id].class_name].name }.uniq)
55
+ # order, each with its own class's settings. Returns when each rejected candidate
56
+ # should look again. The time is read once the locks are held, so claims that
57
+ # share a limit see it move on.
58
+ def check_rate_limits(keys, jobs, job_classes)
59
+ states = RateLimitState.lock_all(keys.values.uniq)
38
60
  now = Time.current
39
61
 
40
- next_checks = executions.each_with_object({}) do |execution, deferred|
62
+ next_checks = keys.keys.each_with_object({}) do |execution, deferred|
41
63
  job_class = job_classes[jobs[execution.job_id].class_name]
42
- state = states[job_class.name]
64
+ state = states[keys[execution]]
43
65
 
44
66
  unless state.admit(job_class.rate_limit_interval, job_class.rate_limit_burst, now)
45
67
  deferred[execution] = state.defer(job_class.rate_limit_interval, job_class.rate_limit_burst, now)
@@ -50,18 +72,37 @@ module SolidQueue
50
72
  next_checks
51
73
  end
52
74
 
53
- def schedule_rate_limited(next_checks, jobs)
75
+ # Returns each candidate's key, stored with its job at enqueue. A job without one,
76
+ # enqueued before its class declared a limit or by 0.1.0, uses the class's name. A
77
+ # blank key isn't limited, as a blank concurrency key isn't.
78
+ def rate_limit_keys(executions, jobs, job_classes)
79
+ stored = Job.where(id: executions.map(&:job_id)).pluck(:id, :arguments).to_h { |id, arguments| [ id, arguments[ActiveJob::RateControls::RATE_LIMIT_KEY] ] }
80
+
81
+ executions.index_with { |execution| stored[execution.job_id] || job_classes[jobs[execution.job_id].class_name].name }.reject { |_, key| key.empty? }
82
+ end
83
+
84
+ def schedule_rate_limited(next_checks, jobs, keys)
54
85
  if next_checks.any?
55
86
  ScheduledExecution.insert_all(next_checks.map do |execution, scheduled_at|
56
87
  jobs[execution.job_id].slice(:queue_name, :priority).merge(job_id: execution.job_id, scheduled_at: scheduled_at)
57
88
  end)
58
89
 
59
90
  next_checks.keys.group_by { |execution| jobs[execution.job_id].class_name }.each do |class_name, deferred|
60
- SolidQueue.instrument(:defer_rate_limited, class_name: class_name, job_ids: deferred.map(&:job_id), size: deferred.size)
91
+ SolidQueue.instrument(:defer_rate_limited, class_name: class_name, keys: keys.values_at(*deferred).uniq, job_ids: deferred.map(&:job_id), size: deferred.size)
61
92
  end
62
93
  end
63
94
  end
64
95
 
96
+ # Loaded inside the claim, so each job keeps the semaphore it held when it was deferred,
97
+ # even if the dispatcher brings it back and it takes another before the release. Going
98
+ # through them by concurrency key, as dispatch_all_one_by_one does, makes claims that
99
+ # release the same semaphores take them in the same order.
100
+ def hold_concurrency_locks(job_ids)
101
+ if job_ids.any?
102
+ ActiveSupport::IsolatedExecutionState[:solid_queue_rate_limit_releases] = Job.where(id: job_ids).sort_by { |job| [ job.concurrency_key, job.id ] }
103
+ end
104
+ end
105
+
65
106
  # A class that can't be loaded fails its jobs when they run, as without the gem
66
107
  def rate_limited_job_class(class_name)
67
108
  job_class = class_name.constantize
@@ -5,35 +5,53 @@ module ActiveJob
5
5
  module RateControls
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ DEFAULT_RATE_LIMIT_GROUP = ->(*) { self.class.name }
8
9
  MIN_RATE_LIMIT_INTERVAL = 0.001
9
10
 
11
+ # Stored in the job's serialized Active Job data, which Active Job ignores when running the job
12
+ RATE_LIMIT_KEY = "solid_queue_rate_limit_key"
13
+
10
14
  included do
11
15
  class_attribute :rate_limit_to, instance_accessor: false
12
16
  class_attribute :rate_limit_per, instance_accessor: false
13
17
  class_attribute :rate_limit_burst, instance_accessor: false
18
+ class_attribute :rate_limit_key, instance_accessor: false
19
+ class_attribute :rate_limit_group, default: DEFAULT_RATE_LIMIT_GROUP, instance_accessor: false
14
20
  end
15
21
 
16
22
  module ClassMethods
17
- # Admits this class's jobs at worker claim no faster than +to+ per +per+,
18
- # using GCRA. A job over the limit waits as a scheduled job, without taking
19
- # a worker thread or a retry, and is checked again when it's dispatched.
23
+ # Admits at most +to+ of this class's jobs at worker claim in any +per+, using
24
+ # GCRA. A job over the limit waits as a scheduled job, without taking a worker
25
+ # thread or a retry, and is checked again when it's dispatched.
20
26
  #
21
27
  # class SyncStripeJob < ApplicationJob
22
- # limits_rate to: 100, per: 1.minute, burst: 10
28
+ # limits_rate to: 100, per: 1.minute, burst: 10, key: :global
23
29
  # end
24
30
  #
25
31
  # ==== Options
26
32
  #
27
- # * +:to+ - Required. A positive integer, how many jobs a +per+ admits.
28
- # * +:per+ - Required. A positive duration. Admissions are spaced <tt>per / to</tt>
33
+ # * +:to+ - Required. A positive integer, the most jobs any +per+ admits.
34
+ # * +:per+ - Required. A positive duration.
35
+ # * +:key+ - Required. A String or Symbol literal, or a Proc that receives the job's
36
+ # arguments, as in +limits_concurrency+. Each value gets a budget of its own. A
37
+ # +nil+ or +false+ key turns the limit off, as it turns off +limits_concurrency+.
38
+ # * +:burst+ - How many jobs a fully recovered budget admits at once, up to +to+.
39
+ # Defaults to 1. After a burst, admissions are spaced <tt>per / (to - burst + 1)</tt>
29
40
  # apart, which has to be at least 1 ms.
30
- # * +:burst+ - How many jobs a fully recovered budget admits at once. Defaults to 1.
41
+ # * +:group+ - Shares the budget with every class that names the same group. Takes
42
+ # the same values as +:key+. Defaults to the class's name.
43
+ #
44
+ # class SyncStripeCustomerJob < ApplicationJob
45
+ # limits_rate to: 100, per: 1.minute, group: "Stripe", key: ->(account) { account }
46
+ # end
31
47
  #
32
- # A subclass has its own budget, and can redeclare the limit. An invalid option
33
- # raises +ArgumentError+. +perform_now+ and other adapters don't enforce it.
34
- def limits_rate(to:, per:, burst: 1, **options)
35
- if (planned = options.keys & %i[ key group name ]).any?
36
- raise ArgumentError, "`#{planned.first}` isn't supported yet. Follow https://github.com/dtcristo/solid_queue-rate_limit/issues/3 for shared, keyed and multiple limits"
48
+ # A subclass inherits the limit, key and group. Without a group it has its own
49
+ # budget, and it can redeclare the limit. Except for +:key+, an invalid option raises
50
+ # +ArgumentError+.
51
+ # +perform_now+ and other adapters don't enforce it, and never compute the key.
52
+ def limits_rate(to:, per:, key:, burst: 1, group: DEFAULT_RATE_LIMIT_GROUP, **options)
53
+ if options.key?(:name)
54
+ raise ArgumentError, "`name` isn't supported yet. Follow https://github.com/dtcristo/solid_queue-rate_limit/issues/14 for multiple limits"
37
55
  elsif options.any?
38
56
  raise ArgumentError, "unknown keyword#{"s" if options.many?}: #{options.keys.map(&:inspect).join(", ")}"
39
57
  end
@@ -50,42 +68,97 @@ module ActiveJob
50
68
  raise ArgumentError, "`burst` must be a positive integer"
51
69
  end
52
70
 
53
- if per.to_r / to < MIN_RATE_LIMIT_INTERVAL
54
- raise ArgumentError, "`limits_rate to: #{to}, per: #{per.inspect}` would admit jobs less than 1 ms apart, and they have to be at least 1 ms apart"
71
+ if burst > to
72
+ raise ArgumentError, "`burst` can't be more than `to`"
73
+ end
74
+
75
+ if rate_limit_interval_for(to, per, burst) < MIN_RATE_LIMIT_INTERVAL
76
+ raise ArgumentError, "`limits_rate to: #{to}, per: #{per.inspect}, burst: #{burst}` would admit jobs less than 1 ms apart, and they have to be at least 1 ms apart"
55
77
  end
56
78
 
57
79
  if @rate_limit_declared
58
80
  raise ArgumentError, "#{name || "This class"} already declares `limits_rate`"
59
81
  end
60
82
 
61
- ensure_rate_limit_compatible_with_concurrency_controls(rate_limited: true)
83
+ ensure_rate_limit_compatible_with_concurrency_controls(rate_limited: key)
62
84
 
63
85
  @rate_limit_declared = SolidQueue::RateLimit.declared = true
64
86
  self.rate_limit_to = to
65
87
  self.rate_limit_per = per
66
88
  self.rate_limit_burst = burst
89
+ self.rate_limit_key = key
90
+ self.rate_limit_group = group
67
91
  end
68
92
 
69
93
  def rate_limited?
70
- rate_limit_to.present?
94
+ rate_limit_key ? true : false
71
95
  end
72
96
 
73
- # The seconds between admissions
97
+ # The seconds between admissions after a burst, so that any +per+ admits at most +to+
74
98
  def rate_limit_interval # :nodoc:
75
- rate_limit_per.to_r / rate_limit_to
99
+ rate_limit_interval_for(rate_limit_to, rate_limit_per, rate_limit_burst)
76
100
  end
77
101
 
78
- def limits_concurrency(**) # :nodoc:
79
- ensure_rate_limit_compatible_with_concurrency_controls(concurrency_limited: true)
102
+ def limits_concurrency(on_conflict: :block, **) # :nodoc:
103
+ ensure_rate_limit_compatible_with_concurrency_controls(discards_on_conflict: on_conflict == :discard)
80
104
  super
81
105
  end
82
106
 
83
107
  private
84
- def ensure_rate_limit_compatible_with_concurrency_controls(rate_limited: rate_limited?, concurrency_limited: concurrency_key.present?)
85
- if rate_limited && concurrency_limited
86
- raise ArgumentError, "`limits_rate` can't be combined with `limits_concurrency` yet. Follow https://github.com/dtcristo/solid_queue-rate_limit/issues/2"
108
+ def rate_limit_interval_for(to, per, burst)
109
+ per.to_r / (to - burst + 1)
110
+ end
111
+
112
+ # A deferred job that comes back to a taken slot would be discarded, dropping work already accepted
113
+ def ensure_rate_limit_compatible_with_concurrency_controls(rate_limited: rate_limited?, discards_on_conflict: concurrency_key.present? && concurrency_on_conflict == :discard)
114
+ if rate_limited && discards_on_conflict
115
+ raise ArgumentError, "`limits_rate` can't be combined with `limits_concurrency(on_conflict: :discard)`"
87
116
  end
88
117
  end
89
118
  end
119
+
120
+ # The key is computed once, when Solid Queue enqueues the job, and stored with it.
121
+ # A claim reads it without deserializing arguments, and a retry keeps it. Every
122
+ # path into Solid Queue serializes the job before writing it, including ones other
123
+ # gems add, so this is where a limited job finds out the schema is missing.
124
+ def serialize # :nodoc:
125
+ if self.class.rate_limited? && self.class.queue_adapter_name == "solid_queue"
126
+ raise SolidQueue::RateLimitState::PendingMigrations unless SolidQueue::RateLimitState.migrated?
127
+
128
+ @rate_limit_key ||= rate_limit_key.tap { |key| ensure_rate_limit_key_fits(key) }
129
+ end
130
+
131
+ @rate_limit_key ? super.merge(RATE_LIMIT_KEY => @rate_limit_key) : super
132
+ end
133
+
134
+ def deserialize(job_data) # :nodoc:
135
+ super
136
+ @rate_limit_key = job_data[RATE_LIMIT_KEY]
137
+ end
138
+
139
+ def rate_limit_key # :nodoc:
140
+ if self.class.rate_limited?
141
+ param = compute_concurrency_parameter(self.class.rate_limit_key)
142
+
143
+ case param
144
+ when ActiveRecord::Base
145
+ [ rate_limit_group, param.class.name, param.id ]
146
+ else
147
+ [ rate_limit_group, param ]
148
+ end.compact.join("/")
149
+ end
150
+ end
151
+
152
+ private
153
+ # Raised here rather than at claim, where it would fail every claim that took the job
154
+ def ensure_rate_limit_key_fits(key)
155
+ if (limit = SolidQueue::RateLimitState.columns_hash["key"].limit) && key.length > limit
156
+ raise ActiveRecord::ValueTooLong, "#{self.class.name}'s rate limit key is #{key.length} characters long, and solid_queue_rate_limits.key allows #{limit}"
157
+ end
158
+ end
159
+
160
+ def rate_limit_group
161
+ compute_concurrency_parameter(self.class.rate_limit_group)
162
+ end
90
163
  end
91
164
  end
@@ -10,22 +10,34 @@ module SolidQueue
10
10
  #
11
11
  # A pass that deferred jobs leaves threads idle that could run jobs behind them,
12
12
  # so the claim looks again, each time in a transaction of its own.
13
+ #
14
+ # Claims that insert a new budget's row at once can deadlock on MySQL, and a
15
+ # deadlock in the first pass would stop the worker, so that pass runs once more.
13
16
  module Claiming
14
17
  MAX_CLAIM_PASSES = 5
15
18
 
16
19
  private
17
20
  def select_and_lock(queue_relation, process_id, limit)
18
- return super unless RateLimit.enforced? && !connection_pool.with_connection(&:transaction_open?)
21
+ return super unless RateLimit.enforced?
22
+ return releasing_concurrency_locks { super } if connection_pool.with_connection(&:transaction_open?)
19
23
 
20
24
  claimed = []
25
+ retried = false
21
26
 
22
27
  MAX_CLAIM_PASSES.times do |pass|
23
- claimed += super(queue_relation, process_id, limit - claimed.size)
28
+ begin
29
+ # Before looking again, so the jobs it wakes can be claimed
30
+ claimed += releasing_concurrency_locks { super(queue_relation, process_id, limit - claimed.size) }
31
+ rescue ActiveRecord::Deadlocked
32
+ # Whatever earlier passes claimed has been committed
33
+ break if pass.positive?
34
+ raise if retried
35
+
36
+ retried = true
37
+ retry
38
+ end
39
+
24
40
  break unless claimed.size < limit && rate_limit_deferred_in_pass?
25
- rescue ActiveRecord::Deadlocked
26
- # Whatever earlier passes claimed has been committed
27
- raise if pass.zero?
28
- break
29
41
  end
30
42
 
31
43
  claimed
@@ -45,35 +57,44 @@ module SolidQueue
45
57
  end
46
58
  end
47
59
 
60
+ # Gives back the semaphores of the jobs a pass deferred once it has committed. A pass
61
+ # that fails has rolled them back, so they're forgotten rather than left for the next.
62
+ def releasing_concurrency_locks
63
+ yield.tap { release_concurrency_locks }
64
+ ensure
65
+ ActiveSupport::IsolatedExecutionState.delete(:solid_queue_rate_limit_releases)
66
+ end
67
+
48
68
  def rate_limit_deferred_in_pass?
49
69
  ActiveSupport::IsolatedExecutionState.delete(:solid_queue_rate_limit_deferred)
50
70
  end
51
71
  end
52
72
 
53
- # Prepended rather than added to the adapter, because RecurringTask calls
54
- # Job.enqueue directly. A limited job can't be admitted without the schema,
55
- # so it's better to find out when it's enqueued.
56
- module Enqueuing
57
- def enqueue_all(active_jobs)
58
- ensure_rate_limitable if active_jobs.any? { |active_job| active_job.class.rate_limited? }
59
- super
60
- end
73
+ # A claim that defers jobs inserts scheduled executions while it holds ready ones,
74
+ # and the dispatcher inserts ready executions while it holds scheduled ones, so on
75
+ # MySQL the two can deadlock. The dispatcher doesn't rescue errors, and would stop,
76
+ # so a deadlocked batch runs once more.
77
+ module Dispatching
78
+ def dispatch_next_batch(batch_size)
79
+ return super unless RateLimit.enforced? && !connection_pool.with_connection(&:transaction_open?)
61
80
 
62
- def enqueue(active_job, **)
63
- ensure_rate_limitable if active_job.class.rate_limited?
64
- super
65
- end
81
+ retried = false
66
82
 
67
- private
68
- def ensure_rate_limitable
69
- raise RateLimitState::PendingMigrations unless RateLimitState.migrated?
83
+ begin
84
+ super
85
+ rescue ActiveRecord::Deadlocked
86
+ raise if retried
87
+
88
+ retried = true
89
+ retry
70
90
  end
91
+ end
71
92
  end
72
93
 
73
94
  def self.apply
74
- SolidQueue::Job.singleton_class.prepend Enqueuing
75
95
  SolidQueue::ReadyExecution.include SolidQueue::ReadyExecution::RateLimited
76
96
  SolidQueue::ReadyExecution.singleton_class.prepend Claiming
97
+ SolidQueue::ScheduledExecution.singleton_class.prepend Dispatching
77
98
  end
78
99
  end
79
100
  end
@@ -84,6 +105,6 @@ class SolidQueue::LogSubscriber
84
105
  subscribe_log_level :defer_rate_limited, :debug
85
106
 
86
107
  def defer_rate_limited(event) # :nodoc:
87
- debug formatted_event(event, action: "Defer rate-limited jobs", **event.payload.slice(:class_name, :job_ids, :size))
108
+ debug formatted_event(event, action: "Defer rate-limited jobs", **event.payload.slice(:class_name, :keys, :job_ids, :size))
88
109
  end
89
110
  end
@@ -1,5 +1,5 @@
1
1
  module SolidQueue
2
2
  module RateLimit
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_queue-rate_limit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cristofaro
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: solid_queue
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.7.2
19
+ - - "<"
17
20
  - !ruby/object:Gem::Version
18
- version: 1.7.0
21
+ version: '1.8'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
- version: 1.7.0
28
+ version: 1.7.2
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '1.8'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: appraisal
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -135,6 +141,20 @@ dependencies:
135
141
  - - ">="
136
142
  - !ruby/object:Gem::Version
137
143
  version: '0'
144
+ - !ruby/object:Gem::Dependency
145
+ name: solid_queue-coalesce
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: 0.2.0
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: 0.2.0
138
158
  - !ruby/object:Gem::Dependency
139
159
  name: rdoc
140
160
  requirement: !ruby/object:Gem::Requirement
@@ -175,6 +195,7 @@ files:
175
195
  - CHANGELOG.md
176
196
  - MIT-LICENSE
177
197
  - README.md
198
+ - UPGRADING.md
178
199
  - app/models/solid_queue/rate_limit_state.rb
179
200
  - app/models/solid_queue/ready_execution/rate_limited.rb
180
201
  - lib/active_job/rate_controls.rb
@@ -206,7 +227,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
206
227
  - !ruby/object:Gem::Version
207
228
  version: '0'
208
229
  requirements: []
209
- rubygems_version: 4.0.20
230
+ rubygems_version: 4.0.18
210
231
  specification_version: 4
211
232
  summary: Rate limits for Solid Queue jobs.
212
233
  test_files: []