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 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