sidekiq-throttler 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .rspec
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.pryrc ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # -*- mode: ruby -*-
3
+ # vi: set ft=ruby :
4
+
5
+ require 'pathname'
6
+ $LOAD_PATH.unshift(Pathname.getwd.join('lib').to_s)
7
+ require 'sidekiq/throttler'
8
+
9
+ def reload!
10
+ Dir["#{Dir.pwd}/lib/**/*.rb"].each { |f| load f }
11
+ end
data/.travis.yml ADDED
@@ -0,0 +1,18 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - jruby-19mode
5
+ - rbx-19mode
6
+ - 2.0.0
7
+ branches:
8
+ only:
9
+ - master
10
+ notifications:
11
+ email:
12
+ recipients:
13
+ - gabriel@codeconcoction.com
14
+ matrix:
15
+ allow_failures:
16
+ - rvm: jruby-19mode
17
+ - rvm: rbx-19mode
18
+ - rvm: 2.0.0
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ --title 'Sidekiq::Throttler Documentation'
2
+ --charset utf-8
3
+ --markup markdown
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 0.1.0 (December 13, 2012)
2
+
3
+ * Initial release.
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sidekiq-throttler.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,16 @@
1
+ guard 'bundler' do
2
+ watch('Gemfile')
3
+ watch('sidekiq-throttler.gemspec')
4
+ end
5
+
6
+ guard 'rspec' do
7
+ watch(%r{^spec/app/.+_worker\.rb$}) { 'spec' }
8
+ watch(%r{^spec/.+_spec\.rb$})
9
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
10
+ watch('spec/spec_helper.rb') { 'spec' }
11
+ end
12
+
13
+ guard 'yard' do
14
+ watch(%r{app/.+\.rb})
15
+ watch(%r{lib/.+\.rb})
16
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Gabriel Evans
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Sidekiq::Throttler
2
+
3
+ [![Build Status](https://secure.travis-ci.org/gevans/sidekiq-throttler.png)](http://travis-ci.org/gevans/sidekiq-throttler)
4
+ [![Dependency Status](https://gemnasium.com/gevans/sidekiq-throttler.png)](https://gemnasium.com/gevans/sidekiq-throttler)
5
+
6
+ Sidekiq::Throttler is a middleware for Sidekiq that adds the ability to rate
7
+ limit job execution on a per-worker basis.
8
+
9
+ ## Compatibility
10
+
11
+ Sidekiq::Throttler is tested against MRI 1.9.3.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ gem 'sidekiq-throttler'
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install sidekiq-throttler
26
+
27
+ ## Configuration
28
+
29
+ In a Rails initializer or wherever you've configured Sidekiq, add
30
+ Sidekiq::Throttler to your server middleware:
31
+
32
+ ```ruby
33
+ Sidekiq.configure_server do |config|
34
+ config.server_middleware do |chain|
35
+ chain.add Sidekiq::Throttler
36
+ end
37
+ end
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ In a worker, specify a threshold (maximum jobs) and period for throttling:
43
+
44
+ ```ruby
45
+ class MyWorker
46
+ include Sidekiq::Worker
47
+
48
+ sidekiq_options throttle: { threshold: 50, period: 1.hour }
49
+
50
+ def perform(user_id)
51
+ # Do some heavy API interactions.
52
+ end
53
+ end
54
+ ```
55
+
56
+ In the above example, when the number of executed jobs for the worker exceeds
57
+ 50 in an hour, remaining jobs will be delayed.
58
+
59
+ If throttling is per-user, for example, you can specify a `Proc` for `key` which
60
+ accepts the arguments passed to your worker's `perform` method:
61
+
62
+ ```ruby
63
+ sidekiq_options throttle: { threshold: 20, period: 1.day, key: ->{ |user_id| user_id } }
64
+ ```
65
+
66
+ In the above example, jobs are throttled for each user when they exceed 20 in a
67
+ day.
68
+
69
+ ## Contributing
70
+
71
+ 1. Fork it
72
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
73
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
74
+ 4. Push to the branch (`git push origin my-new-feature`)
75
+ 5. Create new Pull Request
76
+
77
+ ## License
78
+
79
+ MIT Licensed. See LICENSE.txt for details.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require 'bundler/gem_tasks'
2
+
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec) do |spec|
5
+ spec.pattern = FileList['spec/**/*_spec.rb']
6
+ end
7
+
8
+ task :default => :spec
9
+
10
+ require 'yard'
11
+ YARD::Rake::YardocTask.new
12
+
13
+ desc 'Start Pry with runtime dependencies loaded'
14
+ task :console, :script do |t, args|
15
+ command = 'bundle exec pry'
16
+ command += "-r #{args[:script]}" if args[:script]
17
+ sh command
18
+ end
@@ -0,0 +1,202 @@
1
+ module Sidekiq
2
+ class Throttler
3
+ ##
4
+ # Handles the tracking of rate limits.
5
+ #
6
+ # TODO: Consider reducing `threshold` and `period` to smooth out job
7
+ # executions so that "24 jobs every 1 hour" becomes "1 job every 2 minutes
8
+ # and 30 seconds"
9
+ class RateLimit
10
+
11
+ ##
12
+ # @return [Sidekiq::Worker]
13
+ # The worker to rate limit.
14
+ attr_reader :worker
15
+
16
+ ##
17
+ # @return [Array]
18
+ # The message payload for the current job.
19
+ attr_reader :payload
20
+
21
+ ##
22
+ # @return [String]
23
+ # The queue to rate limit.
24
+ attr_reader :queue
25
+
26
+ ##
27
+ # @param [Sidekiq::Worker] worker
28
+ # The worker to rate limit.
29
+ #
30
+ # @param [Array<Object>] payload
31
+ # The message payload for the current job.
32
+ #
33
+ # @param [String] queue
34
+ # The queue to rate limit.
35
+ def initialize(worker, payload, queue)
36
+ @worker = worker
37
+ @payload = payload
38
+ @queue = queue
39
+ end
40
+
41
+ ##
42
+ # Fetch the number of jobs executed.
43
+ #
44
+ # @return [Integer]
45
+ # The current number of jobs executed.
46
+ def count
47
+ self.class.count(self)
48
+ end
49
+
50
+ ##
51
+ # Increment the count of jobs executed.
52
+ #
53
+ # @return [Integer]
54
+ # The current number of jobs executed.
55
+ def increment
56
+ self.class.increment(self)
57
+ end
58
+
59
+ ##
60
+ # Returns the rate limit options for the current running worker.
61
+ #
62
+ # @return [{String => Float, Integer}]
63
+ def options
64
+ @options ||= (worker.class.get_sidekiq_options['throttle'] || {}).stringify_keys
65
+ end
66
+
67
+ ##
68
+ # @return [Integer]
69
+ # The number of jobs that are allowed within the `period`.
70
+ def threshold
71
+ @threshold ||= options['threshold'].to_i
72
+ end
73
+
74
+ ##
75
+ # @return [Float]
76
+ # The number of seconds in the rate limit period.
77
+ def period
78
+ @period ||= options['period'].to_f
79
+ end
80
+
81
+ ##
82
+ # @return [Symbol]
83
+ # The key name used when storing counters for jobs.
84
+ def key
85
+ @key ||= if options['key']
86
+ options['key'].respond_to?(:call) ? options['key'].call(*payload) : options['key']
87
+ else
88
+ "#{@worker.class.to_s.underscore.gsub('/', ':')}:#{@queue}"
89
+ end
90
+ end
91
+
92
+ ##
93
+ # Check if rate limiting options were correctly specified on the worker.
94
+ #
95
+ # @return [true, false]
96
+ def can_throttle?
97
+ [threshold, period].select(&:zero?).empty?
98
+ end
99
+
100
+ ##
101
+ # Check if rate limit has exceeded the threshold.
102
+ #
103
+ # @return [true, false]
104
+ def exceeded?
105
+ count >= threshold
106
+ end
107
+
108
+ ##
109
+ # Check if rate limit is within the threshold.
110
+ #
111
+ # @return [true, false]
112
+ def within_bounds?
113
+ !exceeded?
114
+ end
115
+
116
+ ##
117
+ # Set a callback to be executed when {#execute} is called and the rate
118
+ # limit has not exceeded the threshold.
119
+ def within_bounds(&block)
120
+ @within_bounds = block
121
+ end
122
+
123
+ ##
124
+ # Set a callback to be executed when {#execute} is called and the rate
125
+ # limit has exceeded the threshold.
126
+ #
127
+ # @yieldparam [Integer] delay
128
+ # Delay in seconds to requeue job for.
129
+ def exceeded(&block)
130
+ @exceeded = block
131
+ end
132
+
133
+ ##
134
+ # Executes a callback ({#within_bounds}, or {#exceeded}) depending on the
135
+ # state of the rate limit.
136
+ def execute
137
+ return @within_bounds.call unless can_throttle?
138
+
139
+ if exceeded?
140
+ @exceeded.call(period)
141
+ else
142
+ increment
143
+ @within_bounds.call
144
+ end
145
+ end
146
+
147
+ ##
148
+ # Reset the tracking of job executions.
149
+ def self.reset!
150
+ @executions = Hash.new { |hash, key| hash[key] = [] }
151
+ end
152
+
153
+ private
154
+
155
+ ##
156
+ # Fetch the number of jobs executed by the provided `RateLimit`.
157
+ #
158
+ # @param [RateLimit] limiter
159
+ #
160
+ # @return [Integer]
161
+ # The current number of jobs executed.
162
+ def self.count(limiter)
163
+ Thread.exclusive do
164
+ prune(limiter)
165
+ executions[limiter.key].length
166
+ end
167
+ end
168
+
169
+ ##
170
+ # Increment the count of jobs executed by the provided `RateLimit`.
171
+ #
172
+ # @param [RateLimit] limiter
173
+ #
174
+ # @return [Integer]
175
+ # The current number of jobs executed.
176
+ def self.increment(limiter)
177
+ Thread.exclusive do
178
+ executions[limiter.key] << Time.now
179
+ end
180
+ count(limiter)
181
+ end
182
+
183
+ ##
184
+ # A hash storing job executions as timestamps for each throttled worker.
185
+ def self.executions
186
+ @executions || reset!
187
+ end
188
+
189
+ ##
190
+ # Remove old entries for the provided `RateLimit`.
191
+ #
192
+ # @param [RateLimit] limiter
193
+ # The rate limit to prune.
194
+ def self.prune(limiter)
195
+ executions[limiter.key].select! do |execution|
196
+ (Time.now - execution) < limiter.period
197
+ end
198
+ end
199
+
200
+ end # RateLimit
201
+ end # Throttler
202
+ end # Sidekiq
@@ -0,0 +1,5 @@
1
+ module Sidekiq
2
+ class Throttler
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ require 'sidekiq'
2
+ require 'active_support'
3
+ require 'active_support/core_ext'
4
+
5
+ require 'sidekiq/throttler/version'
6
+ require 'sidekiq/throttler/rate_limit'
7
+
8
+ module Sidekiq
9
+ ##
10
+ # Sidekiq server middleware. Throttles jobs when they exceed limits specified
11
+ # on the worker. Jobs that exceed the limit are requeued with a delay.
12
+ class Throttler
13
+
14
+ ##
15
+ # Passes the worker, arguments, and queue to {RateLimit} and either yields
16
+ # or requeues the job depending on whether the worker is throttled.
17
+ #
18
+ # @param [Sidekiq::Worker] worker
19
+ # The worker the job belongs to.
20
+ #
21
+ # @param [Hash] msg
22
+ # The job message.
23
+ #
24
+ # @param [String] queue
25
+ # The current queue.
26
+ def call(worker, msg, queue)
27
+ rate_limit = RateLimit.new(worker, msg['args'], queue)
28
+
29
+ rate_limit.within_bounds do
30
+ yield
31
+ end
32
+
33
+ rate_limit.exceeded do |delay|
34
+ worker.class.perform_in(delay, *msg['args'])
35
+ end
36
+
37
+ rate_limit.execute
38
+ end
39
+
40
+ end # Throttler
41
+ end # Sidekiq
@@ -0,0 +1 @@
1
+ require 'sidekiq/throttler'
@@ -0,0 +1,40 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'sidekiq/throttler/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'sidekiq-throttler'
8
+ gem.version = Sidekiq::Throttler::VERSION
9
+ gem.authors = ['Gabriel Evans']
10
+ gem.email = ['gabriel@codeconcoction.com']
11
+ gem.description = %q{Sidekiq middleware that adds the ability to rate limit job execution.}
12
+ gem.summary = %q{Sidekiq::Throttler is a middleware for Sidekiq that adds the ability to rate limit job execution on a per-worker basis.}
13
+ gem.homepage = 'https://github.com/gevans/sidekiq-throttler'
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ['lib']
19
+
20
+ gem.add_dependency 'activesupport'
21
+ gem.add_dependency 'sidekiq', '>= 2.5', '< 3.0'
22
+
23
+ gem.add_development_dependency 'rake'
24
+ gem.add_development_dependency 'pry'
25
+ gem.add_development_dependency 'yard'
26
+ gem.add_development_dependency 'redcarpet'
27
+
28
+ gem.add_development_dependency 'rspec'
29
+ gem.add_development_dependency 'rspec-redis_helper'
30
+ gem.add_development_dependency 'timecop'
31
+ gem.add_development_dependency 'simplecov'
32
+
33
+ gem.add_development_dependency 'guard'
34
+ gem.add_development_dependency 'guard-bundler'
35
+ gem.add_development_dependency 'guard-rspec'
36
+ gem.add_development_dependency 'guard-yard'
37
+ gem.add_development_dependency 'rb-fsevent'
38
+ gem.add_development_dependency 'rb-inotify'
39
+ gem.add_development_dependency 'growl'
40
+ end
@@ -0,0 +1,9 @@
1
+ class CustomKeyWorker
2
+ include Sidekiq::Worker
3
+
4
+ sidekiq_options throttle: { threshold: 10, period: 1.minute, key: 'winning' }
5
+
6
+ def perform(name)
7
+ puts "#{name} is winning!"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class LolzWorker
2
+ include Sidekiq::Worker
3
+
4
+ sidekiq_options throttle: { threshold: 10, period: 1.minute }
5
+
6
+ def perform(name)
7
+ puts "OHAI #{name.upcase}!"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class ProcWorker
2
+ include Sidekiq::Worker
3
+
4
+ sidekiq_options throttle: { threshold: 10, period: 1.minute, key: Proc.new { |*args| args.join(':') } }
5
+
6
+ def perform(*args)
7
+ puts args.first
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ class RegularWorker
2
+ include Sidekiq::Worker
3
+
4
+ def perform(name)
5
+ puts "..."
6
+ end
7
+ end
@@ -0,0 +1,301 @@
1
+ require 'spec_helper'
2
+
3
+ describe Sidekiq::Throttler::RateLimit do
4
+
5
+ let(:worker_class) do
6
+ LolzWorker
7
+ end
8
+
9
+ let(:worker) do
10
+ worker_class.new
11
+ end
12
+
13
+ let(:payload) do
14
+ ['world']
15
+ end
16
+
17
+ let(:queue) do
18
+ 'meow'
19
+ end
20
+
21
+ subject(:rate_limit) do
22
+ described_class.new(worker, payload, 'meow')
23
+ end
24
+
25
+ describe '.new' do
26
+
27
+ it 'initializes with a provided worker' do
28
+ rate_limit.worker.should eq(worker)
29
+ end
30
+
31
+ it 'initializes with provided payload' do
32
+ rate_limit.payload.should eq(payload)
33
+ end
34
+
35
+ it 'initializes with a provided queue' do
36
+ rate_limit.queue.should eq('meow')
37
+ end
38
+ end
39
+
40
+ describe '#options' do
41
+
42
+ it 'retrieves throttle options from the worker' do
43
+ worker_class.get_sidekiq_options.should_receive(:[]).with('throttle')
44
+ rate_limit.options
45
+ end
46
+
47
+ it 'stringifies the option keys' do
48
+ worker_class.get_sidekiq_options['throttle'].should_receive(:stringify_keys)
49
+ rate_limit.options
50
+ end
51
+
52
+ it 'caches the returned options' do
53
+ rate_limit.options.object_id.should eq(rate_limit.options.object_id)
54
+ end
55
+
56
+ context 'when the worker specifies no throttle options' do
57
+
58
+ let(:worker_class) do
59
+ Class.new do
60
+ include Sidekiq::Worker
61
+ end
62
+ end
63
+
64
+ it 'returns an empty hash' do
65
+ rate_limit.options.should eq({})
66
+ end
67
+ end
68
+ end
69
+
70
+ describe '#threshold' do
71
+
72
+ it 'retrieves the threshold from #options' do
73
+ rate_limit.options['threshold'] = 26
74
+ rate_limit.threshold.should eq(26)
75
+ end
76
+
77
+ it 'converts the threshold to an integer' do
78
+ rate_limit.options['threshold'] = '33'
79
+ rate_limit.threshold.should be_a(Integer)
80
+ end
81
+
82
+ it 'caches the returned integer' do
83
+ rate_limit.threshold.object_id.should eq(rate_limit.threshold.object_id)
84
+ end
85
+ end
86
+
87
+ describe '#period' do
88
+
89
+ it 'retrieves the period from #options' do
90
+ rate_limit.options['period'] = 10.0
91
+ rate_limit.period.should eq(10.0)
92
+ end
93
+
94
+ it 'converts the period to a float' do
95
+ rate_limit.options['period'] = 27
96
+ rate_limit.period.should be_a(Float)
97
+ end
98
+
99
+ it 'caches the returned float' do
100
+ rate_limit.period.object_id.should eq(rate_limit.period.object_id)
101
+ end
102
+ end
103
+
104
+ describe '#key' do
105
+
106
+ let(:worker_class) do
107
+ CustomKeyWorker
108
+ end
109
+
110
+ it 'caches the key from the worker' do
111
+ rate_limit.key.object_id.should eq(rate_limit.key.object_id)
112
+ end
113
+
114
+ context 'when key is a string' do
115
+
116
+ it 'returns the key as a symbol' do
117
+ rate_limit.key.should eq('winning')
118
+ end
119
+ end
120
+
121
+ context 'when key is a Proc' do
122
+
123
+ let(:worker_class) do
124
+ ProcWorker
125
+ end
126
+
127
+ let(:payload) do
128
+ ['wat', 'is', 'this']
129
+ end
130
+
131
+ it 'returns the result of the called Proc' do
132
+ rate_limit.key.should eq('wat:is:this')
133
+ end
134
+ end
135
+ end
136
+
137
+ describe '#can_throttle?' do
138
+
139
+ context 'when options are correctly specified' do
140
+
141
+ it 'returns true' do
142
+ rate_limit.can_throttle?.should be_true
143
+ end
144
+ end
145
+
146
+ %w(threshold period).each do |method|
147
+
148
+ context "when ##{method} is zero" do
149
+
150
+ it 'returns false' do
151
+ rate_limit.stub(method.to_sym).and_return(0)
152
+ rate_limit.can_throttle?.should be_false
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ describe '#exceeded?' do
159
+
160
+ context 'when #count is equal to #threshold' do
161
+
162
+ it 'returns true' do
163
+ rate_limit.should_receive(:count).and_return(rate_limit.threshold)
164
+ rate_limit.should be_exceeded
165
+ end
166
+ end
167
+
168
+ context 'when #count is greater than #threshold' do
169
+
170
+ it 'returns true' do
171
+ rate_limit.should_receive(:count).and_return(rate_limit.threshold + 1)
172
+ rate_limit.should be_exceeded
173
+ end
174
+ end
175
+
176
+ context 'when #count is less than #threshold' do
177
+
178
+ it 'returns false' do
179
+ rate_limit.should_receive(:count).and_return(0)
180
+ rate_limit.should_not be_exceeded
181
+ end
182
+ end
183
+ end
184
+
185
+ describe '#within_bounds?' do
186
+
187
+ it 'returns the opposite of #exceeded?' do
188
+ rate_limit.should_receive(:exceeded?).and_return(true)
189
+ rate_limit.should_not be_within_bounds
190
+ rate_limit.should_receive(:exceeded?).and_return(false)
191
+ rate_limit.should be_within_bounds
192
+ end
193
+ end
194
+
195
+ describe '#exceeded' do
196
+
197
+ it 'accepts a block as a callback' do
198
+ rate_limit.exceeded { 'rawr' }
199
+ end
200
+ end
201
+
202
+ describe '#within_bounds' do
203
+
204
+ it 'accepts a block as a callback' do
205
+ rate_limit.within_bounds { 'grr' }
206
+ end
207
+ end
208
+
209
+ describe '#execute' do
210
+
211
+ context 'when rate limit cannot be throttled' do
212
+
213
+ before do
214
+ rate_limit.should_receive(:can_throttle?).and_return(false)
215
+ end
216
+
217
+ it 'calls the within bounds callback' do
218
+ callback = Proc.new {}
219
+ callback.should_receive(:call)
220
+
221
+ rate_limit.within_bounds(&callback)
222
+ rate_limit.execute
223
+ end
224
+
225
+ it 'does not increment the counter' do
226
+ rate_limit.within_bounds {}
227
+
228
+ rate_limit.should_not_receive(:increment)
229
+ rate_limit.execute
230
+ end
231
+ end
232
+
233
+ context 'when rate limit is exceeded' do
234
+
235
+ before do
236
+ rate_limit.should_receive(:exceeded?).and_return(true)
237
+ end
238
+
239
+ it 'calls the exceeded callback with the configured #period' do
240
+ callback = Proc.new {}
241
+ callback.should_receive(:call).with(rate_limit.period)
242
+
243
+ rate_limit.exceeded(&callback)
244
+ rate_limit.execute
245
+ end
246
+ end
247
+
248
+ context 'when rate limit is within bounds' do
249
+
250
+ it 'increments the counter' do
251
+ rate_limit.within_bounds {}
252
+
253
+ rate_limit.should_receive(:increment)
254
+ rate_limit.execute
255
+ end
256
+
257
+ it 'calls the within bounds callback' do
258
+ callback = Proc.new {}
259
+ callback.should_receive(:call)
260
+
261
+ rate_limit.within_bounds(&callback)
262
+ rate_limit.execute
263
+ end
264
+ end
265
+ end
266
+
267
+ describe '#count' do
268
+
269
+ context 'when no jobs have executed' do
270
+
271
+ it 'returns 0' do
272
+ rate_limit.count.should be_zero
273
+ end
274
+ end
275
+ end
276
+
277
+ describe '#increment' do
278
+
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
283
+ end
284
+
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
300
+ end
301
+ end
@@ -0,0 +1,52 @@
1
+ require 'spec_helper'
2
+
3
+ describe Sidekiq::Throttler do
4
+
5
+ subject(:throttler) do
6
+ described_class.new
7
+ end
8
+
9
+ let(:worker) do
10
+ LolzWorker.new
11
+ end
12
+
13
+ let(:message) do
14
+ {
15
+ args: 'Clint Eastwood'
16
+ }
17
+ end
18
+
19
+ let(:queue) do
20
+ 'default'
21
+ end
22
+
23
+ describe '#call' do
24
+
25
+ it 'instantiates a rate limit with the worker, args, and queue' do
26
+ rate_limit = Sidekiq::Throttler::RateLimit.new(worker, message['args'], queue)
27
+ Sidekiq::Throttler::RateLimit.should_receive(:new).with(
28
+ worker, message['args'], queue
29
+ ).and_return(rate_limit)
30
+
31
+ throttler.call(worker, message, queue) {}
32
+ end
33
+
34
+ it 'yields in RateLimit#within_bounds' do
35
+ expect { |b| throttler.call(worker, message, queue, &b) }.to yield_with_no_args
36
+ end
37
+
38
+ it 'calls RateLimit#execute' do
39
+ Sidekiq::Throttler::RateLimit.any_instance.should_receive(:execute)
40
+ throttler.call(worker, message, queue)
41
+ end
42
+
43
+ context 'when rate limit is exceeded' do
44
+
45
+ it 'requeues the job with a delay' do
46
+ Sidekiq::Throttler::RateLimit.any_instance.should_receive(:exceeded?).and_return(true)
47
+ worker.class.should_receive(:perform_in).with(1.minute, *message['args'])
48
+ throttler.call(worker, message, queue)
49
+ end
50
+ end
51
+ end
52
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
@@ -0,0 +1,39 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+
4
+ WORKERS = File.join(File.dirname(__FILE__), 'app', 'workers')
5
+ $LOAD_PATH.unshift(WORKERS)
6
+
7
+ unless ENV['CI']
8
+ require 'simplecov'
9
+ SimpleCov.start do
10
+ add_filter '/spec/'
11
+ end
12
+ end
13
+
14
+ require 'rspec'
15
+ require 'timecop'
16
+
17
+ require 'sidekiq/throttler'
18
+
19
+ # Autoload every worker for the test suite that sits in spec/app/workers
20
+ Dir[File.join(WORKERS, '*.rb')].sort.each do |file|
21
+ name = File.basename(file, '.rb')
22
+ autoload name.camelize.to_sym, name
23
+ end
24
+
25
+ RSpec.configure do |config|
26
+ # Run specs in random order to surface order dependencies. If you find an
27
+ # order dependency and want to debug it, you can fix the order by providing
28
+ # the seed, which is printed after each run.
29
+ # --seed 1234
30
+ config.order = 'random'
31
+
32
+ config.before(:each) do
33
+ Sidekiq::Throttler::RateLimit.reset!
34
+ end
35
+ end
36
+
37
+ # Requires supporting files with custom matchers and macros, etc,
38
+ # in ./support/ and its subdirectories.
39
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
@@ -0,0 +1,20 @@
1
+ require 'sidekiq/util'
2
+ Sidekiq.logger.level = Logger::ERROR
3
+
4
+ require 'rspec-redis_helper'
5
+ RSpec::RedisHelper::CONFIG = { :url => 'redis://localhost/15', :namespace => 'testy' }
6
+
7
+ require 'sidekiq/redis_connection'
8
+ REDIS = Sidekiq::RedisConnection.create(RSpec::RedisHelper::CONFIG)
9
+
10
+ RSpec.configure do |spec|
11
+ spec.include RSpec::RedisHelper, redis: true
12
+
13
+ # clean the Redis database around each run
14
+ # @see https://www.relishapp.com/rspec/rspec-core/docs/hooks/around-hooks
15
+ spec.around( :each, redis: true ) do |example|
16
+ with_clean_redis do
17
+ example.run
18
+ end
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,364 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-throttler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Gabriel Evans
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-12-13 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: sidekiq
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '2.5'
38
+ - - <
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ type: :runtime
42
+ prerelease: false
43
+ version_requirements: !ruby/object:Gem::Requirement
44
+ none: false
45
+ requirements:
46
+ - - ! '>='
47
+ - !ruby/object:Gem::Version
48
+ version: '2.5'
49
+ - - <
50
+ - !ruby/object:Gem::Version
51
+ version: '3.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: rake
54
+ requirement: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ! '>='
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ none: false
64
+ requirements:
65
+ - - ! '>='
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: pry
70
+ requirement: !ruby/object:Gem::Requirement
71
+ none: false
72
+ requirements:
73
+ - - ! '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: yard
86
+ requirement: !ruby/object:Gem::Requirement
87
+ none: false
88
+ requirements:
89
+ - - ! '>='
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ type: :development
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ none: false
96
+ requirements:
97
+ - - ! '>='
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ - !ruby/object:Gem::Dependency
101
+ name: redcarpet
102
+ requirement: !ruby/object:Gem::Requirement
103
+ none: false
104
+ requirements:
105
+ - - ! '>='
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ type: :development
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ none: false
112
+ requirements:
113
+ - - ! '>='
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: rspec
118
+ requirement: !ruby/object:Gem::Requirement
119
+ none: false
120
+ requirements:
121
+ - - ! '>='
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ none: false
128
+ requirements:
129
+ - - ! '>='
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ - !ruby/object:Gem::Dependency
133
+ name: rspec-redis_helper
134
+ requirement: !ruby/object:Gem::Requirement
135
+ none: false
136
+ requirements:
137
+ - - ! '>='
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ type: :development
141
+ prerelease: false
142
+ version_requirements: !ruby/object:Gem::Requirement
143
+ none: false
144
+ requirements:
145
+ - - ! '>='
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ - !ruby/object:Gem::Dependency
149
+ name: timecop
150
+ requirement: !ruby/object:Gem::Requirement
151
+ none: false
152
+ requirements:
153
+ - - ! '>='
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ type: :development
157
+ prerelease: false
158
+ version_requirements: !ruby/object:Gem::Requirement
159
+ none: false
160
+ requirements:
161
+ - - ! '>='
162
+ - !ruby/object:Gem::Version
163
+ version: '0'
164
+ - !ruby/object:Gem::Dependency
165
+ name: simplecov
166
+ requirement: !ruby/object:Gem::Requirement
167
+ none: false
168
+ requirements:
169
+ - - ! '>='
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ type: :development
173
+ prerelease: false
174
+ version_requirements: !ruby/object:Gem::Requirement
175
+ none: false
176
+ requirements:
177
+ - - ! '>='
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: guard
182
+ requirement: !ruby/object:Gem::Requirement
183
+ none: false
184
+ requirements:
185
+ - - ! '>='
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ none: false
192
+ requirements:
193
+ - - ! '>='
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ - !ruby/object:Gem::Dependency
197
+ name: guard-bundler
198
+ requirement: !ruby/object:Gem::Requirement
199
+ none: false
200
+ requirements:
201
+ - - ! '>='
202
+ - !ruby/object:Gem::Version
203
+ version: '0'
204
+ type: :development
205
+ prerelease: false
206
+ version_requirements: !ruby/object:Gem::Requirement
207
+ none: false
208
+ requirements:
209
+ - - ! '>='
210
+ - !ruby/object:Gem::Version
211
+ version: '0'
212
+ - !ruby/object:Gem::Dependency
213
+ name: guard-rspec
214
+ requirement: !ruby/object:Gem::Requirement
215
+ none: false
216
+ requirements:
217
+ - - ! '>='
218
+ - !ruby/object:Gem::Version
219
+ version: '0'
220
+ type: :development
221
+ prerelease: false
222
+ version_requirements: !ruby/object:Gem::Requirement
223
+ none: false
224
+ requirements:
225
+ - - ! '>='
226
+ - !ruby/object:Gem::Version
227
+ version: '0'
228
+ - !ruby/object:Gem::Dependency
229
+ name: guard-yard
230
+ requirement: !ruby/object:Gem::Requirement
231
+ none: false
232
+ requirements:
233
+ - - ! '>='
234
+ - !ruby/object:Gem::Version
235
+ version: '0'
236
+ type: :development
237
+ prerelease: false
238
+ version_requirements: !ruby/object:Gem::Requirement
239
+ none: false
240
+ requirements:
241
+ - - ! '>='
242
+ - !ruby/object:Gem::Version
243
+ version: '0'
244
+ - !ruby/object:Gem::Dependency
245
+ name: rb-fsevent
246
+ requirement: !ruby/object:Gem::Requirement
247
+ none: false
248
+ requirements:
249
+ - - ! '>='
250
+ - !ruby/object:Gem::Version
251
+ version: '0'
252
+ type: :development
253
+ prerelease: false
254
+ version_requirements: !ruby/object:Gem::Requirement
255
+ none: false
256
+ requirements:
257
+ - - ! '>='
258
+ - !ruby/object:Gem::Version
259
+ version: '0'
260
+ - !ruby/object:Gem::Dependency
261
+ name: rb-inotify
262
+ requirement: !ruby/object:Gem::Requirement
263
+ none: false
264
+ requirements:
265
+ - - ! '>='
266
+ - !ruby/object:Gem::Version
267
+ version: '0'
268
+ type: :development
269
+ prerelease: false
270
+ version_requirements: !ruby/object:Gem::Requirement
271
+ none: false
272
+ requirements:
273
+ - - ! '>='
274
+ - !ruby/object:Gem::Version
275
+ version: '0'
276
+ - !ruby/object:Gem::Dependency
277
+ name: growl
278
+ requirement: !ruby/object:Gem::Requirement
279
+ none: false
280
+ requirements:
281
+ - - ! '>='
282
+ - !ruby/object:Gem::Version
283
+ version: '0'
284
+ type: :development
285
+ prerelease: false
286
+ version_requirements: !ruby/object:Gem::Requirement
287
+ none: false
288
+ requirements:
289
+ - - ! '>='
290
+ - !ruby/object:Gem::Version
291
+ version: '0'
292
+ description: Sidekiq middleware that adds the ability to rate limit job execution.
293
+ email:
294
+ - gabriel@codeconcoction.com
295
+ executables: []
296
+ extensions: []
297
+ extra_rdoc_files: []
298
+ files:
299
+ - .gitignore
300
+ - .pryrc
301
+ - .travis.yml
302
+ - .yardopts
303
+ - CHANGELOG.md
304
+ - Gemfile
305
+ - Guardfile
306
+ - LICENSE.txt
307
+ - README.md
308
+ - Rakefile
309
+ - lib/sidekiq-throttler.rb
310
+ - lib/sidekiq/throttler.rb
311
+ - lib/sidekiq/throttler/rate_limit.rb
312
+ - lib/sidekiq/throttler/version.rb
313
+ - sidekiq-throttler.gemspec
314
+ - spec/app/workers/custom_key_worker.rb
315
+ - spec/app/workers/lolz_worker.rb
316
+ - spec/app/workers/proc_worker.rb
317
+ - spec/app/workers/regular_worker.rb
318
+ - spec/sidekiq/throttler/rate_limit_spec.rb
319
+ - spec/sidekiq/throttler_spec.rb
320
+ - spec/spec.opts
321
+ - spec/spec_helper.rb
322
+ - spec/support/sidekiq.rb
323
+ homepage: https://github.com/gevans/sidekiq-throttler
324
+ licenses: []
325
+ post_install_message:
326
+ rdoc_options: []
327
+ require_paths:
328
+ - lib
329
+ required_ruby_version: !ruby/object:Gem::Requirement
330
+ none: false
331
+ requirements:
332
+ - - ! '>='
333
+ - !ruby/object:Gem::Version
334
+ version: '0'
335
+ segments:
336
+ - 0
337
+ hash: -2112495299373871691
338
+ required_rubygems_version: !ruby/object:Gem::Requirement
339
+ none: false
340
+ requirements:
341
+ - - ! '>='
342
+ - !ruby/object:Gem::Version
343
+ version: '0'
344
+ segments:
345
+ - 0
346
+ hash: -2112495299373871691
347
+ requirements: []
348
+ rubyforge_project:
349
+ rubygems_version: 1.8.24
350
+ signing_key:
351
+ specification_version: 3
352
+ summary: Sidekiq::Throttler is a middleware for Sidekiq that adds the ability to rate
353
+ limit job execution on a per-worker basis.
354
+ test_files:
355
+ - spec/app/workers/custom_key_worker.rb
356
+ - spec/app/workers/lolz_worker.rb
357
+ - spec/app/workers/proc_worker.rb
358
+ - spec/app/workers/regular_worker.rb
359
+ - spec/sidekiq/throttler/rate_limit_spec.rb
360
+ - spec/sidekiq/throttler_spec.rb
361
+ - spec/spec.opts
362
+ - spec/spec_helper.rb
363
+ - spec/support/sidekiq.rb
364
+ has_rdoc: