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
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe Sidekiq::QueueThrottled::Job do
|
6
|
+
describe 'DSL' do
|
7
|
+
let(:job_class) do
|
8
|
+
create_test_job_class('TestJob') do
|
9
|
+
sidekiq_throttle(
|
10
|
+
concurrency: {
|
11
|
+
limit: 10,
|
12
|
+
key_suffix: ->(user_id) { user_id }
|
13
|
+
}
|
14
|
+
)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'sets throttle config on class' do
|
19
|
+
config = job_class.sidekiq_throttle_config
|
20
|
+
expect(config[:concurrency][:limit]).to eq(10)
|
21
|
+
expect(config[:concurrency][:key_suffix]).to be_a(Proc)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'allows empty throttle config' do
|
25
|
+
job_class = create_test_job_class('EmptyJob') do
|
26
|
+
sidekiq_throttle({})
|
27
|
+
end
|
28
|
+
expect(job_class.sidekiq_throttle_config).to eq({})
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe 'validation' do
|
33
|
+
it 'raises error for both concurrency and rate' do
|
34
|
+
expect do
|
35
|
+
create_test_job_class('InvalidJob') do
|
36
|
+
sidekiq_throttle(
|
37
|
+
concurrency: { limit: 10, key_suffix: ->(id) { id } },
|
38
|
+
rate: { limit: 100, period: 60, key_suffix: ->(id) { id } }
|
39
|
+
)
|
40
|
+
end
|
41
|
+
end.to raise_error(ArgumentError, /Cannot specify both concurrency and rate limits/)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'raises error for invalid concurrency config' do
|
45
|
+
expect do
|
46
|
+
create_test_job_class('InvalidJob') do
|
47
|
+
sidekiq_throttle(concurrency: 'invalid')
|
48
|
+
end
|
49
|
+
end.to raise_error(ArgumentError, /Concurrency must be a hash/)
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'raises error for missing concurrency limit' do
|
53
|
+
expect do
|
54
|
+
create_test_job_class('InvalidJob') do
|
55
|
+
sidekiq_throttle(
|
56
|
+
concurrency: { key_suffix: ->(id) { id } }
|
57
|
+
)
|
58
|
+
end
|
59
|
+
end.to raise_error(ArgumentError, /Concurrency limit must be a positive integer/)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'raises error for non-positive concurrency limit' do
|
63
|
+
expect do
|
64
|
+
create_test_job_class('InvalidJob') do
|
65
|
+
sidekiq_throttle(
|
66
|
+
concurrency: { limit: 0, key_suffix: ->(id) { id } }
|
67
|
+
)
|
68
|
+
end
|
69
|
+
end.to raise_error(ArgumentError, /Concurrency limit must be a positive integer/)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'raises error for missing concurrency key_suffix' do
|
73
|
+
expect do
|
74
|
+
create_test_job_class('InvalidJob') do
|
75
|
+
sidekiq_throttle(concurrency: { limit: 10 })
|
76
|
+
end
|
77
|
+
end.to raise_error(ArgumentError, /Concurrency key_suffix is required/)
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'raises error for invalid rate config' do
|
81
|
+
expect do
|
82
|
+
create_test_job_class('InvalidJob') do
|
83
|
+
sidekiq_throttle(rate: 'invalid')
|
84
|
+
end
|
85
|
+
end.to raise_error(ArgumentError, /Rate must be a hash/)
|
86
|
+
end
|
87
|
+
|
88
|
+
it 'raises error for missing rate limit' do
|
89
|
+
expect do
|
90
|
+
create_test_job_class('InvalidJob') do
|
91
|
+
sidekiq_throttle(
|
92
|
+
rate: { period: 60, key_suffix: ->(id) { id } }
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end.to raise_error(ArgumentError, /Rate limit must be a positive integer/)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'raises error for non-positive rate limit' do
|
99
|
+
expect do
|
100
|
+
create_test_job_class('InvalidJob') do
|
101
|
+
sidekiq_throttle(
|
102
|
+
rate: { limit: 0, period: 60, key_suffix: ->(id) { id } }
|
103
|
+
)
|
104
|
+
end
|
105
|
+
end.to raise_error(ArgumentError, /Rate limit must be a positive integer/)
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'raises error for non-positive rate period' do
|
109
|
+
expect do
|
110
|
+
create_test_job_class('InvalidJob') do
|
111
|
+
sidekiq_throttle(
|
112
|
+
rate: { limit: 100, period: 0, key_suffix: ->(id) { id } }
|
113
|
+
)
|
114
|
+
end
|
115
|
+
end.to raise_error(ArgumentError, /Rate period must be a positive integer/)
|
116
|
+
end
|
117
|
+
|
118
|
+
it 'raises error for missing rate key_suffix' do
|
119
|
+
expect do
|
120
|
+
create_test_job_class('InvalidJob') do
|
121
|
+
sidekiq_throttle(rate: { limit: 100, period: 60 })
|
122
|
+
end
|
123
|
+
end.to raise_error(ArgumentError, /Rate key_suffix is required/)
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'accepts valid concurrency config' do
|
127
|
+
expect do
|
128
|
+
create_test_job_class('ValidJob') do
|
129
|
+
sidekiq_throttle(
|
130
|
+
concurrency: {
|
131
|
+
limit: 10,
|
132
|
+
key_suffix: ->(user_id) { user_id }
|
133
|
+
}
|
134
|
+
)
|
135
|
+
end
|
136
|
+
end.not_to raise_error
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'accepts valid rate config' do
|
140
|
+
expect do
|
141
|
+
create_test_job_class('ValidJob') do
|
142
|
+
sidekiq_throttle(
|
143
|
+
rate: {
|
144
|
+
limit: 100,
|
145
|
+
period: 60,
|
146
|
+
key_suffix: ->(api_key) { api_key }
|
147
|
+
}
|
148
|
+
)
|
149
|
+
end
|
150
|
+
end.not_to raise_error
|
151
|
+
end
|
152
|
+
|
153
|
+
it 'accepts rate config without period' do
|
154
|
+
expect do
|
155
|
+
create_test_job_class('ValidJob') do
|
156
|
+
sidekiq_throttle(
|
157
|
+
rate: {
|
158
|
+
limit: 100,
|
159
|
+
key_suffix: ->(api_key) { api_key }
|
160
|
+
}
|
161
|
+
)
|
162
|
+
end
|
163
|
+
end.not_to raise_error
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
describe 'integration with Sidekiq::Job' do
|
168
|
+
it 'includes Sidekiq::Job methods' do
|
169
|
+
job_class = create_test_job_class('IntegrationJob')
|
170
|
+
job = job_class.new
|
171
|
+
expect(job).to respond_to(:perform)
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'allows sidekiq_options' do
|
175
|
+
job_class = create_test_job_class('OptionsJob') do
|
176
|
+
sidekiq_options queue: :test_queue, retry: 3
|
177
|
+
end
|
178
|
+
expect(job_class.get_sidekiq_options).to include('queue' => :test_queue, 'retry' => 3)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
@@ -0,0 +1,365 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe Sidekiq::QueueThrottled::JobThrottler do
|
6
|
+
let(:job_class) { 'TestJob' }
|
7
|
+
let(:throttler) { described_class.new(job_class, throttle_config) }
|
8
|
+
|
9
|
+
describe '#initialize' do
|
10
|
+
let(:throttle_config) { nil }
|
11
|
+
|
12
|
+
it 'initializes without throttle config' do
|
13
|
+
expect(throttler.job_class).to eq(job_class)
|
14
|
+
expect(throttler.throttle_config).to be_nil
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'uses provided redis connection' do
|
18
|
+
custom_redis = double('redis')
|
19
|
+
throttler = described_class.new(job_class, nil, custom_redis)
|
20
|
+
expect(throttler.redis).to eq(custom_redis)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '#can_process?' do
|
25
|
+
context 'without throttle config' do
|
26
|
+
let(:throttle_config) { nil }
|
27
|
+
|
28
|
+
it 'returns true' do
|
29
|
+
expect(throttler.can_process?([1, 2, 3])).to be_truthy
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context 'with concurrency config' do
|
34
|
+
let(:throttle_config) do
|
35
|
+
{
|
36
|
+
concurrency: {
|
37
|
+
limit: 2,
|
38
|
+
key_suffix: ->(user_id) { user_id }
|
39
|
+
}
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'returns true when under limit' do
|
44
|
+
expect(throttler.can_process?([123])).to be_truthy
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'returns false when at limit' do
|
48
|
+
throttler.acquire_slot([123])
|
49
|
+
throttler.acquire_slot([123])
|
50
|
+
expect(throttler.can_process?([123])).to be_falsey
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'allows different key suffixes' do
|
54
|
+
throttler.acquire_slot([123])
|
55
|
+
throttler.acquire_slot([123])
|
56
|
+
expect(throttler.can_process?([456])).to be_truthy
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
context 'with rate config' do
|
61
|
+
let(:throttle_config) do
|
62
|
+
{
|
63
|
+
rate: {
|
64
|
+
limit: 2,
|
65
|
+
period: 60,
|
66
|
+
key_suffix: ->(api_key) { api_key }
|
67
|
+
}
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'returns true when under limit' do
|
72
|
+
expect(throttler.can_process?(['key123'])).to be_truthy
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'returns false when at limit' do
|
76
|
+
throttler.acquire_slot(['key123'])
|
77
|
+
throttler.acquire_slot(['key123'])
|
78
|
+
expect(throttler.can_process?(['key123'])).to be_falsey
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'resets after period' do
|
82
|
+
throttler.acquire_slot(['key123'])
|
83
|
+
throttler.acquire_slot(['key123'])
|
84
|
+
|
85
|
+
Timecop.travel(Time.now + 61) do
|
86
|
+
expect(throttler.can_process?(['key123'])).to be_truthy
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
context 'with invalid key_suffix' do
|
92
|
+
let(:throttle_config) do
|
93
|
+
{
|
94
|
+
concurrency: {
|
95
|
+
limit: 1,
|
96
|
+
key_suffix: nil
|
97
|
+
}
|
98
|
+
}
|
99
|
+
end
|
100
|
+
|
101
|
+
it 'uses default key' do
|
102
|
+
expect(throttler.can_process?([123])).to be_truthy
|
103
|
+
throttler.acquire_slot([123])
|
104
|
+
expect(throttler.can_process?([456])).to be_falsey # Same default key
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe '#acquire_slot' do
|
110
|
+
context 'without throttle config' do
|
111
|
+
let(:throttle_config) { nil }
|
112
|
+
|
113
|
+
it 'returns true' do
|
114
|
+
expect(throttler.acquire_slot([1, 2, 3])).to be_truthy
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
context 'with concurrency config' do
|
119
|
+
let(:throttle_config) do
|
120
|
+
{
|
121
|
+
concurrency: {
|
122
|
+
limit: 2,
|
123
|
+
key_suffix: ->(user_id) { user_id }
|
124
|
+
}
|
125
|
+
}
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'acquires slot when under limit' do
|
129
|
+
expect(throttler.acquire_slot([123])).to be_truthy
|
130
|
+
end
|
131
|
+
|
132
|
+
it 'fails to acquire slot when at limit' do
|
133
|
+
throttler.acquire_slot([123])
|
134
|
+
throttler.acquire_slot([123])
|
135
|
+
expect(throttler.acquire_slot([123])).to be_falsey
|
136
|
+
end
|
137
|
+
|
138
|
+
it 'allows different key suffixes' do
|
139
|
+
throttler.acquire_slot([123])
|
140
|
+
throttler.acquire_slot([123])
|
141
|
+
expect(throttler.acquire_slot([456])).to be_truthy
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
context 'with rate config' do
|
146
|
+
let(:throttle_config) do
|
147
|
+
{
|
148
|
+
rate: {
|
149
|
+
limit: 2,
|
150
|
+
period: 60,
|
151
|
+
key_suffix: ->(api_key) { api_key }
|
152
|
+
}
|
153
|
+
}
|
154
|
+
end
|
155
|
+
|
156
|
+
it 'acquires slot when under limit' do
|
157
|
+
expect(throttler.acquire_slot(['key123'])).to be_truthy
|
158
|
+
end
|
159
|
+
|
160
|
+
it 'fails to acquire slot when at limit' do
|
161
|
+
throttler.acquire_slot(['key123'])
|
162
|
+
throttler.acquire_slot(['key123'])
|
163
|
+
expect(throttler.acquire_slot(['key123'])).to be_falsey
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe '#release_slot' do
|
169
|
+
context 'without throttle config' do
|
170
|
+
let(:throttle_config) { nil }
|
171
|
+
|
172
|
+
it 'returns true' do
|
173
|
+
expect(throttler.release_slot([1, 2, 3])).to be_truthy
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
context 'with concurrency config' do
|
178
|
+
let(:throttle_config) do
|
179
|
+
{
|
180
|
+
concurrency: {
|
181
|
+
limit: 2,
|
182
|
+
key_suffix: ->(user_id) { user_id }
|
183
|
+
}
|
184
|
+
}
|
185
|
+
end
|
186
|
+
|
187
|
+
it 'releases slot and decrements counter' do
|
188
|
+
throttler.acquire_slot([123])
|
189
|
+
expect(throttler.can_process?([123])).to be_truthy
|
190
|
+
|
191
|
+
throttler.release_slot([123])
|
192
|
+
expect(throttler.can_process?([123])).to be_truthy
|
193
|
+
end
|
194
|
+
|
195
|
+
it 'handles redis errors gracefully' do
|
196
|
+
allow(throttler.redis).to receive(:multi).and_raise(Redis::BaseError.new('Connection error'))
|
197
|
+
|
198
|
+
expect(throttler.release_slot([123])).to be_falsey
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
context 'with rate config' do
|
203
|
+
let(:throttle_config) do
|
204
|
+
{
|
205
|
+
rate: {
|
206
|
+
limit: 2,
|
207
|
+
period: 60,
|
208
|
+
key_suffix: ->(api_key) { api_key }
|
209
|
+
}
|
210
|
+
}
|
211
|
+
end
|
212
|
+
|
213
|
+
it "returns true (rate limiting doesn't need release)" do
|
214
|
+
expect(throttler.release_slot(['key123'])).to be_truthy
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
describe 'key suffix resolution' do
|
220
|
+
context 'with proc key_suffix' do
|
221
|
+
let(:throttle_config) do
|
222
|
+
{
|
223
|
+
concurrency: {
|
224
|
+
limit: 2,
|
225
|
+
key_suffix: ->(user_id, org_id) { "#{user_id}:#{org_id}" }
|
226
|
+
}
|
227
|
+
}
|
228
|
+
end
|
229
|
+
|
230
|
+
it 'calls proc with arguments' do
|
231
|
+
expect(throttler.can_process?([123, 456])).to be_truthy
|
232
|
+
throttler.acquire_slot([123, 456])
|
233
|
+
expect(throttler.can_process?([123, 456])).to be_truthy
|
234
|
+
expect(throttler.can_process?([123, 789])).to be_truthy
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
context 'with symbol key_suffix' do
|
239
|
+
let(:throttle_config) do
|
240
|
+
{
|
241
|
+
concurrency: {
|
242
|
+
limit: 2,
|
243
|
+
key_suffix: :id
|
244
|
+
}
|
245
|
+
}
|
246
|
+
end
|
247
|
+
|
248
|
+
it 'calls method on first argument' do
|
249
|
+
user = double('user', id: 123)
|
250
|
+
expect(throttler.can_process?([user])).to be_truthy
|
251
|
+
throttler.acquire_slot([user])
|
252
|
+
expect(throttler.can_process?([user])).to be_truthy
|
253
|
+
end
|
254
|
+
|
255
|
+
it 'handles non-responder gracefully' do
|
256
|
+
expect(throttler.can_process?([123])).to be_truthy
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
context 'with string key_suffix' do
|
261
|
+
let(:throttle_config) do
|
262
|
+
{
|
263
|
+
concurrency: {
|
264
|
+
limit: 1,
|
265
|
+
key_suffix: 'fixed_key'
|
266
|
+
}
|
267
|
+
}
|
268
|
+
end
|
269
|
+
|
270
|
+
it 'uses string as key' do
|
271
|
+
expect(throttler.can_process?([123])).to be_truthy
|
272
|
+
throttler.acquire_slot([123])
|
273
|
+
expect(throttler.can_process?([456])).to be_falsey # Same key
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
context 'with invalid key_suffix' do
|
278
|
+
let(:throttle_config) do
|
279
|
+
{
|
280
|
+
concurrency: {
|
281
|
+
limit: 1,
|
282
|
+
key_suffix: nil
|
283
|
+
}
|
284
|
+
}
|
285
|
+
end
|
286
|
+
|
287
|
+
it 'uses default key' do
|
288
|
+
expect(throttler.can_process?([123])).to be_truthy
|
289
|
+
throttler.acquire_slot([123])
|
290
|
+
expect(throttler.can_process?([456])).to be_falsey # Same default key
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
describe 'redis key management' do
|
296
|
+
let(:throttle_config) do
|
297
|
+
{
|
298
|
+
concurrency: {
|
299
|
+
limit: 2,
|
300
|
+
key_suffix: ->(user_id) { user_id }
|
301
|
+
}
|
302
|
+
}
|
303
|
+
end
|
304
|
+
|
305
|
+
it 'uses correct redis key prefix' do
|
306
|
+
throttler.acquire_slot([123])
|
307
|
+
|
308
|
+
keys = throttler.redis.keys('sidekiq:queue_throttled:concurrency:*')
|
309
|
+
expect(keys).not_to be_empty
|
310
|
+
end
|
311
|
+
|
312
|
+
it 'sets TTL on concurrency keys' do
|
313
|
+
throttler.acquire_slot([123])
|
314
|
+
|
315
|
+
key = throttler.redis.keys('sidekiq:queue_throttled:concurrency:*').first
|
316
|
+
ttl = throttler.redis.ttl(key)
|
317
|
+
expect(ttl).to be > 0
|
318
|
+
end
|
319
|
+
|
320
|
+
it 'sets TTL on rate keys' do
|
321
|
+
rate_config = {
|
322
|
+
rate: {
|
323
|
+
limit: 2,
|
324
|
+
period: 60,
|
325
|
+
key_suffix: ->(api_key) { api_key }
|
326
|
+
}
|
327
|
+
}
|
328
|
+
rate_throttler = described_class.new(job_class, rate_config)
|
329
|
+
rate_throttler.acquire_slot(['key123'])
|
330
|
+
|
331
|
+
key = rate_throttler.redis.keys('sidekiq:queue_throttled:rate:*').first
|
332
|
+
ttl = rate_throttler.redis.ttl(key)
|
333
|
+
expect(ttl).to be > 0
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
describe 'concurrent access' do
|
338
|
+
let(:throttle_config) do
|
339
|
+
{
|
340
|
+
concurrency: {
|
341
|
+
limit: 3,
|
342
|
+
key_suffix: ->(user_id) { user_id }
|
343
|
+
}
|
344
|
+
}
|
345
|
+
end
|
346
|
+
|
347
|
+
it 'handles concurrent slot acquisitions' do
|
348
|
+
threads = []
|
349
|
+
results = []
|
350
|
+
|
351
|
+
5.times do
|
352
|
+
threads << Thread.new do
|
353
|
+
result = throttler.acquire_slot([123])
|
354
|
+
results << result
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
threads.each(&:join)
|
359
|
+
|
360
|
+
# Should only acquire 3 slots (the limit)
|
361
|
+
acquired_slots = results.count { |r| r }
|
362
|
+
expect(acquired_slots).to eq(3)
|
363
|
+
end
|
364
|
+
end
|
365
|
+
end
|