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 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
@@ -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,5 @@
1
+ module SolidQueue
2
+ module RateLimit
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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: []