sidekiq-throttler 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f178571c0d6b730e0f2dda4af252fd1c3322219f
4
- data.tar.gz: a1ecdbffc68b36c4cd2d879fd7fd10da7e7a1b7b
3
+ metadata.gz: 0258db1754f26622f1f38b1875774946a6a1c16e
4
+ data.tar.gz: 151f57ccb677fd3ad6cd25cd30ed605da04acb97
5
5
  SHA512:
6
- metadata.gz: 3f957bb16f42a8e0024ca3bb961e227ed1f24659cc6863ba5eedb02915ee2065f61224207ffc05af771eccbd8f4bf2a2998b457418063eaee8322f6844c68280
7
- data.tar.gz: 43062db0499c028ee9381bb6e6aea06f9409b4023cb0add9969e17c9747da3240badfcb30abbc5590bdc1b811e46256dd29daf38c76e240a51cae061221ef130
6
+ metadata.gz: 91440b76579924fc449a60c749ce5b88619c5efacfa5fabc45271b7f3f6a72b7408bc2b9b954e99d5aa33ab0b52a18addc957c9753fe4854b521c82738aec389
7
+ data.tar.gz: 9b57fbf2ae12af64aaf5667d658da9e2fb6af6ba3099373376eedad86c422a13a879a80137f7f9c8d2244dcb91a7db18bbb01555d43c6a57b37f2af999d5ece5
data/.travis.yml CHANGED
@@ -1,18 +1,17 @@
1
1
  language: ruby
2
2
  rvm:
3
+ - 2.0.0
3
4
  - 1.9.3
4
5
  - jruby-19mode
5
6
  - rbx-19mode
6
- - 2.0.0
7
7
  branches:
8
8
  only:
9
9
  - master
10
10
  notifications:
11
11
  email:
12
12
  recipients:
13
- - gabriel@codeconcoction.com
13
+ - gabe@ga.be
14
14
  matrix:
15
15
  allow_failures:
16
16
  - rvm: jruby-19mode
17
17
  - rvm: rbx-19mode
18
- - rvm: 2.0.0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.3.0 (October 3, 2013)
2
+
3
+ * Redis is supported as a storage backend for persistence of job execution
4
+ counters across multiple Sidekiq processes.
5
+
6
+ *Louis Simoneau*
7
+
8
+ * Only Active Support's `Time` extensions are required. Fixes compatibility with
9
+ Rails 4.
10
+
11
+ *Louis Simoneau*
12
+
1
13
  ## 0.2.0 (June 29, 2013)
2
14
 
3
15
  * Now supports using a Proc for `:threshold` argument, similar to how the
data/README.md CHANGED
@@ -8,7 +8,7 @@ limit job execution on a per-worker basis.
8
8
 
9
9
  ## Compatibility
10
10
 
11
- Sidekiq::Throttler is tested against MRI 1.9.3.
11
+ Sidekiq::Throttler is actively tested against MRI versions 2.0.0 and 1.9.3.
12
12
 
13
13
  ## Installation
14
14
 
@@ -37,6 +37,18 @@ Sidekiq.configure_server do |config|
37
37
  end
