solid_queue-rate_limit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/MIT-LICENSE +20 -0
- data/README.md +287 -0
- data/app/models/solid_queue/rate_limit_state.rb +90 -0
- data/app/models/solid_queue/ready_execution/rate_limited.rb +79 -0
- data/lib/active_job/rate_controls.rb +91 -0
- data/lib/generators/solid_queue/rate_limit/install/install_generator.rb +16 -0
- data/lib/generators/solid_queue/rate_limit/install/templates/db/create_solid_queue_rate_limits.rb +12 -0
- data/lib/solid_queue/rate_limit/engine.rb +16 -0
- data/lib/solid_queue/rate_limit/patches.rb +89 -0
- data/lib/solid_queue/rate_limit/version.rb +5 -0
- data/lib/solid_queue/rate_limit.rb +20 -0
- metadata +212 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f903a1ef2190109dff2e08ad8e57357534a45228f2079097ede82055e095aac2
|
|
4
|
+
data.tar.gz: 395d34ca629026778e2c6cb96838bedf46250ff4e40d2231e6a63987e49c8288
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8eadc6cd4a9276e3ffd0f8246c28be9c5da25dbe77398abbd0d2f0ac6c4f08814e1447fd69935e3994a8a17d4ee0767e80b84641be72e7c9d6548c01e87ba5ac
|
|
7
|
+
data.tar.gz: 6296c9c4d330e516b4ee0c89f57adc84409418b9fea6ba13671010f0e5c4369fa2eb84b6d9894574bbf6957407a64950db605021b40770bafc750794134dac68
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-04
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `limits_rate to:, per:, burst: 1` for Active Job classes on Solid Queue. Workers admit a class's jobs at claim no faster than `to` per `per`, using GCRA, with up to `burst` at once from a recovered budget. A job over the limit waits as a scheduled job with a staggered next check, at most 5 minutes away, and keeps its own `scheduled_at`. `perform_now` and other adapters don't enforce it. ([#12](https://github.com/dtcristo/solid_queue-rate_limit/pull/12))
|
|
16
|
+
- `rate_limit_to`, `rate_limit_per`, `rate_limit_burst` and `rate_limited?` on job classes. ([#12](https://github.com/dtcristo/solid_queue-rate_limit/pull/12))
|
|
17
|
+
- `bin/rails generate solid_queue:rate_limit:install`, which copies the migration for `solid_queue_rate_limits` into the queue database. Enqueueing a limited job before it has run raises `SolidQueue::RateLimitState::PendingMigrations`, and workers hold back limited jobs. ([#12](https://github.com/dtcristo/solid_queue-rate_limit/pull/12))
|
|
18
|
+
- 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
|
+
- Requires Solid Queue 1.7. ([#11](https://github.com/dtcristo/solid_queue-rate_limit/pull/11))
|
|
20
|
+
- 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))
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) David Cristofaro
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>Solid Queue Rate Limit</h1>
|
|
3
|
+
<p>
|
|
4
|
+
<strong>Rate limits for <a href="https://github.com/rails/solid_queue">Solid Queue</a> jobs</strong>
|
|
5
|
+
</p>
|
|
6
|
+
</div>
|
|
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.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class SyncStripeJob < ApplicationJob
|
|
12
|
+
limits_rate to: 100, per: 1.minute, burst: 10
|
|
13
|
+
|
|
14
|
+
def perform(account_id)
|
|
15
|
+
Account.find(account_id).sync_to_stripe!
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
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
|
+
|
|
22
|
+
## Table of contents
|
|
23
|
+
|
|
24
|
+
- [Getting started](#getting-started)
|
|
25
|
+
- [Recipes](#recipes)
|
|
26
|
+
- [How waiting works](#how-waiting-works)
|
|
27
|
+
- [What the limit guarantees](#what-the-limit-guarantees)
|
|
28
|
+
- [Picking a burst for a backlog](#picking-a-burst-for-a-backlog)
|
|
29
|
+
- [Things to know](#things-to-know)
|
|
30
|
+
- [Compatibility](#compatibility)
|
|
31
|
+
- [`limits_rate` options](#limits_rate-options)
|
|
32
|
+
- [Rules and limitations](#rules-and-limitations)
|
|
33
|
+
- [Monitoring](#monitoring)
|
|
34
|
+
- [Testing](#testing)
|
|
35
|
+
- [Roadmap](#roadmap)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
38
|
+
## Getting started
|
|
39
|
+
|
|
40
|
+
Add the gem to your application's Gemfile, alongside Solid Queue, then copy its migration to your queue database's migrations path and run it:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
gem "solid_queue-rate_limit"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle install
|
|
48
|
+
bin/rails generate solid_queue:rate_limit:install
|
|
49
|
+
bin/rails db:migrate
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Add `limits_rate` to a job class, then pick a [recipe](#recipes) or read [Picking a burst for a backlog](#picking-a-burst-for-a-backlog).
|
|
53
|
+
|
|
54
|
+
### Where the migration goes
|
|
55
|
+
|
|
56
|
+
If Solid Queue doesn't use a database called `queue`, pass the right one with `--database`. For example, `bin/rails generate solid_queue:rate_limit:install --database primary`. Solid Queue's [database configuration](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#database-configuration) and [single database setup](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#single-database-configuration) cover the options.
|
|
57
|
+
|
|
58
|
+
The generator looks up that database in the current environment's `config/database.yml`. If it isn't there, the generator writes to `db/migrate` without warning, and the migration runs against your primary database. A new Rails app only defines `queue` under `production`, so generate the migration for that environment instead, and let `bin/rails db:prepare` run it when you deploy:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails generate solid_queue:rate_limit:install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The migration should land in `db/queue_migrate`. `db/queue_schema.rb` doesn't pick up the new table from a production migration. That's fine, because `db:prepare` loads that schema into an empty database and then runs the migration.
|
|
65
|
+
|
|
66
|
+
### Before the migration runs
|
|
67
|
+
|
|
68
|
+
Until the migration has run, everything else works as before, and enqueuing a job that uses `limits_rate` raises `SolidQueue::RateLimitState::PendingMigrations`. A limited job that reaches a worker anyway, for example one enqueued before the class declared its limit, isn't claimed. It waits a minute at a time, and each worker process logs one error about the missing table. The limit fails closed, so it never lets jobs through unchecked.
|
|
69
|
+
|
|
70
|
+
Each process caches whether the table exists, so restart workers and web processes after you migrate. A deploy usually does that for you.
|
|
71
|
+
|
|
72
|
+
The gem relies on private Solid Queue methods, so its gemspec allows one minor version of Solid Queue at a time.
|
|
73
|
+
|
|
74
|
+
[CHANGELOG.md](CHANGELOG.md) lists what changed in each version.
|
|
75
|
+
|
|
76
|
+
## Recipes
|
|
77
|
+
|
|
78
|
+
Start from what the API on the other end allows.
|
|
79
|
+
|
|
80
|
+
- [A quota per minute](#stay-under-a-quota-per-minute)
|
|
81
|
+
- [A fixed gap between calls](#space-jobs-out-evenly)
|
|
82
|
+
- [A high rate, with a backlog that has to keep up](#drain-a-backlog-at-the-full-rate)
|
|
83
|
+
|
|
84
|
+
`Account`, `Address`, `Webhook` and the other names in these recipes stand in for your own code.
|
|
85
|
+
|
|
86
|
+
### Stay under a quota per minute
|
|
87
|
+
|
|
88
|
+
Use this when an API allows a number of calls in any minute and you want to stay under it with a burst for spikes.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class SyncStripeCustomerJob < ApplicationJob
|
|
92
|
+
# At most 10 + 90 = 100 claims in any minute
|
|
93
|
+
limits_rate to: 90, per: 1.minute, burst: 10
|
|
94
|
+
|
|
95
|
+
def perform(account_id)
|
|
96
|
+
Account.find(account_id).sync_to_stripe!
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
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).
|
|
102
|
+
|
|
103
|
+
### Space jobs out evenly
|
|
104
|
+
|
|
105
|
+
Use this when an API wants a gap between calls, and two calls close together would fail.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class GeocodeAddressJob < ApplicationJob
|
|
109
|
+
# The default burst: 1 never claims two jobs less than 5 seconds apart
|
|
110
|
+
limits_rate to: 1, per: 5.seconds
|
|
111
|
+
|
|
112
|
+
def perform(address_id)
|
|
113
|
+
Address.find(address_id).geocode!
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
With `burst: 1` there's no slack, so a backlog drains slower than one job every 5 seconds. [Picking a burst for a backlog](#picking-a-burst-for-a-backlog) explains why, and what it costs to fix.
|
|
119
|
+
|
|
120
|
+
### Drain a backlog at the full rate
|
|
121
|
+
|
|
122
|
+
Use this when a backlog builds up and has to drain at the declared rate.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class SendWebhookJob < ApplicationJob
|
|
126
|
+
# 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
|
|
128
|
+
|
|
129
|
+
def perform(webhook_id)
|
|
130
|
+
Webhook.find(webhook_id).deliver!
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
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:
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
# config/queue.yml
|
|
139
|
+
production:
|
|
140
|
+
dispatchers:
|
|
141
|
+
- polling_interval: 0.2
|
|
142
|
+
batch_size: 500
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
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
|
+
|
|
147
|
+
## How waiting works
|
|
148
|
+
|
|
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.
|
|
150
|
+
|
|
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.
|
|
152
|
+
|
|
153
|
+
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
|
+
|
|
155
|
+
The job's own `scheduled_at` in `solid_queue_jobs` stays as it was enqueued. Only the scheduled execution carries the next check. `SolidQueue::Job#scheduled?` is true for a waiting job, so dashboards that read scheduled executions list it as scheduled.
|
|
156
|
+
|
|
157
|
+
Unlimited jobs keep running. Only limited jobs move to scheduled executions, and the rest of the batch is claimed as usual. When a claim deferred jobs and still has free threads, the worker claims again straight away, up to 5 times per poll, so its threads don't sit idle behind waiting jobs.
|
|
158
|
+
|
|
159
|
+
## What the limit guarantees
|
|
160
|
+
|
|
161
|
+
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
|
+
|
|
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.
|
|
164
|
+
|
|
165
|
+
Some things fall outside it:
|
|
166
|
+
|
|
167
|
+
- A claim is charged when it happens, whatever happens next. A worker that crashes after claiming a job has used that budget.
|
|
168
|
+
- 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
|
+
- 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.
|
|
170
|
+
- 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
|
+
- 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
|
+
|
|
173
|
+
## Picking a burst for a backlog
|
|
174
|
+
|
|
175
|
+
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
|
+
|
|
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.
|
|
178
|
+
|
|
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.
|
|
180
|
+
|
|
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`.
|
|
182
|
+
|
|
183
|
+
These numbers come from 30 to 60 waiting jobs, with one worker polling every 0.1 seconds, on SQLite:
|
|
184
|
+
|
|
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 |
|
|
197
|
+
|
|
198
|
+
## Things to know
|
|
199
|
+
|
|
200
|
+
- 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).
|
|
203
|
+
- 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.
|
|
206
|
+
|
|
207
|
+
## Compatibility
|
|
208
|
+
|
|
209
|
+
| Dependency | Supported |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| Ruby | 3.2 and newer |
|
|
212
|
+
| 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 |
|
|
214
|
+
| Database | MySQL, PostgreSQL and SQLite |
|
|
215
|
+
|
|
216
|
+
CI runs Ruby 3.2, 3.3, 3.4 and 4.0 against all three databases.
|
|
217
|
+
|
|
218
|
+
| Feature | Behaviour |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `perform_later` and `perform_all_later` | Limited when a worker claims the job |
|
|
221
|
+
| `perform_now` | Runs straight away, unlimited |
|
|
222
|
+
| `set(wait:)` and `set(wait_until:)` | Dispatched at their time, then limited at claim. A job that waits keeps its `scheduled_at` |
|
|
223
|
+
| Recurring tasks | Limited like any other enqueue |
|
|
224
|
+
| Retries | Charged again when they come back to ready |
|
|
225
|
+
| Other adapters | Ignore `limits_rate`. See [Testing](#testing) |
|
|
226
|
+
|
|
227
|
+
## `limits_rate` options
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
limits_rate to:, per:, burst: 1
|
|
231
|
+
```
|
|
232
|
+
|
|
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.
|
|
236
|
+
|
|
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.
|
|
238
|
+
|
|
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.
|
|
240
|
+
|
|
241
|
+
## Rules and limitations
|
|
242
|
+
|
|
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.
|
|
248
|
+
- 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
|
+
- A class that drops its limit has its waiting jobs claimed when they next check, at most 5 minutes later.
|
|
250
|
+
|
|
251
|
+
The design, and the costs behind these rules, are in [the design notes](https://github.com/dtcristo/solid_queue-rate_limit/blob/main/docs/design.md).
|
|
252
|
+
|
|
253
|
+
## Monitoring
|
|
254
|
+
|
|
255
|
+
| Event | When | Payload |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `defer_rate_limited.solid_queue` | A claim sent a class's jobs to wait | `class_name`, `job_ids`, `size` |
|
|
258
|
+
|
|
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.
|
|
260
|
+
|
|
261
|
+
`SolidQueue::LogSubscriber` logs it at debug level:
|
|
262
|
+
|
|
263
|
+
```text
|
|
264
|
+
SolidQueue-1.7.2 Defer rate-limited jobs (0.1ms) class_name: "SyncStripeJob", job_ids: [1, 2], size: 2
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A backlog can defer jobs on every claim, so the line isn't built at all above debug.
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
ActiveSupport::Notifications.subscribe("defer_rate_limited.solid_queue") do |*, payload|
|
|
271
|
+
Metrics.increment("solid_queue.rate_limit.deferred", payload[:size], tags: payload.slice(:class_name))
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Testing
|
|
276
|
+
|
|
277
|
+
`perform_now` and adapters other than Solid Queue's don't check the limit, and don't need the table. With the test adapter, `assert_enqueued_with` and `perform_enqueued_jobs` work as before, and jobs run as soon as you perform them.
|
|
278
|
+
|
|
279
|
+
Tests that run jobs through Solid Queue need the table in the test queue database, or enqueuing a limited job raises `SolidQueue::RateLimitState::PendingMigrations`.
|
|
280
|
+
|
|
281
|
+
## Roadmap
|
|
282
|
+
|
|
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.
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
# The GCRA state of one limit. +tat+ is the theoretical arrival time: the budget
|
|
5
|
+
# is full when it's in the past, and each admission moves it on by the interval.
|
|
6
|
+
# +deferred_until+ is the latest slot handed to a waiting job. It's only a hint
|
|
7
|
+
# for staggering their next checks, and never charges the budget.
|
|
8
|
+
class RateLimitState < Record # :nodoc:
|
|
9
|
+
self.table_name = "solid_queue_rate_limits"
|
|
10
|
+
|
|
11
|
+
# The furthest a waiting job is sent ahead, so a job that's cancelled or a limit
|
|
12
|
+
# that's loosened can't strand capacity for longer than this.
|
|
13
|
+
MAX_DEFERRAL = 5.minutes
|
|
14
|
+
|
|
15
|
+
# Enqueuing a job that uses +limits_rate+ before the gem's migration has run.
|
|
16
|
+
class PendingMigrations < StandardError
|
|
17
|
+
def initialize(message = "The rate limiting schema hasn't been installed yet. Run `bin/rails generate solid_queue:rate_limit:install` to copy the migration to your application, and then `bin/rails db:migrate` to run it")
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Until the app has run the gem's migration everything else works as before.
|
|
24
|
+
def migrated?
|
|
25
|
+
@migrated ||= table_exists?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the state of each key, by key, locked until the claim commits. A new
|
|
29
|
+
# key starts with a full budget. They're inserted and locked in key order, so
|
|
30
|
+
# claims that share keys can't deadlock on them.
|
|
31
|
+
def lock_all(keys)
|
|
32
|
+
keys = keys.sort
|
|
33
|
+
|
|
34
|
+
# 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
|
+
|
|
39
|
+
states = where(key: keys).order(:key).lock.index_by(&:key)
|
|
40
|
+
|
|
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)) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Charges one admission and returns true, or returns false if the budget is spent.
|
|
48
|
+
# +interval+ is the seconds between admissions, and +burst+ how many a full
|
|
49
|
+
# budget admits at once.
|
|
50
|
+
def admit(interval, burst, now)
|
|
51
|
+
clamp(interval, burst, now)
|
|
52
|
+
|
|
53
|
+
if tat <= now + spare(interval, burst)
|
|
54
|
+
self.tat = ceil_usec([ tat, now ].max + interval)
|
|
55
|
+
true
|
|
56
|
+
else
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# When a job the budget rejected should look again: the next free slot after
|
|
62
|
+
# the ones already handed out, or MAX_DEFERRAL from now if that's further.
|
|
63
|
+
def defer(interval, burst, now)
|
|
64
|
+
clamp(interval, burst, now)
|
|
65
|
+
|
|
66
|
+
slot = ceil_usec([ tat - spare(interval, burst), deferred_until&.+(interval) ].compact.max)
|
|
67
|
+
|
|
68
|
+
if slot <= now + MAX_DEFERRAL
|
|
69
|
+
self.deferred_until = slot
|
|
70
|
+
else
|
|
71
|
+
now + MAX_DEFERRAL
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
# A loosened limit applies at once, instead of after the old one's debt
|
|
77
|
+
def clamp(interval, burst, now)
|
|
78
|
+
self.tat = [ tat, ceil_usec(now + burst * interval) ].min
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def spare(interval, burst)
|
|
82
|
+
(burst - 1) * interval
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Rounding down could erase a little of the debt on every admission
|
|
86
|
+
def ceil_usec(time)
|
|
87
|
+
time.ceil(6)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
class ReadyExecution
|
|
5
|
+
module RateLimited # :nodoc: all
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
# Moves the candidates whose class is over its rate to scheduled executions,
|
|
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.
|
|
12
|
+
def defer_rate_limited(executions)
|
|
13
|
+
jobs = Job.where(id: executions.map(&:job_id)).select(:id, :class_name, :queue_name, :priority, :concurrency_key).index_by(&:id)
|
|
14
|
+
job_classes = jobs.values.map(&:class_name).uniq.index_with { |class_name| rate_limited_job_class(class_name) }.compact
|
|
15
|
+
|
|
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?
|
|
19
|
+
|
|
20
|
+
next_checks = if RateLimitState.migrated?
|
|
21
|
+
check_rate_limits(limited, jobs, job_classes)
|
|
22
|
+
else
|
|
23
|
+
log_pending_rate_limit_migrations
|
|
24
|
+
limited.to_h { |execution| [ execution, 1.minute.from_now ] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
schedule_rate_limited(next_checks, jobs)
|
|
28
|
+
next_checks.keys
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
# 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)
|
|
38
|
+
now = Time.current
|
|
39
|
+
|
|
40
|
+
next_checks = executions.each_with_object({}) do |execution, deferred|
|
|
41
|
+
job_class = job_classes[jobs[execution.job_id].class_name]
|
|
42
|
+
state = states[job_class.name]
|
|
43
|
+
|
|
44
|
+
unless state.admit(job_class.rate_limit_interval, job_class.rate_limit_burst, now)
|
|
45
|
+
deferred[execution] = state.defer(job_class.rate_limit_interval, job_class.rate_limit_burst, now)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
states.each_value { |state| state.save! if state.changed? }
|
|
50
|
+
next_checks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def schedule_rate_limited(next_checks, jobs)
|
|
54
|
+
if next_checks.any?
|
|
55
|
+
ScheduledExecution.insert_all(next_checks.map do |execution, scheduled_at|
|
|
56
|
+
jobs[execution.job_id].slice(:queue_name, :priority).merge(job_id: execution.job_id, scheduled_at: scheduled_at)
|
|
57
|
+
end)
|
|
58
|
+
|
|
59
|
+
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)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A class that can't be loaded fails its jobs when they run, as without the gem
|
|
66
|
+
def rate_limited_job_class(class_name)
|
|
67
|
+
job_class = class_name.constantize
|
|
68
|
+
job_class if job_class.is_a?(Class) && job_class < ActiveJob::Base && job_class.rate_limited?
|
|
69
|
+
rescue StandardError, ScriptError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_pending_rate_limit_migrations
|
|
74
|
+
@pending_rate_limit_migrations_logged ||= SolidQueue.logger&.error("Rate-limited jobs are waiting for the rate limiting schema. #{RateLimitState::PendingMigrations.new.message}") || true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
# Limits how fast workers start a job class's jobs. See #limits_rate.
|
|
5
|
+
module RateControls
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
MIN_RATE_LIMIT_INTERVAL = 0.001
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
class_attribute :rate_limit_to, instance_accessor: false
|
|
12
|
+
class_attribute :rate_limit_per, instance_accessor: false
|
|
13
|
+
class_attribute :rate_limit_burst, instance_accessor: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
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.
|
|
20
|
+
#
|
|
21
|
+
# class SyncStripeJob < ApplicationJob
|
|
22
|
+
# limits_rate to: 100, per: 1.minute, burst: 10
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# ==== Options
|
|
26
|
+
#
|
|
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>
|
|
29
|
+
# apart, which has to be at least 1 ms.
|
|
30
|
+
# * +:burst+ - How many jobs a fully recovered budget admits at once. Defaults to 1.
|
|
31
|
+
#
|
|
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"
|
|
37
|
+
elsif options.any?
|
|
38
|
+
raise ArgumentError, "unknown keyword#{"s" if options.many?}: #{options.keys.map(&:inspect).join(", ")}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless to.is_a?(Integer) && to.positive?
|
|
42
|
+
raise ArgumentError, "`to` must be a positive integer"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless per.is_a?(ActiveSupport::Duration) && per.positive?
|
|
46
|
+
raise ArgumentError, "`per` must be a positive duration"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless burst.is_a?(Integer) && burst.positive?
|
|
50
|
+
raise ArgumentError, "`burst` must be a positive integer"
|
|
51
|
+
end
|
|
52
|
+
|
|
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"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if @rate_limit_declared
|
|
58
|
+
raise ArgumentError, "#{name || "This class"} already declares `limits_rate`"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ensure_rate_limit_compatible_with_concurrency_controls(rate_limited: true)
|
|
62
|
+
|
|
63
|
+
@rate_limit_declared = SolidQueue::RateLimit.declared = true
|
|
64
|
+
self.rate_limit_to = to
|
|
65
|
+
self.rate_limit_per = per
|
|
66
|
+
self.rate_limit_burst = burst
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rate_limited?
|
|
70
|
+
rate_limit_to.present?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The seconds between admissions
|
|
74
|
+
def rate_limit_interval # :nodoc:
|
|
75
|
+
rate_limit_per.to_r / rate_limit_to
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def limits_concurrency(**) # :nodoc:
|
|
79
|
+
ensure_rate_limit_compatible_with_concurrency_controls(concurrency_limited: true)
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
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"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/active_record"
|
|
4
|
+
|
|
5
|
+
class SolidQueue::RateLimit::InstallGenerator < Rails::Generators::Base
|
|
6
|
+
include ActiveRecord::Generators::Migration
|
|
7
|
+
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :database, type: :string, aliases: %i[ --db ], default: "queue",
|
|
11
|
+
desc: "The database that Solid Queue uses. Defaults to `queue`"
|
|
12
|
+
|
|
13
|
+
def copy_migration
|
|
14
|
+
migration_template "db/create_solid_queue_rate_limits.rb", File.join(db_migrate_path, "create_solid_queue_rate_limits.rb"), skip: true
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/generators/solid_queue/rate_limit/install/templates/db/create_solid_queue_rate_limits.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class CreateSolidQueueRateLimits < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :solid_queue_rate_limits do |t|
|
|
4
|
+
t.string :key, null: false
|
|
5
|
+
t.datetime :tat, null: false
|
|
6
|
+
t.datetime :deferred_until
|
|
7
|
+
t.timestamps
|
|
8
|
+
|
|
9
|
+
t.index :key, unique: true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidQueue
|
|
4
|
+
module RateLimit
|
|
5
|
+
class Engine < ::Rails::Engine # :nodoc:
|
|
6
|
+
initializer "solid_queue.rate_limit.active_job", after: "solid_queue.active_job.extensions" do
|
|
7
|
+
ActiveSupport.on_load :active_job do
|
|
8
|
+
include ActiveJob::RateControls
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Solid Queue's models are reloadable, so a reload needs the patches again
|
|
13
|
+
config.to_prepare { Patches.apply }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every place this gem reaches into Solid Queue. All of it depends on private
|
|
4
|
+
# details of the version pinned in the gemspec.
|
|
5
|
+
module SolidQueue
|
|
6
|
+
module RateLimit
|
|
7
|
+
module Patches # :nodoc: all
|
|
8
|
+
# Admission runs in lock_candidates, inside the claim's transaction and after
|
|
9
|
+
# select_candidates has locked the ready executions with SKIP LOCKED.
|
|
10
|
+
#
|
|
11
|
+
# A pass that deferred jobs leaves threads idle that could run jobs behind them,
|
|
12
|
+
# so the claim looks again, each time in a transaction of its own.
|
|
13
|
+
module Claiming
|
|
14
|
+
MAX_CLAIM_PASSES = 5
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def select_and_lock(queue_relation, process_id, limit)
|
|
18
|
+
return super unless RateLimit.enforced? && !connection_pool.with_connection(&:transaction_open?)
|
|
19
|
+
|
|
20
|
+
claimed = []
|
|
21
|
+
|
|
22
|
+
MAX_CLAIM_PASSES.times do |pass|
|
|
23
|
+
claimed += super(queue_relation, process_id, limit - claimed.size)
|
|
24
|
+
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
|
+
end
|
|
30
|
+
|
|
31
|
+
claimed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lock_candidates(executions, process_id)
|
|
35
|
+
ActiveSupport::IsolatedExecutionState[:solid_queue_rate_limit_deferred] = false
|
|
36
|
+
return super unless RateLimit.enforced? && executions.any?
|
|
37
|
+
|
|
38
|
+
deferred = defer_rate_limited(executions)
|
|
39
|
+
return super if deferred.empty?
|
|
40
|
+
|
|
41
|
+
ActiveSupport::IsolatedExecutionState[:solid_queue_rate_limit_deferred] = true
|
|
42
|
+
|
|
43
|
+
super(executions - deferred, process_id).tap do
|
|
44
|
+
where(id: deferred.map(&:id)).delete_all
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rate_limit_deferred_in_pass?
|
|
49
|
+
ActiveSupport::IsolatedExecutionState.delete(:solid_queue_rate_limit_deferred)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
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
|
|
61
|
+
|
|
62
|
+
def enqueue(active_job, **)
|
|
63
|
+
ensure_rate_limitable if active_job.class.rate_limited?
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
def ensure_rate_limitable
|
|
69
|
+
raise RateLimitState::PendingMigrations unless RateLimitState.migrated?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.apply
|
|
74
|
+
SolidQueue::Job.singleton_class.prepend Enqueuing
|
|
75
|
+
SolidQueue::ReadyExecution.include SolidQueue::ReadyExecution::RateLimited
|
|
76
|
+
SolidQueue::ReadyExecution.singleton_class.prepend Claiming
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class SolidQueue::LogSubscriber
|
|
83
|
+
# A backlog can defer jobs on every claim, so above debug it shouldn't build a line for each
|
|
84
|
+
subscribe_log_level :defer_rate_limited, :debug
|
|
85
|
+
|
|
86
|
+
def defer_rate_limited(event) # :nodoc:
|
|
87
|
+
debug formatted_event(event, action: "Defer rate-limited jobs", **event.payload.slice(:class_name, :job_ids, :size))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "solid_queue"
|
|
4
|
+
|
|
5
|
+
require "active_job/rate_controls"
|
|
6
|
+
require "solid_queue/rate_limit/version"
|
|
7
|
+
require "solid_queue/rate_limit/patches"
|
|
8
|
+
require "solid_queue/rate_limit/engine"
|
|
9
|
+
|
|
10
|
+
module SolidQueue
|
|
11
|
+
module RateLimit
|
|
12
|
+
# Set once any job class declares limits_rate
|
|
13
|
+
mattr_accessor :declared, default: false
|
|
14
|
+
|
|
15
|
+
# Without eager loading, a job's class may not be loaded until its job is claimed
|
|
16
|
+
def self.enforced? # :nodoc:
|
|
17
|
+
declared || !Rails.application.config.eager_load
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: solid_queue-rate_limit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- David Cristofaro
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: solid_queue
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.7.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 1.7.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: appraisal
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: debug
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.9'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.9'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: mocha
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: mysql2
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: pg
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: sqlite3
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: rubocop-rails-omakase
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :development
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: rdoc
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: logger
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '0'
|
|
159
|
+
type: :development
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - ">="
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: '0'
|
|
166
|
+
description: Adds limits_rate to Active Job classes on Solid Queue. Workers admit
|
|
167
|
+
a class's jobs no faster than the declared rate, and over-limit jobs wait as scheduled
|
|
168
|
+
jobs without taking a worker thread.
|
|
169
|
+
email:
|
|
170
|
+
- david@dtcristo.com
|
|
171
|
+
executables: []
|
|
172
|
+
extensions: []
|
|
173
|
+
extra_rdoc_files: []
|
|
174
|
+
files:
|
|
175
|
+
- CHANGELOG.md
|
|
176
|
+
- MIT-LICENSE
|
|
177
|
+
- README.md
|
|
178
|
+
- app/models/solid_queue/rate_limit_state.rb
|
|
179
|
+
- app/models/solid_queue/ready_execution/rate_limited.rb
|
|
180
|
+
- lib/active_job/rate_controls.rb
|
|
181
|
+
- lib/generators/solid_queue/rate_limit/install/install_generator.rb
|
|
182
|
+
- lib/generators/solid_queue/rate_limit/install/templates/db/create_solid_queue_rate_limits.rb
|
|
183
|
+
- lib/solid_queue/rate_limit.rb
|
|
184
|
+
- lib/solid_queue/rate_limit/engine.rb
|
|
185
|
+
- lib/solid_queue/rate_limit/patches.rb
|
|
186
|
+
- lib/solid_queue/rate_limit/version.rb
|
|
187
|
+
homepage: https://github.com/dtcristo/solid_queue-rate_limit
|
|
188
|
+
licenses:
|
|
189
|
+
- MIT
|
|
190
|
+
metadata:
|
|
191
|
+
homepage_uri: https://github.com/dtcristo/solid_queue-rate_limit
|
|
192
|
+
source_code_uri: https://github.com/dtcristo/solid_queue-rate_limit
|
|
193
|
+
documentation_uri: https://www.rubydoc.info/gems/solid_queue-rate_limit
|
|
194
|
+
changelog_uri: https://github.com/dtcristo/solid_queue-rate_limit/blob/main/CHANGELOG.md
|
|
195
|
+
rdoc_options: []
|
|
196
|
+
require_paths:
|
|
197
|
+
- lib
|
|
198
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
199
|
+
requirements:
|
|
200
|
+
- - ">="
|
|
201
|
+
- !ruby/object:Gem::Version
|
|
202
|
+
version: '3.2'
|
|
203
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - ">="
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: '0'
|
|
208
|
+
requirements: []
|
|
209
|
+
rubygems_version: 4.0.20
|
|
210
|
+
specification_version: 4
|
|
211
|
+
summary: Rate limits for Solid Queue jobs.
|
|
212
|
+
test_files: []
|