sidekiq-queue-throttled 1.0.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 +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +303 -0
- data/lib/sidekiq/queue_throttled/configuration.rb +53 -0
- data/lib/sidekiq/queue_throttled/job.rb +82 -0
- data/lib/sidekiq/queue_throttled/job_throttler.rb +155 -0
- data/lib/sidekiq/queue_throttled/middleware.rb +92 -0
- data/lib/sidekiq/queue_throttled/queue_limiter.rb +110 -0
- data/lib/sidekiq/queue_throttled/version.rb +7 -0
- data/lib/sidekiq/queue_throttled.rb +49 -0
- data/spec/examples.txt +110 -0
- data/spec/sidekiq/queue_throttled/configuration_spec.rb +145 -0
- data/spec/sidekiq/queue_throttled/job_spec.rb +181 -0
- data/spec/sidekiq/queue_throttled/job_throttler_spec.rb +365 -0
- data/spec/sidekiq/queue_throttled/middleware_spec.rb +280 -0
- data/spec/sidekiq/queue_throttled/queue_limiter_spec.rb +217 -0
- data/spec/spec_helper.rb +79 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f04395444525e11488352d423c291bbefa3d15cd0415cea21a6a248fe9ec11f1
|
4
|
+
data.tar.gz: 97e5ee1c7501c66e598fa6638a30a2cb1ba39801dbef610456aa590e2300685c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c8e536136b78764fe9fa29ac5666839fdac9c81f53d6edf23a0725abb9459966f6a5e0b35c3b47099d665a569e28fc222a6bbc5e49f4b4597c8bce0cd7d51db8
|
7
|
+
data.tar.gz: 8dfae89423e246c267efa76eda12237e10885917cb519aea643f6e2868d100dd1db518a6f9d5fe5b27bcb01f41c9bb9b4ca80277ae289a97426a4e6e6556ad28
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,53 @@
|
|
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.0.0/),
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
|
+
|
8
|
+
## [Unreleased]
|
9
|
+
|
10
|
+
### Added
|
11
|
+
- Initial release
|
12
|
+
- Queue-level concurrency limits
|
13
|
+
- Job-level throttling with concurrency and rate limits
|
14
|
+
- Redis-based coordination
|
15
|
+
- Thread-safe implementation
|
16
|
+
- Comprehensive configuration options
|
17
|
+
- Middleware integration with Sidekiq
|
18
|
+
- DSL for job throttling configuration
|
19
|
+
- Error handling and logging
|
20
|
+
- Production-ready features
|
21
|
+
|
22
|
+
### Features
|
23
|
+
- Support for queue limits in sidekiq.yml configuration
|
24
|
+
- Programmatic queue limit configuration
|
25
|
+
- Concurrency-based job throttling with custom key suffixes
|
26
|
+
- Rate-based job throttling with time windows
|
27
|
+
- Automatic job rescheduling when limits are reached
|
28
|
+
- Redis key management with TTL
|
29
|
+
- Concurrent access safety with read-write locks
|
30
|
+
- Comprehensive validation of configuration options
|
31
|
+
|
32
|
+
## [1.0.0] - 2024-01-01
|
33
|
+
|
34
|
+
### Added
|
35
|
+
- Initial release of sidekiq-queue-throttled gem
|
36
|
+
- Queue-level concurrency limiting
|
37
|
+
- Job-level throttling capabilities
|
38
|
+
- Redis-based coordination system
|
39
|
+
- Thread-safe implementation
|
40
|
+
- Comprehensive configuration system
|
41
|
+
- Middleware integration
|
42
|
+
- DSL for job configuration
|
43
|
+
- Error handling and logging
|
44
|
+
- Production-ready features
|
45
|
+
|
46
|
+
### Technical Details
|
47
|
+
- Uses Concurrent::ReentrantReadWriteLock for thread safety
|
48
|
+
- Redis-based counters with TTL for memory management
|
49
|
+
- Automatic job rescheduling with configurable delays
|
50
|
+
- Support for both concurrency and rate limiting
|
51
|
+
- Flexible key suffix resolution (Proc, Symbol, String)
|
52
|
+
- Comprehensive validation of configuration options
|
53
|
+
- Integration with Sidekiq's middleware chain
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Sidekiq Queue Throttled
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,303 @@
|
|
1
|
+
# Sidekiq Queue Throttled
|
2
|
+
|
3
|
+
A production-ready Sidekiq gem that combines queue-level concurrency limits with job-level throttling capabilities. This gem provides the best of both `sidekiq-limit_fetch` and `sidekiq-throttled` in a single, well-tested package.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- **Queue-level limits**: Set maximum concurrent jobs per queue
|
8
|
+
- **Job-level throttling**: Limit jobs by concurrency or rate
|
9
|
+
- **Redis-based**: Scalable across multiple Sidekiq processes
|
10
|
+
- **Production-ready**: Comprehensive error handling and logging
|
11
|
+
- **Thread-safe**: Uses concurrent primitives for safety
|
12
|
+
- **Configurable**: Flexible configuration options
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'sidekiq-queue-throttled'
|
20
|
+
```
|
21
|
+
|
22
|
+
And then execute:
|
23
|
+
|
24
|
+
```bash
|
25
|
+
$ bundle install
|
26
|
+
```
|
27
|
+
|
28
|
+
## Configuration
|
29
|
+
|
30
|
+
### Queue Limits
|
31
|
+
|
32
|
+
Configure queue limits in your `sidekiq.yml`:
|
33
|
+
|
34
|
+
```yaml
|
35
|
+
:concurrency: 25
|
36
|
+
:queues:
|
37
|
+
- [default, 1]
|
38
|
+
- [high, 2]
|
39
|
+
- [low, 1]
|
40
|
+
|
41
|
+
:limits:
|
42
|
+
default: 100
|
43
|
+
high: 50
|
44
|
+
low: 200
|
45
|
+
```
|
46
|
+
|
47
|
+
Or configure programmatically:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
Sidekiq::QueueThrottled.configure do |config|
|
51
|
+
config.set_queue_limit(:default, 100)
|
52
|
+
config.set_queue_limit(:high, 50)
|
53
|
+
config.set_queue_limit(:low, 200)
|
54
|
+
end
|
55
|
+
```
|
56
|
+
|
57
|
+
### Job Throttling
|
58
|
+
|
59
|
+
Include the `Sidekiq::QueueThrottled::Job` module in your job classes:
|
60
|
+
|
61
|
+
```ruby
|
62
|
+
class MyJob
|
63
|
+
include Sidekiq::Job
|
64
|
+
include Sidekiq::QueueThrottled::Job
|
65
|
+
|
66
|
+
sidekiq_options queue: :my_queue
|
67
|
+
|
68
|
+
# Concurrency-based throttling
|
69
|
+
sidekiq_throttle(
|
70
|
+
concurrency: {
|
71
|
+
limit: 10,
|
72
|
+
key_suffix: -> (user_id) { user_id }
|
73
|
+
}
|
74
|
+
)
|
75
|
+
|
76
|
+
def perform(user_id)
|
77
|
+
# Your job logic here
|
78
|
+
end
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
## Usage Examples
|
83
|
+
|
84
|
+
### Basic Queue Limiting
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
# sidekiq.yml
|
88
|
+
:limits:
|
89
|
+
email_queue: 10
|
90
|
+
processing_queue: 5
|
91
|
+
|
92
|
+
# This ensures email_queue never has more than 10 concurrent jobs
|
93
|
+
# and processing_queue never has more than 5 concurrent jobs
|
94
|
+
```
|
95
|
+
|
96
|
+
### Concurrency-based Job Throttling
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
class UserNotificationJob
|
100
|
+
include Sidekiq::Job
|
101
|
+
include Sidekiq::QueueThrottled::Job
|
102
|
+
|
103
|
+
sidekiq_options queue: :notifications
|
104
|
+
|
105
|
+
# Allow maximum 3 concurrent jobs per user
|
106
|
+
sidekiq_throttle(
|
107
|
+
concurrency: {
|
108
|
+
limit: 3,
|
109
|
+
key_suffix: -> (user_id) { user_id }
|
110
|
+
}
|
111
|
+
)
|
112
|
+
|
113
|
+
def perform(user_id, message)
|
114
|
+
# Send notification to user
|
115
|
+
end
|
116
|
+
end
|
117
|
+
```
|
118
|
+
|
119
|
+
### Rate-based Job Throttling
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
class APICallJob
|
123
|
+
include Sidekiq::Job
|
124
|
+
include Sidekiq::QueueThrottled::Job
|
125
|
+
|
126
|
+
sidekiq_options queue: :api_calls
|
127
|
+
|
128
|
+
# Allow maximum 100 jobs per hour per API key
|
129
|
+
sidekiq_throttle(
|
130
|
+
rate: {
|
131
|
+
limit: 100,
|
132
|
+
period: 3600, # 1 hour in seconds
|
133
|
+
key_suffix: -> (api_key) { api_key }
|
134
|
+
}
|
135
|
+
)
|
136
|
+
|
137
|
+
def perform(api_key, endpoint)
|
138
|
+
# Make API call
|
139
|
+
end
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
### Complex Throttling with Multiple Parameters
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
class DataProcessingJob
|
147
|
+
include Sidekiq::Job
|
148
|
+
include Sidekiq::QueueThrottled::Job
|
149
|
+
|
150
|
+
sidekiq_options queue: :processing
|
151
|
+
|
152
|
+
# Allow maximum 5 concurrent jobs per organization per data_type
|
153
|
+
sidekiq_throttle(
|
154
|
+
concurrency: {
|
155
|
+
limit: 5,
|
156
|
+
key_suffix: -> (org_id, data_type) { "#{org_id}:#{data_type}" }
|
157
|
+
}
|
158
|
+
)
|
159
|
+
|
160
|
+
def perform(org_id, data_type, data)
|
161
|
+
# Process data
|
162
|
+
end
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
## Configuration Options
|
167
|
+
|
168
|
+
### Global Configuration
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
Sidekiq::QueueThrottled.configure do |config|
|
172
|
+
# Redis key prefix for all keys
|
173
|
+
config.redis_key_prefix = "sidekiq:queue_throttled"
|
174
|
+
|
175
|
+
# TTL for throttle counters (in seconds)
|
176
|
+
config.throttle_ttl = 3600 # 1 hour
|
177
|
+
|
178
|
+
# TTL for queue locks (in seconds)
|
179
|
+
config.lock_ttl = 300 # 5 minutes
|
180
|
+
|
181
|
+
# Delay before rescheduling blocked jobs (in seconds)
|
182
|
+
config.retry_delay = 5
|
183
|
+
end
|
184
|
+
```
|
185
|
+
|
186
|
+
### Custom Logger
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
Sidekiq::QueueThrottled.logger = Rails.logger
|
190
|
+
```
|
191
|
+
|
192
|
+
### Custom Redis Connection
|
193
|
+
|
194
|
+
```ruby
|
195
|
+
Sidekiq::QueueThrottled.redis = Redis.new(url: ENV['REDIS_URL'])
|
196
|
+
```
|
197
|
+
|
198
|
+
## API Reference
|
199
|
+
|
200
|
+
### Queue Limiter
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
limiter = Sidekiq::QueueThrottled::QueueLimiter.new(queue_name, limit)
|
204
|
+
|
205
|
+
# Acquire a lock (returns lock_id or false)
|
206
|
+
lock_id = limiter.acquire_lock
|
207
|
+
|
208
|
+
# Release a lock
|
209
|
+
limiter.release_lock(lock_id)
|
210
|
+
|
211
|
+
# Get current count
|
212
|
+
current_count = limiter.current_count
|
213
|
+
|
214
|
+
# Get available slots
|
215
|
+
available = limiter.available_slots
|
216
|
+
|
217
|
+
# Reset counters
|
218
|
+
limiter.reset!
|
219
|
+
```
|
220
|
+
|
221
|
+
### Job Throttler
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
throttler = Sidekiq::QueueThrottled::JobThrottler.new(job_class, throttle_config)
|
225
|
+
|
226
|
+
# Check if job can be processed
|
227
|
+
can_process = throttler.can_process?(args)
|
228
|
+
|
229
|
+
# Acquire a slot
|
230
|
+
acquired = throttler.acquire_slot(args)
|
231
|
+
|
232
|
+
# Release a slot
|
233
|
+
throttler.release_slot(args)
|
234
|
+
```
|
235
|
+
|
236
|
+
## Monitoring and Debugging
|
237
|
+
|
238
|
+
### Redis Keys
|
239
|
+
|
240
|
+
The gem uses the following Redis key patterns:
|
241
|
+
|
242
|
+
- Queue counters: `sidekiq:queue_throttled:queue:{queue_name}:counter`
|
243
|
+
- Queue locks: `sidekiq:queue_throttled:queue:{queue_name}:lock`
|
244
|
+
- Concurrency counters: `sidekiq:queue_throttled:concurrency:{job_class}:{key_suffix}`
|
245
|
+
- Rate counters: `sidekiq:queue_throttled:rate:{job_class}:{key_suffix}:{window}`
|
246
|
+
|
247
|
+
### Logging
|
248
|
+
|
249
|
+
The gem logs important events:
|
250
|
+
|
251
|
+
```
|
252
|
+
INFO: Queue limit reached for email_queue, rescheduling job
|
253
|
+
INFO: Job throttling limit reached for UserNotificationJob, rescheduling job
|
254
|
+
ERROR: Failed to release lock for queue email_queue: Redis connection error
|
255
|
+
```
|
256
|
+
|
257
|
+
## Testing
|
258
|
+
|
259
|
+
```ruby
|
260
|
+
# In your test setup
|
261
|
+
require 'sidekiq/queue_throttled'
|
262
|
+
|
263
|
+
RSpec.configure do |config|
|
264
|
+
config.before(:each) do
|
265
|
+
# Reset all counters
|
266
|
+
Sidekiq::QueueThrottled.redis.flushdb
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
# Test queue limits
|
271
|
+
RSpec.describe "Queue Limiting" do
|
272
|
+
it "respects queue limits" do
|
273
|
+
limiter = Sidekiq::QueueThrottled::QueueLimiter.new("test_queue", 2)
|
274
|
+
|
275
|
+
expect(limiter.acquire_lock).to be_truthy
|
276
|
+
expect(limiter.acquire_lock).to be_truthy
|
277
|
+
expect(limiter.acquire_lock).to be_falsey # Limit reached
|
278
|
+
end
|
279
|
+
end
|
280
|
+
```
|
281
|
+
|
282
|
+
## Performance Considerations
|
283
|
+
|
284
|
+
- **Redis Operations**: The gem uses Redis for coordination, so ensure your Redis instance can handle the load
|
285
|
+
- **Memory Usage**: Counters are stored in Redis with TTL, so memory usage is bounded
|
286
|
+
- **Network Latency**: Consider Redis network latency when setting TTL values
|
287
|
+
- **Concurrent Access**: The gem uses thread-safe primitives for concurrent access
|
288
|
+
|
289
|
+
## Contributing
|
290
|
+
|
291
|
+
1. Fork the repository
|
292
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
293
|
+
3. Commit your changes (`git commit -am 'Add some amazing feature'`)
|
294
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
295
|
+
5. Create a new Pull Request
|
296
|
+
|
297
|
+
## License
|
298
|
+
|
299
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
300
|
+
|
301
|
+
## Changelog
|
302
|
+
|
303
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of changes and version history.
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module QueueThrottled
|
5
|
+
class Configuration
|
6
|
+
attr_accessor :queue_limits, :redis_key_prefix, :throttle_ttl, :lock_ttl, :retry_delay
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@queue_limits = {}
|
10
|
+
@redis_key_prefix = 'sidekiq:queue_throttled'
|
11
|
+
@throttle_ttl = 3600 # 1 hour
|
12
|
+
@lock_ttl = 300 # 5 minutes
|
13
|
+
@retry_delay = 5 # 5 seconds
|
14
|
+
end
|
15
|
+
|
16
|
+
def queue_limit(queue_name)
|
17
|
+
@queue_limits[queue_name.to_s] || @queue_limits[queue_name.to_sym]
|
18
|
+
end
|
19
|
+
|
20
|
+
def set_queue_limit(queue_name, limit)
|
21
|
+
@queue_limits[queue_name.to_s] = limit.to_i
|
22
|
+
end
|
23
|
+
|
24
|
+
def load_from_sidekiq_config!(sidekiq_config = nil)
|
25
|
+
limits = sidekiq_config&.dig(:limits) || sidekiq_config&.dig('limits')
|
26
|
+
return unless limits
|
27
|
+
|
28
|
+
limits.each do |queue_name, limit|
|
29
|
+
set_queue_limit(queue_name, limit)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def load_from_yaml!(yaml_content)
|
34
|
+
require 'yaml'
|
35
|
+
config = YAML.safe_load(yaml_content)
|
36
|
+
limits = config['limits'] || config[:limits]
|
37
|
+
return unless limits
|
38
|
+
|
39
|
+
limits.each do |queue_name, limit|
|
40
|
+
set_queue_limit(queue_name, limit)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate!
|
45
|
+
@queue_limits.each do |queue_name, limit|
|
46
|
+
unless limit.is_a?(Integer) && limit.positive?
|
47
|
+
raise ArgumentError, "Queue limit for '#{queue_name}' must be a positive integer, got: #{limit}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module QueueThrottled
|
5
|
+
module Job
|
6
|
+
def self.included(base)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
base.class_eval do
|
9
|
+
# Simple class attribute implementation
|
10
|
+
class << self
|
11
|
+
attr_accessor :sidekiq_throttle_config
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.sidekiq_throttle_config
|
15
|
+
@sidekiq_throttle_config
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.sidekiq_throttle_config=(value)
|
19
|
+
@sidekiq_throttle_config = value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module ClassMethods
|
25
|
+
def sidekiq_throttle(options = {})
|
26
|
+
validate_throttle_options!(options)
|
27
|
+
self.sidekiq_throttle_config = options
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def validate_throttle_options!(options)
|
33
|
+
return if options.empty?
|
34
|
+
|
35
|
+
if options[:concurrency] && options[:rate]
|
36
|
+
raise ArgumentError, 'Cannot specify both concurrency and rate limits'
|
37
|
+
end
|
38
|
+
|
39
|
+
if options[:concurrency]
|
40
|
+
validate_concurrency_options!(options[:concurrency])
|
41
|
+
end
|
42
|
+
|
43
|
+
if options[:rate]
|
44
|
+
validate_rate_options!(options[:rate])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_concurrency_options!(concurrency)
|
49
|
+
unless concurrency.is_a?(Hash)
|
50
|
+
raise ArgumentError, 'Concurrency must be a hash'
|
51
|
+
end
|
52
|
+
|
53
|
+
unless concurrency[:limit].is_a?(Integer) && concurrency[:limit].positive?
|
54
|
+
raise ArgumentError, 'Concurrency limit must be a positive integer'
|
55
|
+
end
|
56
|
+
|
57
|
+
unless concurrency[:key_suffix]
|
58
|
+
raise ArgumentError, 'Concurrency key_suffix is required'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def validate_rate_options!(rate)
|
63
|
+
unless rate.is_a?(Hash)
|
64
|
+
raise ArgumentError, 'Rate must be a hash'
|
65
|
+
end
|
66
|
+
|
67
|
+
unless rate[:limit].is_a?(Integer) && rate[:limit].positive?
|
68
|
+
raise ArgumentError, 'Rate limit must be a positive integer'
|
69
|
+
end
|
70
|
+
|
71
|
+
unless rate[:period].nil? || (rate[:period].is_a?(Integer) && rate[:period].positive?)
|
72
|
+
raise ArgumentError, 'Rate period must be a positive integer'
|
73
|
+
end
|
74
|
+
|
75
|
+
unless rate[:key_suffix]
|
76
|
+
raise ArgumentError, 'Rate key_suffix is required'
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sidekiq
|
4
|
+
module QueueThrottled
|
5
|
+
class JobThrottler
|
6
|
+
attr_reader :job_class, :throttle_config, :redis
|
7
|
+
|
8
|
+
def initialize(job_class, throttle_config, redis = nil)
|
9
|
+
@job_class = job_class
|
10
|
+
@throttle_config = throttle_config
|
11
|
+
@redis = redis || Sidekiq::QueueThrottled.redis
|
12
|
+
@mutex = Concurrent::ReentrantReadWriteLock.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def can_process?(args)
|
16
|
+
return true unless @throttle_config
|
17
|
+
|
18
|
+
@mutex.with_read_lock do
|
19
|
+
check_concurrency_limit(args) && check_rate_limit(args)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def acquire_slot(args)
|
24
|
+
return true unless @throttle_config
|
25
|
+
|
26
|
+
@mutex.with_write_lock do
|
27
|
+
return false unless can_process?(args)
|
28
|
+
|
29
|
+
if @throttle_config[:concurrency]
|
30
|
+
acquire_concurrency_slot(args)
|
31
|
+
elsif @throttle_config[:rate]
|
32
|
+
acquire_rate_slot(args)
|
33
|
+
else
|
34
|
+
true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def release_slot(args)
|
40
|
+
return true unless @throttle_config
|
41
|
+
|
42
|
+
@mutex.with_write_lock do
|
43
|
+
if @throttle_config[:concurrency]
|
44
|
+
release_concurrency_slot(args)
|
45
|
+
end
|
46
|
+
# Rate limiting slots are not released - they expire naturally
|
47
|
+
end
|
48
|
+
|
49
|
+
true
|
50
|
+
rescue StandardError => e
|
51
|
+
Sidekiq::QueueThrottled.logger.error "Failed to release slot for job #{@job_class}: #{e.message}"
|
52
|
+
false
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def check_concurrency_limit(args)
|
58
|
+
return true unless @throttle_config[:concurrency]
|
59
|
+
|
60
|
+
config = @throttle_config[:concurrency]
|
61
|
+
limit = config[:limit]
|
62
|
+
key_suffix = resolve_key_suffix(config[:key_suffix], args)
|
63
|
+
current_count = get_concurrency_count(key_suffix)
|
64
|
+
|
65
|
+
current_count < limit
|
66
|
+
end
|
67
|
+
|
68
|
+
def check_rate_limit(args)
|
69
|
+
return true unless @throttle_config[:rate]
|
70
|
+
|
71
|
+
config = @throttle_config[:rate]
|
72
|
+
limit = config[:limit]
|
73
|
+
period = config[:period] || 60
|
74
|
+
key_suffix = resolve_key_suffix(config[:key_suffix], args)
|
75
|
+
|
76
|
+
current_count = get_rate_count(key_suffix, period)
|
77
|
+
current_count < limit
|
78
|
+
end
|
79
|
+
|
80
|
+
def acquire_concurrency_slot(args)
|
81
|
+
config = @throttle_config[:concurrency]
|
82
|
+
key_suffix = resolve_key_suffix(config[:key_suffix], args)
|
83
|
+
key = concurrency_key(key_suffix)
|
84
|
+
|
85
|
+
@redis.multi do |multi|
|
86
|
+
multi.incr(key)
|
87
|
+
multi.expire(key, Sidekiq::QueueThrottled.configuration.throttle_ttl)
|
88
|
+
end
|
89
|
+
|
90
|
+
true
|
91
|
+
end
|
92
|
+
|
93
|
+
def release_concurrency_slot(args)
|
94
|
+
config = @throttle_config[:concurrency]
|
95
|
+
key_suffix = resolve_key_suffix(config[:key_suffix], args)
|
96
|
+
key = concurrency_key(key_suffix)
|
97
|
+
|
98
|
+
@redis.multi do |multi|
|
99
|
+
multi.decr(key)
|
100
|
+
multi.expire(key, Sidekiq::QueueThrottled.configuration.throttle_ttl)
|
101
|
+
end
|
102
|
+
|
103
|
+
true
|
104
|
+
end
|
105
|
+
|
106
|
+
def acquire_rate_slot(args)
|
107
|
+
config = @throttle_config[:rate]
|
108
|
+
period = config[:period] || 60
|
109
|
+
key_suffix = resolve_key_suffix(config[:key_suffix], args)
|
110
|
+
key = rate_key(key_suffix, period)
|
111
|
+
|
112
|
+
@redis.multi do |multi|
|
113
|
+
multi.incr(key)
|
114
|
+
multi.expire(key, period)
|
115
|
+
end
|
116
|
+
|
117
|
+
true
|
118
|
+
end
|
119
|
+
|
120
|
+
def get_concurrency_count(key_suffix)
|
121
|
+
key = concurrency_key(key_suffix)
|
122
|
+
count = @redis.get(key)
|
123
|
+
count ? count.to_i : 0
|
124
|
+
end
|
125
|
+
|
126
|
+
def get_rate_count(key_suffix, period)
|
127
|
+
key = rate_key(key_suffix, period)
|
128
|
+
count = @redis.get(key)
|
129
|
+
count ? count.to_i : 0
|
130
|
+
end
|
131
|
+
|
132
|
+
def concurrency_key(key_suffix)
|
133
|
+
"#{Sidekiq::QueueThrottled.configuration.redis_key_prefix}:concurrency:#{@job_class}:#{key_suffix}"
|
134
|
+
end
|
135
|
+
|
136
|
+
def rate_key(key_suffix, period)
|
137
|
+
window = Time.now.to_i / period
|
138
|
+
"#{Sidekiq::QueueThrottled.configuration.redis_key_prefix}:rate:#{@job_class}:#{key_suffix}:#{window}"
|
139
|
+
end
|
140
|
+
|
141
|
+
def resolve_key_suffix(key_suffix, args)
|
142
|
+
case key_suffix
|
143
|
+
when Proc
|
144
|
+
key_suffix.call(*args)
|
145
|
+
when Symbol
|
146
|
+
args.first.send(key_suffix) if args.first.respond_to?(key_suffix)
|
147
|
+
when String
|
148
|
+
key_suffix
|
149
|
+
else
|
150
|
+
'default'
|
151
|
+
end.to_s
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|