38
38
  ```
39
39
 
40
+ Sidekiq::Throttler defaults to in-memory storage of job execution times. If
41
+ you have multiple worker processes, or frequently restart your processes, this
42
+ will be unreliable. Instead, specify the `:redis` storage option:
43
+
44
+ ```ruby
45
+ Sidekiq.configure_server do |config|
46
+ config.server_middleware do |chain|
47
+ chain.add Sidekiq::Throttler, storage: :redis
48
+ end
49
+ end
50
+ ```
51
+
40
52
  ## Usage
41
53
 
42
54
  In a worker, specify a threshold (maximum jobs) and period for throttling:
@@ -66,6 +78,15 @@ sidekiq_options throttle: { threshold: 20, period: 1.day, key: ->{ |user_id| use
66
78
  In the above example, jobs are throttled for each user when they exceed 20 in a
67
79
  day.
68
80
 
81
+ Thresholds can be configured based on the arguments passed to your worker's `perform` method,
82
+ similar to how the `key` option works:
83
+
84
+ ```ruby
85
+ sidekiq_options throttle: { threshold: -> {|user_id, rate_limit| rate_limit }, period: 1.hour, key: ->{ |user_id, rate_limit| user_id } }
86
+ ```
87
+
88
+ In the above example, jobs are throttled for each user when they exceed the rate limit provided in the message. This is useful in cases where each user may have a different rate limit (ex: interacting with external APIs)
89
+
69
90
  ## Contributing
70
91
 
71
92
  1. Fork it
@@ -76,4 +97,4 @@ day.
76
97
 
77
98
  ## License
78
99
 
79
- MIT Licensed. See LICENSE.txt for details.
100
+ MIT Licensed. See LICENSE.txt for details.
@@ -32,10 +32,18 @@ module Sidekiq
32
32
  #
33
33
  # @param [String] queue
34
34
  # The queue to rate limit.
35
- def initialize(worker, payload, queue)
35
+ #
36
+ # @option [Symbol] :storage
37
+ # Either :memory or :redis, the storage backend to use
38
+ def initialize(worker, payload, queue, options = {})
36
39
  @worker = worker
37
40
  @payload = payload
38
41
  @queue = queue
42
+
43
+ unless @storage_class = lookup_storage(options.fetch(:storage, :memory))
44
+ raise ArgumentError,
45
+ "Unrecognized storage backend: #{options[:storage].inspect}"
46
+ end
39
47
  end
40
48
 
41
49
  ##
@@ -68,7 +76,7 @@ module Sidekiq
68
76
  # @return [Integer]
69
77
  # The number of jobs that are allowed within the `period`.
70
78
  def threshold
71
- @threshold ||= options['threshold'].to_i
79
+ @threshold ||= (options['threshold'].respond_to?(:call) ? options['threshold'].call(*payload) : options['threshold']).to_i
72
80
  end
73
81
 
74
82
  ##
@@ -79,7 +87,7 @@ module Sidekiq
79
87
  end
80
88
 
81
89
  ##
82
- # @return [Symbol]
90
+ # @return [String]
83
91
  # The key name used when storing counters for jobs.
84
92
  def key
85
93
  @key ||= if options['key']
@@ -146,8 +154,14 @@ module Sidekiq
146
154
 
147
155
  ##
148
156
  # Reset the tracking of job executions.
149
- def self.reset!
150
- @executions = Hash.new { |hash, key| hash[key] = [] }
157
+ def reset!
158
+ executions.reset
159
+ end
160
+
161
+ ##
162
+ # Get the storage backend.
163
+ def executions
164
+ @storage_class.instance
151
165
  end
152
166
 
153
167
  private
@@ -162,7 +176,7 @@ module Sidekiq
162
176
  def self.count(limiter)
163
177
  Thread.exclusive do
164
178
  prune(limiter)
165
- executions[limiter.key].length
179
+ limiter.executions.count(limiter.key)
166
180
  end
167
181
  end
168
182
 
@@ -175,28 +189,31 @@ module Sidekiq
175
189
  # The current number of jobs executed.
176
190
  def self.increment(limiter)
177
191
  Thread.exclusive do
178
- executions[limiter.key] << Time.now
192
+ limiter.executions.append(limiter.key, Time.now)
179
193
  end
180
194
  count(limiter)
181
195
  end
182
196
 
183
- ##
184
- # A hash storing job executions as timestamps for each throttled worker.
185
- def self.executions
186
- @executions || reset!
187
- end
188
-
189
197
  ##
190
198
  # Remove old entries for the provided `RateLimit`.
191
199
  #
192
200
  # @param [RateLimit] limiter
193
201
  # The rate limit to prune.
194
202
  def self.prune(limiter)
195
- executions[limiter.key].select! do |execution|
196
- (Time.now - execution) < limiter.period
197
- end
203
+ limiter.executions.prune(limiter.key, Time.now - limiter.period)
198
204
  end
199
205
 
206
+ ##
207
+ # Lookup storage class for a given options key
208
+ #
209
+ # @param [Symbol] key
210
+ # The options key, :memory or :redis
211
+ #
212
+ # @return [Class]
213
+ # The storage backend class, or nil if the key is not found
214
+ def lookup_storage(key)
215
+ { memory: Storage::Memory, redis: Storage::Redis }[key]
216
+ end
200
217
  end # RateLimit
201
218
  end # Throttler
202
- end # Sidekiq
219
+ end # Sidekiq
@@ -0,0 +1,55 @@
1
+ module Sidekiq
2
+ class Throttler
3
+ module Storage
4
+ ##
5
+ # Stores job executions in a Hash of Arrays.
6
+ class Memory
7
+ include Singleton
8
+
9
+ def initialize
10
+ @hash = Hash.new { |hash, key| hash[key] = [] }
11
+ end
12
+
13
+ ##
14
+ # Number of executions for +key+.
15
+ #
16
+ # @param [String]
17
+ # Key to fetch count for
18
+ #
19
+ # @return [Fixnum]
20
+ # Execution count
21
+ def count(key)
22
+ @hash[key].length
23
+ end
24
+
25
+ ##
26
+ # Remove entries older than +cutoff+.
27
+ #
28
+ # @param [String] key
29
+ # The key to prune
30
+ #
31
+ # @param [Time] cutoff
32
+ # Oldest allowable time
33
+ def prune(key, cutoff)
34
+ @hash[key].reject! { |time| time <= cutoff }
35
+ end
36
+
37
+ ##
38
+ # Add a new entry to the hash.
39
+ #
40
+ # @param [String] key
41
+ # The key to append to
42
+ #
43
+ # @param [Time]
44
+ # The time to insert
45
+ def append(key, time)
46
+ @hash[key] << time
47
+ end
48
+
49
+ def reset
50
+ @hash.clear
51
+ end
52
+ end
53
+ end # Storage
54
+ end # Throttler
55
+ end # Sidekiq
@@ -0,0 +1,89 @@
1
+ module Sidekiq
2
+ class Throttler
3
+ module Storage
4
+ ##
5
+ # Stores job executions in Redis lists.
6
+ #
7
+ # Timestamps in each list are ordered with the oldest on the right.
8
+ # Values are inserted on the left (LPUSH) & pruned from the right (RPOP)
9
+ class Redis
10
+ include Singleton
11
+
12
+ ##
13
+ # Number of executions for +key+.
14
+ #
15
+ # @param [String]
16
+ # Key to fetch count for
17
+ #
18
+ # @return [Fixnum]
19
+ # Execution count
20
+ def count(key)
21
+ Sidekiq.redis do |conn|
22
+ conn.llen(namespace_key(key))
23
+ end
24
+ end
25
+
26
+ ##
27
+ # Remove entries older than +cutoff+.
28
+ #
29
+ # @param [String] key
30
+ # The key to prune
31
+ #
32
+ # @param [Time] cutoff
33
+ # Oldest allowable time
34
+ def prune(key, cutoff)
35
+ # Repeatedly pop from the right of the list until we encounter
36
+ # a value greater than the cutoff.
37
+ #
38
+ # We compare popped values to account for race conditions,
39
+ # pushing them back if they don't match.
40
+ Sidekiq.redis do |conn|
41
+ prune_one = ->(timestamp) {
42
+ if timestamp && timestamp.to_i <= cutoff.to_i
43
+ last = conn.rpop(namespace_key(key))
44
+ if last == timestamp
45
+ true
46
+ else
47
+ conn.rpush(namespace_key(key), last)
48
+ nil
49
+ end
50
+ end
51
+ }
52
+
53
+ loop while prune_one.call(conn.lindex(namespace_key(key), -1))
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Add a new entry to the hash.
59
+ #
60
+ # @param [String] key
61
+ # The key to append to
62
+ #
63
+ # @param [Time]
64
+ # The time to insert
65
+ def append(key, time)
66
+ Sidekiq.redis do |conn|
67
+ conn.lpush(namespace_key(key), time.to_i)
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Clear all data from storage.
73
+ def reset
74
+ Sidekiq.redis do |conn|
75
+ conn.keys(namespace_key("*")).each do |key|
76
+ conn.del(key)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def namespace_key(key)
84
+ "throttled:#{key}"
85
+ end
86
+ end
87
+ end # Storage
88
+ end # Throttler
89
+ end # Sidekiq
@@ -1,5 +1,5 @@
1
1
  module Sidekiq
2
2
  class Throttler
3
- VERSION = '0.2.0'
3
+ VERSION = '0.3.0'
4
4
  end
5
5
  end
@@ -1,15 +1,20 @@
1
1
  require 'sidekiq'
2
- require 'active_support'
3
- require 'active_support/core_ext'
2
+ require 'active_support/core_ext/numeric/time'
4
3
 
5
4
  require 'sidekiq/throttler/version'
6
5
  require 'sidekiq/throttler/rate_limit'
7
6
 
7
+ require 'sidekiq/throttler/storage/memory'
8
+ require 'sidekiq/throttler/storage/redis'
9
+
8
10
  module Sidekiq
9
11
  ##
10
12
  # Sidekiq server middleware. Throttles jobs when they exceed limits specified
11
13
  # on the worker. Jobs that exceed the limit are requeued with a delay.
12
14
  class Throttler
15
+ def initialize(options = {})
16
+ @options = options.dup
17
+ end
13
18
 
14
19
  ##
15
20
  # Passes the worker, arguments, and queue to {RateLimit} and either yields
@@ -24,7 +29,7 @@ module Sidekiq
24
29
  # @param [String] queue
25
30
  # The current queue.
26
31
  def call(worker, msg, queue)
27
- rate_limit = RateLimit.new(worker, msg['args'], queue)
32
+ rate_limit = RateLimit.new(worker, msg['args'], queue, @options)
28
33
 
29
34
  rate_limit.within_bounds do
30
35
  yield
@@ -38,4 +43,4 @@ module Sidekiq
38
43
  end
39
44
 
40
45
  end # Throttler
41
- end # Sidekiq
46
+ end # Sidekiq
@@ -0,0 +1,10 @@
1
+ class ProcThresholdWorker
2
+ include Sidekiq::Worker
3
+
4
+ sidekiq_options throttle: { threshold: Proc.new { |user_id, limit| limit }, period: 1.minute }
5
+
6
+ def perform(user_id, limit)
7
+ puts user_id
8
+ puts limit
9
+ end
10
+ end
@@ -1,5 +1,29 @@
1
1
  require 'spec_helper'
2
2
 
3
+ shared_examples "incrementing" do
4
+ it 'increments #count by one' do
5
+ Timecop.freeze do
6
+ expect { rate_limit.increment }.to change{ rate_limit.count }.by(1)
7
+ end
8
+ end
9
+
10
+ context 'when #period has passed' do
11
+
12
+ it 'removes old increments' do
13
+ rate_limit.options['period'] = 5
14
+
15
+ Timecop.freeze
16
+
17
+ 20.times do
18
+ Timecop.travel(1.second.from_now)
19
+ rate_limit.increment
20
+ end
21
+
22
+ rate_limit.count.should eq(5)
23
+ end
24
+ end
25
+ end
26
+
3
27
  describe Sidekiq::Throttler::RateLimit do
4
28
 
5
29
  let(:worker_class) do
@@ -22,6 +46,10 @@ describe Sidekiq::Throttler::RateLimit do
22
46
  described_class.new(worker, payload, 'meow')
23
47
  end
24
48
 
49
+ before(:each) do
50
+ rate_limit.reset!
51
+ end
52
+
25
53
  describe '.new' do
26
54
 
27
55
  it 'initializes with a provided worker' do
@@ -35,6 +63,14 @@ describe Sidekiq::Throttler::RateLimit do
35
63
  it 'initializes with a provided queue' do
36
64
  rate_limit.queue.should eq('meow')
37
65
  end
66
+
67
+ context "with an invalid storage backend" do
68
+ it "raises an ArgumentError" do
69
+ expect {
70
+ described_class.new(worker, payload, 'meow', storage: :blarg)
71
+ }.to raise_error(ArgumentError)
72
+ end
73
+ end
38
74
  end
39
75
 
40
76
  describe '#options' do
@@ -69,6 +105,20 @@ describe Sidekiq::Throttler::RateLimit do
69
105
 
70
106
  describe '#threshold' do
71
107
 
108
+ context 'when threshold is a Proc' do
109
+ let(:worker_class) do
110
+ ProcThresholdWorker
111
+ end
112
+
113
+ let(:payload) do
114
+ [1, 500]
115
+ end
116
+
117
+ it 'returns the result of the called Proc' do
118
+ rate_limit.threshold.should eq(500)
119
+ end
120
+ end
121
+
72
122
  it 'retrieves the threshold from #options' do
73
123
  rate_limit.options['threshold'] = 26
74
124
  rate_limit.threshold.should eq(26)
@@ -275,27 +325,14 @@ describe Sidekiq::Throttler::RateLimit do
275
325
  end
276
326
 
277
327
  describe '#increment' do
328
+ include_examples "incrementing"
329
+ end
278
330
 
279
- it 'increments #count by one' do
280
- Timecop.freeze do
281
- expect { rate_limit.increment }.to change{ rate_limit.count }.by(1)
282
- end
331
+ context "with a :redis storage backend" do
332
+ subject(:rate_limit) do
333
+ described_class.new(worker, payload, 'meow', storage: :redis)
283
334
  end
284
335
 
285
- context 'when #period has passed' do
286
-
287
- it 'removes old increments' do
288
- rate_limit.options['period'] = 5
289
-
290
- Timecop.freeze
291
-
292
- 20.times do
293
- Timecop.travel(1.second.from_now)
294
- rate_limit.increment
295
- end
296
-
297
- rate_limit.count.should eq(5)
298
- end
299
- end
336
+ include_examples "incrementing"
300
337
  end
301
- end
338
+ end
@@ -0,0 +1,37 @@
1
+ require "spec_helper"
2
+
3
+ describe Sidekiq::Throttler::Storage::Redis do
4
+ let(:storage) { described_class.instance }
5
+
6
+ before(:each) do
7
+ @sidekiq = double()
8
+ Sidekiq.stub(:redis).and_yield(@sidekiq)
9
+ end
10
+
11
+ describe "#prune" do
12
+ it "pops the last item off the list if it's lower than the cutoff" do
13
+ @sidekiq.stub(:lindex).and_return(100, nil)
14
+ @sidekiq.should_receive(:rpop).with("throttled:fake").and_return(100)
15
+ storage.prune("fake", 200)
16
+ end
17
+
18
+ it "leaves the last item on the list if it's higher than the cutoff" do
19
+ @sidekiq.stub(:lindex).and_return(200, nil)
20
+ @sidekiq.should_not_receive(:rpop)
21
+ storage.prune("fake", 100)
22
+ end
23
+
24
+ context "when another job has concurrently removed a timestamp" do
25
+
26
+ before(:each) do
27
+ @sidekiq.stub(:lindex) { 100 }
28
+ @sidekiq.stub(:rpop) { 200 }
29
+ end
30
+
31
+ it "pushes the value back onto the list" do
32
+ @sidekiq.should_receive(:rpush).with("throttled:fake", 200)
33
+ storage.prune("fake", 1000)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -3,13 +3,17 @@ require 'spec_helper'
3
3
  describe Sidekiq::Throttler do
4
4
 
5
5
  subject(:throttler) do
6
- described_class.new
6
+ described_class.new(options)
7
7
  end
8
8
 
9
9
  let(:worker) do
10
10
  LolzWorker.new
11
11
  end
12
12
 
13
+ let(:options) do
14
+ { storage: :memory }
15
+ end
16
+
13
17
  let(:message) do
14
18
  {
15
19
  args: 'Clint Eastwood'
@@ -23,10 +27,9 @@ describe Sidekiq::Throttler do
23
27
  describe '#call' do
24
28
 
25
29
  it 'instantiates a rate limit with the worker, args, and queue' do
26
- rate_limit = Sidekiq::Throttler::RateLimit.new(worker, message['args'], queue)
27
30
  Sidekiq::Throttler::RateLimit.should_receive(:new).with(
28
- worker, message['args'], queue
29
- ).and_return(rate_limit)
31
+ worker, message['args'], queue, options
32
+ ).and_call_original
30
33
 
31
34
  throttler.call(worker, message, queue) {}
32
35
  end
@@ -49,4 +52,4 @@ describe Sidekiq::Throttler do
49
52
  end
50
53
  end
51
54
  end
52
- end
55
+ end
data/spec/spec_helper.rb CHANGED
@@ -28,12 +28,8 @@ RSpec.configure do |config|
28
28
  # the seed, which is printed after each run.
29
29
  # --seed 1234
30
30
  config.order = 'random'
31
-
32
- config.before(:each) do
33
- Sidekiq::Throttler::RateLimit.reset!
34
- end
35
31
  end
36
32
 
37
33
  # Requires supporting files with custom matchers and macros, etc,
38
34
  # in ./support/ and its subdirectories.
39
- Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
35
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sidekiq-throttler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabe Evans
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2013-06-29 00:00:00.000000000 Z
11
+ date: 2013-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -274,13 +274,17 @@ files:
274
274
  - lib/sidekiq-throttler.rb
275
275
  - lib/sidekiq/throttler.rb
276
276
  - lib/sidekiq/throttler/rate_limit.rb
277
+ - lib/sidekiq/throttler/storage/memory.rb
278
+ - lib/sidekiq/throttler/storage/redis.rb
277
279
  - lib/sidekiq/throttler/version.rb
278
280
  - sidekiq-throttler.gemspec
279
281
  - spec/app/workers/custom_key_worker.rb
280
282
  - spec/app/workers/lolz_worker.rb
283
+ - spec/app/workers/proc_threshold_worker.rb
281
284
  - spec/app/workers/proc_worker.rb
282
285
  - spec/app/workers/regular_worker.rb
283
286
  - spec/sidekiq/throttler/rate_limit_spec.rb
287
+ - spec/sidekiq/throttler/storage/redis_spec.rb
284
288
  - spec/sidekiq/throttler_spec.rb
285
289
  - spec/spec.opts
286
290
  - spec/spec_helper.rb
@@ -304,7 +308,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
304
308
  version: '0'
305
309
  requirements: []
306
310
  rubyforge_project:
307
- rubygems_version: 2.0.3
311
+ rubygems_version: 2.1.5
308
312
  signing_key:
309
313
  specification_version: 4
310
314
  summary: Sidekiq::Throttler is a middleware for Sidekiq that adds the ability to rate
@@ -312,9 +316,11 @@ summary: Sidekiq::Throttler is a middleware for Sidekiq that adds the ability to
312
316
  test_files:
313
317
  - spec/app/workers/custom_key_worker.rb
314
318
  - spec/app/workers/lolz_worker.rb
319
+ - spec/app/workers/proc_threshold_worker.rb
315
320
  - spec/app/workers/proc_worker.rb
316
321
  - spec/app/workers/regular_worker.rb
317
322
  - spec/sidekiq/throttler/rate_limit_spec.rb
323
+ - spec/sidekiq/throttler/storage/redis_spec.rb
318
324
  - spec/sidekiq/throttler_spec.rb
319
325
  - spec/spec.opts
320
326
  - spec/spec_helper.rb