sidekiq-throttler 0.2.0 → 0.3.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 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