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