sidekiq-throttler 0.1.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.
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: