sidekiq-throttled 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 76a708f284e2c4b3c3a65477f8893420854aeca4
4
+ data.tar.gz: 8c5c83a1d64063af01170067819bf167fe710bfe
5
+ SHA512:
6
+ metadata.gz: 6e703df521b67bccb01d0e022dd83376ac6d9cd68d57e7603f36212b7e8969a0128e5119df0e6224c9da813308a743e8ffb1302f44142adfc82b43b163c82978
7
+ data.tar.gz: b0b142ad1f692d962c7ffa63027c309350d5c0258a05d5b9cb1adcc2eb1a491244d0d9dd1191ef1348ed2930f020a1f6a66bc902ff6ab4bd2814c0b98a30233e
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,5 @@
1
+ --backtrace
2
+ --color
3
+ --format=documentation
4
+ --order random
5
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,72 @@
1
+ ## Styles ######################################################################
2
+
3
+ Style/AlignParameters:
4
+ EnforcedStyle: with_fixed_indentation
5
+
6
+ Style/BracesAroundHashParameters:
7
+ Enabled: false
8
+
9
+ # Broken (2014-12-15). Use `yardstick` gem instead.
10
+ # See: https://github.com/bbatsov/rubocop/issues/947
11
+ # TODO: Enable back once cop is fixed.
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Style/EmptyLineBetweenDefs:
16
+ AllowAdjacentOneLineDefs: true
17
+
18
+ Style/Encoding:
19
+ EnforcedStyle: when_needed
20
+
21
+ Style/HashSyntax:
22
+ EnforcedStyle: hash_rockets
23
+
24
+ Style/IndentHash:
25
+ EnforcedStyle: consistent
26
+
27
+ # New lambda syntax is as ugly to me as new syntax of Hash.
28
+ Style/Lambda:
29
+ Enabled: false
30
+
31
+ Style/MultilineOperationIndentation:
32
+ EnforcedStyle: indented
33
+
34
+ # IMHO `%r{foo/bar}` looks way more cleaner than `/foo\/bar/`.
35
+ # Enabling this cop also makes Guardfile (which is full of pathname regexps)
36
+ # look absolutley (style) inconsistent and terrible. Thus it should be on
37
+ # developer's choice whenever to user `%r` or not. Just like we don't enforce
38
+ # to use `["foo"]` over `%w(foo)` and so on.
39
+ Style/RegexpLiteral:
40
+ Enabled: false
41
+
42
+ # A bit useless restriction, that makes impossible aligning code like this:
43
+ #
44
+ # redis do |conn|
45
+ # conn.hset :k1, now
46
+ # conn.hincrby :k2, 123
47
+ # end
48
+ Style/SingleSpaceBeforeFirstArg:
49
+ Enabled: false
50
+
51
+ Style/StringLiterals:
52
+ EnforcedStyle: double_quotes
53
+
54
+ # Not all trivial readers/writers can be defined with attr_* methods
55
+ #
56
+ # class Example < SimpleDelegator
57
+ # def __getobj__
58
+ # @obj
59
+ # end
60
+ #
61
+ # def __setobj__(obj)
62
+ # @obj = obj
63
+ # end
64
+ # end
65
+ Style/TrivialAccessors:
66
+ Enabled: false
67
+
68
+ ## Metrics #####################################################################
69
+
70
+ Metrics/MethodLength:
71
+ CountComments: false
72
+ Max: 15
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.6
4
+ before_install: gem install bundler -v 1.10.6
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --no-private - LICENSE.md
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in sidekiq-throttled.gemspec
4
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 SensorTower Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Sidekiq::Throttled
2
+
3
+ Concurrency and threshold throttling for Sidekiq.
4
+
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "sidekiq-throttled"
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ ```
17
+ $ bundle
18
+ ```
19
+
20
+ Or install it yourself as:
21
+
22
+ ```
23
+ $ gem install sidekiq-throttled
24
+ ```
25
+
26
+
27
+ ## Usage
28
+
29
+ Add somewhere in your app's bootstrap (e.g. `config/initializers/sidekiq.rb` if
30
+ you are using Rails):
31
+
32
+ ``` ruby
33
+ require "sidekiq/throttled"
34
+ Sidekiq::Throttled.setup!
35
+ ```
36
+
37
+ Once you've done that you can include `Sidekiq::Throttled::Worker` to your
38
+ job classes and configure throttling:
39
+
40
+ ``` ruby
41
+ class MyWorker
42
+ include Sidekiq::Worker
43
+ include Sidekiq::Throttled::Worker
44
+
45
+ sidekiq_options :queue => :my_queue
46
+
47
+ sidekiq_throttle({
48
+ # Allow maximum 10 concurrent jobs of this class at a time.
49
+ :concurrency => { :limit => 10 },
50
+ # Allow maximum 1K jobs being processed within one hour window.
51
+ :threshold => { :limit => 1_000, :period => 1.hour }
52
+ })
53
+
54
+ def perform
55
+ # ...
56
+ end
57
+ end
58
+ ```
59
+
60
+
61
+ ## Contributing
62
+
63
+ * Fork sidekiq-throttled on GitHub
64
+ * Make your changes
65
+ * Ensure all tests pass (`bundle exec rake`)
66
+ * Send a pull request
67
+ * If we like them we'll merge them
68
+ * If we've accepted a patch, feel free to ask for commit access!
69
+
70
+
71
+ ## Copyright
72
+
73
+ Copyright (c) 2015 SensorTower Inc.
74
+ See LICENSE.md for further details.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rspec/core/rake_task"
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ require "rubocop/rake_task"
7
+ RuboCop::RakeTask.new
8
+
9
+ task :default => [:spec, :rubocop]
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sidekiq/throttled"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,72 @@
1
+ # 3rd party
2
+ require "sidekiq"
3
+
4
+ # internal
5
+ require "sidekiq/version"
6
+ require "sidekiq/throttled/registry"
7
+ require "sidekiq/throttled/worker"
8
+
9
+ # @see https://github.com/mperham/sidekiq/
10
+ module Sidekiq
11
+ # Concurrency and threshold throttling for Sidekiq.
12
+ #
13
+ # Just add somewhere in your bootstrap:
14
+ #
15
+ # require "sidekiq/throttled"
16
+ # Sidekiq::Throttled.setup!
17
+ #
18
+ # Once you've done that you can include {Sidekiq::Throttled::Worker} to your
19
+ # job classes and configure throttling:
20
+ #
21
+ # class MyWorker
22
+ # include Sidekiq::Worker
23
+ # include Sidekiq::Throttled::Worker
24
+ #
25
+ # sidekiq_options :queue => :my_queue
26
+ #
27
+ # sidekiq_throttle({
28
+ # # Allow maximum 10 concurrent jobs of this class at a time.
29
+ # :concurrency => { :limit => 10 },
30
+ # # Allow maximum 1K jobs being processed within one hour window.
31
+ # :threshold => { :limit => 1_000, :period => 1.hour }
32
+ # })
33
+ #
34
+ # def perform
35
+ # # ...
36
+ # end
37
+ # end
38
+ module Throttled
39
+ class << self
40
+ # Hooks throttler into sidekiq.
41
+ # @return [void]
42
+ def setup!
43
+ Sidekiq.configure_server do |config|
44
+ require "sidekiq/throttled/basic_fetch"
45
+ Sidekiq.options[:fetch] = Sidekiq::Throttled::BasicFetch
46
+
47
+ require "sidekiq/throttled/middleware"
48
+ config.server_middleware do |chain|
49
+ chain.add Sidekiq::Throttled::Middleware
50
+ end
51
+ end
52
+ end
53
+
54
+ # @param [String] message JSON payload of job
55
+ # @return [TrueClass] if job is not allowed to be processed now
56
+ # @return [FalseClass] otherwise
57
+ def throttled?(message)
58
+ message = JSON.parse message
59
+ job = message.fetch("class".freeze) { return false }
60
+ jid = message.fetch("jid".freeze) { return false }
61
+
62
+ Registry.get job do |strategy|
63
+ return strategy.throttled? jid
64
+ end
65
+
66
+ false
67
+ rescue
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,63 @@
1
+ # stdlib
2
+ require "thread"
3
+
4
+ # 3rd party
5
+ require "celluloid"
6
+ require "sidekiq"
7
+ require "sidekiq/fetch"
8
+
9
+ module Sidekiq
10
+ module Throttled
11
+ # Throttled version of `Sidekiq::BasicFetch` fetcher strategy.
12
+ class BasicFetch < ::Sidekiq::BasicFetch
13
+ # Class constructor
14
+ def initialize(*args)
15
+ @mutex = Mutex.new
16
+ @suspended = []
17
+
18
+ super(*args)
19
+ end
20
+
21
+ # @return [Sidekiq::BasicFetch::UnitOfWork, nil]
22
+ def retrieve_work
23
+ work = brpop
24
+ return unless work
25
+
26
+ work = ::Sidekiq::BasicFetch::UnitOfWork.new(*work)
27
+ return work unless Throttled.throttled? work.message
28
+
29
+ queue = "queue:#{work.queue_name}"
30
+
31
+ @mutex.synchronize { @suspended << queue }
32
+ Sidekiq.redis { |conn| conn.lpush(queue, work.message) }
33
+
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ # Tries to pop pair of `queue` and job `message` out of sidekiq queue.
40
+ # @return [Array<String, String>, nil]
41
+ def brpop
42
+ if @strictly_ordered_queues
43
+ queues = @unique_queues.dup
44
+ else
45
+ queues = @queues.shuffle.uniq
46
+ end
47
+
48
+ @mutex.synchronize do
49
+ next if @suspended.empty?
50
+ queues -= @suspended
51
+ @suspended.clear
52
+ end
53
+
54
+ if queues.empty?
55
+ sleep Sidekiq::Fetcher::TIMEOUT
56
+ return
57
+ end
58
+
59
+ Sidekiq.redis { |conn| conn.brpop(*queues, Sidekiq::Fetcher::TIMEOUT) }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,6 @@
1
+ module Sidekiq
2
+ module Throttled
3
+ # Generic class for Sidekiq::Throttled errors
4
+ class Error < StandardError; end
5
+ end
6
+ end
@@ -0,0 +1,19 @@
1
+ # internal
2
+ require "sidekiq/throttled/registry"
3
+
4
+ module Sidekiq
5
+ module Throttled
6
+ # Server middleware that notifies strategy that job was finished.
7
+ # @private
8
+ class Middleware
9
+ # Called within Sidekiq job processing
10
+ def call(_worker, msg, _queue)
11
+ yield
12
+ ensure
13
+ Registry.get msg["class".freeze] do |strategy|
14
+ strategy.finalize! msg["jid".freeze]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,73 @@
1
+ # internal
2
+ require "sidekiq/throttled/strategy"
3
+
4
+ module Sidekiq
5
+ module Throttled
6
+ # Registred strategies.
7
+ module Registry
8
+ @strategies = {}
9
+ @aliases = {}
10
+
11
+ class << self
12
+ # Adds strategy to the registry.
13
+ #
14
+ # @note prints a warning to STDERR upon duplicate strategy name
15
+ # @param (see Strategy#initialize)
16
+ # @return [Strategy]
17
+ def add(name, **kwargs)
18
+ name = name.to_s
19
+
20
+ warn "Duplicate strategy name: #{name}" if @strategies[name]
21
+
22
+ @strategies[name] = Strategy.new(name, **kwargs)
23
+ end
24
+
25
+ # Adds alias for existing strategy.
26
+ #
27
+ # @note prints a warning to STDERR upon duplicate strategy name
28
+ # @param (#to_s) new_name
29
+ # @param (#to_s) old_name
30
+ # @raise [RuntimeError] if no strategy found with `old_name`
31
+ # @return [Strategy]
32
+ def add_alias(new_name, old_name)
33
+ new_name = new_name.to_s
34
+ old_name = old_name.to_s
35
+
36
+ warn "Duplicate strategy name: #{new_name}" if @strategies[new_name]
37
+ fail "Strategy not found: #{old_name}" unless @strategies[old_name]
38
+
39
+ @aliases[new_name] = @strategies[old_name]
40
+ end
41
+
42
+ # @overload get(name)
43
+ # @param [#to_s] name
44
+ # @return [Strategy, nil] registred strategy
45
+ #
46
+ # @overload get(name, &block)
47
+ # Yields control to the block if requested strategy was found.
48
+ # @yieldparam [Strategy] strategy
49
+ # @yield [strategy] Gives found strategy to the block
50
+ # @return result of a block
51
+ def get(name)
52
+ strategy = @strategies[name.to_s] || @aliases[name.to_s]
53
+ return yield strategy if strategy && block_given?
54
+ strategy
55
+ end
56
+
57
+ # @overload each()
58
+ # @return [Enumerator]
59
+ #
60
+ # @overload each(&block)
61
+ # @yieldparam [String] name
62
+ # @yieldparam [Strategy] strategy
63
+ # @yield [strategy] Gives strategy to the block
64
+ # @return [Registry]
65
+ def each
66
+ return to_enum(__method__) unless block_given?
67
+ @strategies.each { |*args| yield(*args) }
68
+ self
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,60 @@
1
+ # internal
2
+ require "sidekiq/throttled/errors"
3
+ require "sidekiq/throttled/strategy/concurrency"
4
+ require "sidekiq/throttled/strategy/threshold"
5
+
6
+ module Sidekiq
7
+ module Throttled
8
+ # Meta-strategy that couples {Concurrency} and {Threshold} strategies.
9
+ class Strategy
10
+ # @!attribute [r] concurrency
11
+ # @return [Strategy::Concurrency, nil]
12
+ attr_reader :concurrency
13
+
14
+ # @!attribute [r] threshold
15
+ # @return [Strategy::Threshold, nil]
16
+ attr_reader :threshold
17
+
18
+ # @param [#to_s] key
19
+ # @param [Hash] concurrency Concurrency options.
20
+ # See {Strategy::Concurrency#initialize} for details.
21
+ # @param [Hash] threshold Threshold options.
22
+ # See {Strategy::Threshold#initialize} for details.
23
+ def initialize(key, concurrency: nil, threshold: nil)
24
+ base_key = "throttled:#{key}"
25
+
26
+ @concurrency = concurrency && Concurrency.new(base_key, concurrency)
27
+ @threshold = threshold && Threshold.new(base_key, threshold)
28
+
29
+ return if @concurrency || @threshold
30
+
31
+ fail ArgumentError, "Neither :concurrency nor :threshold given"
32
+ end
33
+
34
+ # @return [Boolean] whenever job is throttled or not.
35
+ def throttled?(jid)
36
+ return true if @concurrency && @concurrency.throttled?(jid)
37
+
38
+ if @threshold && @threshold.throttled?
39
+ finalize! jid
40
+ return true
41
+ end
42
+
43
+ false
44
+ end
45
+
46
+ # Marks job as being processed.
47
+ # @return [void]
48
+ def finalize!(jid)
49
+ @concurrency && @concurrency.finalize!(jid)
50
+ end
51
+
52
+ # Resets count of jobs of all avaliable strategies
53
+ # @return [void]
54
+ def reset!
55
+ @concurrency && @concurrency.reset!
56
+ @threshold && @threshold.reset!
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ local r, k, l, t, j = redis, KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), ARGV[3]
2
+ if l <= r.call("SCARD", k) and 0 == r.call("SISMEMBER", k, j) then return 1 end
3
+ r.call("SADD", k, j); r.call("EXPIRE", k, t); return 0
@@ -0,0 +1,59 @@
1
+ # internal
2
+ require "sidekiq/throttled/strategy/script"
3
+
4
+ module Sidekiq
5
+ module Throttled
6
+ class Strategy
7
+ # Concurrency throttling strategy
8
+ class Concurrency
9
+ # LUA script used to limit fetch concurrency.
10
+ # Logic behind the scene can be described in following pseudo code:
11
+ #
12
+ # return 1 if @limit <= LLEN(@key)
13
+ #
14
+ # PUSH(@key, @jid)
15
+ # return 0
16
+ SCRIPT = Script.new File.read "#{__dir__}/concurrency.lua"
17
+ private_constant :SCRIPT
18
+
19
+ # @!attribute [r] limit
20
+ # @return [Integer] Amount of allwoed concurrent job processors
21
+ attr_reader :limit
22
+
23
+ # @param [#to_s] base_key
24
+ # @param [Hash] opts
25
+ # @option opts [#to_i] :limit Amount of allwoed concurrent jobs
26
+ # processors running for given key
27
+ # @option opts [#to_i] :ttl (15.minutes) Concurrency lock TTL
28
+ def initialize(base_key, opts)
29
+ @key = "#{base_key}:concurrency".freeze
30
+ @keys = [@key]
31
+ @limit = opts.fetch(:limit).to_i
32
+ @ttl = opts.fetch(:ttl, 900).to_i
33
+ end
34
+
35
+ # @return [Boolean] whenever job is throttled or not
36
+ def throttled?(jid)
37
+ 1 == SCRIPT.eval(@keys, [@limit, @ttl, jid.to_s])
38
+ end
39
+
40
+ # @return [Integer] Current count of jobs
41
+ def count
42
+ Sidekiq.redis { |conn| conn.scard(@key) }.to_i
43
+ end
44
+
45
+ # Resets count of jobs
46
+ # @return [void]
47
+ def reset!
48
+ Sidekiq.redis { |conn| conn.del(@key) }.to_i
49
+ end
50
+
51
+ # Remove jid from the pool of jobs in progress
52
+ # @return [void]
53
+ def finalize!(jid)
54
+ Sidekiq.redis { |conn| conn.srem(@key, jid.to_s) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,45 @@
1
+ module Sidekiq
2
+ module Throttled
3
+ class Strategy
4
+ # Lua script executor for redis.
5
+ #
6
+ # Instead of executing script with `EVAL` everytime - loads script once
7
+ # and then runs it with `EVALSHA`.
8
+ #
9
+ # @private
10
+ class Script
11
+ # Script load command
12
+ LOAD = "load".freeze
13
+ private_constant :LOAD
14
+
15
+ # Redis error fired when script ID is unkown
16
+ NOSCRIPT = "NOSCRIPT".freeze
17
+ private_constant :NOSCRIPT
18
+
19
+ # @param [#to_s] source Lua script
20
+ def initialize(source)
21
+ @source = source.to_s.strip.freeze
22
+ @sha = nil
23
+ end
24
+
25
+ # Executes script and returns result of execution
26
+ def eval(*args)
27
+ Sidekiq.redis { |conn| conn.evalsha(@sha, *args) }
28
+ rescue => e
29
+ raise unless e.message.include? NOSCRIPT
30
+ load_and_eval(*args)
31
+ end
32
+
33
+ private
34
+
35
+ # Loads script into redis cache and executes it.
36
+ def load_and_eval(*args)
37
+ Sidekiq.redis do |conn|
38
+ @sha = conn.script(LOAD, @source)
39
+ conn.evalsha(@sha, *args)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ local r, k, l, p, t = redis, KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
2
+ if l <= r.call("LLEN", k) and t - r.call("LINDEX", k, -1) < p then return 1 end
3
+ r.call("LPUSH", k, t); r.call("LTRIM", k, 0, l - 1); r.call("EXPIRE", k, p); return 0
@@ -0,0 +1,68 @@
1
+ # internal
2
+ require "sidekiq/throttled/strategy/script"
3
+
4
+ module Sidekiq
5
+ module Throttled
6
+ class Strategy
7
+ # Threshold throttling strategy
8
+ # @todo Use redis TIME command instead of sending current timestamp from
9
+ # sidekiq manager. See: http://redis.io/commands/time
10
+ class Threshold
11
+ # LUA script used to limit fetch threshold.
12
+ # Logic behind the scene can be described in following pseudo code:
13
+ #
14
+ # def exceeded?
15
+ # @limit <= LLEN(@key) && NOW - LINDEX(@key, -1) < @period
16
+ # end
17
+ #
18
+ # def increase!
19
+ # LPUSH(@key, NOW)
20
+ # LTRIM(@key, 0, @limit - 1)
21
+ # EXPIRE(@key, @period)
22
+ # end
23
+ #
24
+ # return 1 if exceeded?
25
+ #
26
+ # increase!
27
+ # return 0
28
+ SCRIPT = Script.new File.read "#{__dir__}/threshold.lua"
29
+ private_constant :SCRIPT
30
+
31
+ # @!attribute [r] limit
32
+ # @return [Integer] Amount of jobs allowed per period
33
+ attr_reader :limit
34
+
35
+ # @!attribute [r] period
36
+ # @return [Float] Period in seconds
37
+ attr_reader :period
38
+
39
+ # @param [#to_s] base_key
40
+ # @param [Hash] opts
41
+ # @option opts [#to_i] :limit Amount of jobs allowed per period
42
+ # @option opts [#to_f] :period Period in seconds
43
+ def initialize(base_key, opts)
44
+ @key = "#{base_key}:threshold".freeze
45
+ @keys = [@key]
46
+ @limit = opts.fetch(:limit).to_i
47
+ @period = opts.fetch(:period).to_f
48
+ end
49
+
50
+ # @return [Boolean] whenever job is throttled or not
51
+ def throttled?
52
+ 1 == SCRIPT.eval(@keys, [@limit, @period, Time.now.to_f])
53
+ end
54
+
55
+ # @return [Integer] Current count of jobs
56
+ def count
57
+ Sidekiq.redis { |conn| conn.llen(@key) }.to_i
58
+ end
59
+
60
+ # Resets count of jobs
61
+ # @return [void]
62
+ def reset!
63
+ Sidekiq.redis { |conn| conn.del(@key) }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,10 @@
1
+ require "sidekiq/throttled/registry"
2
+
3
+ RSpec.configure do |config|
4
+ config.before :example do
5
+ Sidekiq::Throttled::Registry.instance_eval do
6
+ @strategies.clear
7
+ @aliases.clear
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module Sidekiq
2
+ module Throttled
3
+ # Gem version
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
@@ -0,0 +1,33 @@
1
+ # stdlib
2
+ require "pathname"
3
+
4
+ # 3rd party
5
+ require "sidekiq"
6
+ require "sidekiq/web"
7
+
8
+ # internal
9
+ require "sidekiq/throttled/registry"
10
+ require "sidekiq/throttled/web/stats"
11
+
12
+ module Sidekiq
13
+ module Throttled
14
+ # Provides Sidekiq tab to monitor and reset throttled stats.
15
+ # @private
16
+ module Web
17
+ class << self
18
+ def registered(app)
19
+ template = Pathname.new(__FILE__).join("../web/index.html.erb").read
20
+ app.get("/throttled") { erb template.dup }
21
+
22
+ app.delete("/throttled/:id") do
23
+ Registry.get(params[:id], &:reset!)
24
+ redirect "#{root_path}throttled"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ Sidekiq::Web.register Sidekiq::Throttled::Web
33
+ Sidekiq::Web.tabs["Throttled"] = "throttled"
@@ -0,0 +1,38 @@
1
+ <div class="col-sm-12">
2
+ <div class="row header">
3
+ <div class="col-sm-8 pull-left">
4
+ <h3>Throttled</h3>
5
+ </div>
6
+ </div>
7
+ </div>
8
+
9
+ <table class="table table-hover table-bordered table-striped table-white">
10
+ <thead>
11
+ <tr>
12
+ <th>Name</th>
13
+ <th style="text-align:center;">Concurrency</th>
14
+ <th style="text-align:center;">Threshold</th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% Sidekiq::Throttled::Registry.each do |name, strategy| %>
20
+ <tr>
21
+ <td style="vertical-align:middle;"><%= name %></td>
22
+ <td style="vertical-align:middle;text-align:center;">
23
+ <%= Sidekiq::Throttled::Web::Stats.new(strategy.concurrency).to_html %>
24
+ </td>
25
+ <td style="vertical-align:middle;text-align:center;">
26
+ <%= Sidekiq::Throttled::Web::Stats.new(strategy.threshold).to_html %>
27
+ </td>
28
+ <td style="vertical-align:middle;text-align:center;">
29
+ <form action="<%= root_path %>throttled/<%= name %>" method="POST">
30
+ <%= csrf_tag %>
31
+ <input type="hidden" name="_method" value="delete" />
32
+ <button class="btn btn-danger" type="submit">Reset</button>
33
+ </form>
34
+ </td>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
@@ -0,0 +1,72 @@
1
+ module Sidekiq
2
+ module Throttled
3
+ module Web
4
+ # Throttle strategy stats generation helper
5
+ # @private
6
+ class Stats
7
+ TIME_CONVERSION = [
8
+ [60 * 60 * 24, "day", "days"],
9
+ [60 * 60, "hour", "hours"],
10
+ [60, "minute", "minutes"],
11
+ [1, "second", "seconds"]
12
+ ].freeze
13
+
14
+ # @param [Strategy::Concurrency, Strategy::Threshold] strategy
15
+ def initialize(strategy)
16
+ @strategy = strategy
17
+ end
18
+
19
+ # @return [String]
20
+ def to_html
21
+ return "" unless @strategy
22
+
23
+ html = humanize_integer(@strategy.limit) << " jobs"
24
+
25
+ if @strategy.respond_to? :period
26
+ html << " per " << humanize_duration(@strategy.period)
27
+ end
28
+
29
+ html << "<br />" << colorize_count(@strategy.count, @strategy.limit)
30
+ end
31
+
32
+ private
33
+
34
+ # @return [String]
35
+ def colorize_count(int, max)
36
+ percentile = 100.00 * int / max
37
+ lvl = case
38
+ when 80 <= percentile then "danger"
39
+ when 60 <= percentile then "warning"
40
+ else "success"
41
+ end
42
+
43
+ %(<span class="label label-#{lvl}">#{int}</span>)
44
+ end
45
+
46
+ # @return [String]
47
+ def humanize_duration(int)
48
+ arr = []
49
+
50
+ TIME_CONVERSION.each do |(dimension, unit, units)|
51
+ count = (int / dimension).to_i
52
+ next unless 0 < count
53
+ int -= count * dimension
54
+ arr << "#{count} #{1 == count ? unit : units}"
55
+ end
56
+
57
+ arr.join " "
58
+ end
59
+
60
+ # @return [String]
61
+ def humanize_integer(int)
62
+ digits = int.to_s.split ""
63
+ str = digits.shift(digits.count % 3).join("")
64
+
65
+ str << " " << digits.shift(3).join("") while 0 < digits.count
66
+
67
+ str.strip
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,126 @@
1
+ # internal
2
+ require "sidekiq/throttled/registry"
3
+
4
+ module Sidekiq
5
+ module Throttled
6
+ # Adds helpers to your worker classes
7
+ #
8
+ # @example Usage
9
+ #
10
+ # class MyWorker
11
+ # include Sidekiq::Worker
12
+ # include Sidekiq::Throttled::Worker
13
+ #
14
+ # sidkiq_options :queue => :my_queue
15
+ # sidekiq_throttle :threshold => { :limit => 123, :period => 1.hour }
16
+ #
17
+ # def perform
18
+ # # ...
19
+ # end
20
+ # end
21
+ #
22
+ # @see ClassMethods
23
+ module Worker
24
+ # Extends worker class with {ClassMethods}.
25
+ #
26
+ # @note Using `included` hook with extending worker with {ClassMethods}
27
+ # in order to make API inline with `include Sidekiq::Worker`.
28
+ #
29
+ # @private
30
+ def self.included(worker)
31
+ worker.send(:extend, ClassMethods)
32
+ end
33
+
34
+ # Helper methods added to the singleton class of destination
35
+ module ClassMethods
36
+ # Registers some strategy for the worker.
37
+ #
38
+ # @example Allow max 123 MyWorker jobs per hour
39
+ #
40
+ # class MyWorker
41
+ # include Sidekiq::Worker
42
+ # include Sidekiq::Throttled::Worker
43
+ #
44
+ # sidekiq_throttle({
45
+ # :threshold => { :limit => 123, :period => 1.hour }
46
+ # })
47
+ # end
48
+ #
49
+ # @example Allow max 10 concurrently running MyWorker jobs
50
+ #
51
+ # class MyWorker
52
+ # include Sidekiq::Worker
53
+ # include Sidekiq::Throttled::Worker
54
+ #
55
+ # sidekiq_throttle({
56
+ # :concurrency => { :limit => 10 }
57
+ # })
58
+ # end
59
+ #
60
+ # @example Allow max 10 concurrent MyWorker jobs and max 123 per hour
61
+ #
62
+ # class MyWorker
63
+ # include Sidekiq::Worker
64
+ # include Sidekiq::Throttled::Worker
65
+ #
66
+ # sidekiq_throttle({
67
+ # :threshold => { :limit => 123, :period => 1.hour },
68
+ # :concurrency => { :limit => 10 }
69
+ # })
70
+ # end
71
+ #
72
+ # @see Registry.add
73
+ # @return [void]
74
+ def sidekiq_throttle(**kwargs)
75
+ Registry.add(self, **kwargs)
76
+ end
77
+
78
+ # Adds current worker to preconfigured throtttling strtegy. Allows
79
+ # sharing same pool for multiple workers.
80
+ #
81
+ # First of all we need to create shared throttling strategy:
82
+ #
83
+ # # Create google_api throttling strategy
84
+ # Sidekiq::Throttled::Registry.add(:google_api, {
85
+ # :threshold => { :limit => 123, :period => 1.hour },
86
+ # :concurrency => { :limit => 123 }
87
+ # })
88
+ #
89
+ # Now we can assign it to our workers:
90
+ #
91
+ # class FetchProfileJob
92
+ # include Sidekiq::Worker
93
+ # include Sidekiq::Throttled::Worker
94
+ #
95
+ # sidekiq_throttle_as :google_api
96
+ # end
97
+ #
98
+ # class FetchCommentsJob
99
+ # include Sidekiq::Worker
100
+ # include Sidekiq::Throttled::Worker
101
+ #
102
+ # sidekiq_throttle_as :google_api
103
+ # end
104
+ #
105
+ # With the above configuration we ensure that there are maximum 10
106
+ # concurrently running jobs of FetchProfileJob or FetchCommentsJob
107
+ # allowed. And only 123 jobs of those are executed per hour.
108
+ #
109
+ # In other words, it will allow:
110
+ #
111
+ # - only `X` concurrent `FetchProfileJob`s
112
+ # - max `XX` `FetchProfileJob` per hour
113
+ # - only `Y` concurrent `FetchCommentsJob`s
114
+ # - max `YY` `FetchCommentsJob` per hour
115
+ #
116
+ # Where `(X + Y) == 10` and `(XX + YY) == 123`
117
+ #
118
+ # @see Registry.add_alias
119
+ # @return [void]
120
+ def sidekiq_throttle_as(name)
121
+ Registry.add_alias(self, name)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+
6
+ require "sidekiq/throttled/version"
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = "sidekiq-throttled"
10
+ spec.version = Sidekiq::Throttled::VERSION
11
+ spec.authors = ["Alexey V Zapparov"]
12
+ spec.email = ["ixti@member.fsf.org"]
13
+
14
+ spec.summary = "Concurrency and threshold throttling for Sidekiq."
15
+ spec.description = "Concurrency and threshold throttling for Sidekiq."
16
+ spec.homepage = "https://github.com/sensortower/sidekiq-throttled"
17
+ spec.license = "MIT"
18
+
19
+ spec.files = `git ls-files -z`.split("\x0")
20
+ .reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_runtime_dependency "sidekiq", "< 4"
27
+
28
+ spec.add_development_dependency "bundler", "~> 1.10"
29
+ spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "rspec"
31
+ spec.add_development_dependency "timecop"
32
+ spec.add_development_dependency "rubocop"
33
+ spec.add_development_dependency "rack-test"
34
+ spec.add_development_dependency "sinatra", "~> 1.4", ">= 1.4.6"
35
+ end
metadata ADDED
@@ -0,0 +1,191 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-throttled
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexey V Zapparov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2015-11-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sidekiq
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "<"
18
+ - !ruby/object:Gem::Version
19
+ version: '4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "<"
25
+ - !ruby/object:Gem::Version
26
+ version: '4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: timecop
71
+ requirement: !ruby/object:Gem::Requirement
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
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rack-test
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: sinatra
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.4'
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 1.4.6
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - "~>"
126
+ - !ruby/object:Gem::Version
127
+ version: '1.4'
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 1.4.6
131
+ description: Concurrency and threshold throttling for Sidekiq.
132
+ email:
133
+ - ixti@member.fsf.org
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - ".gitignore"
139
+ - ".rspec"
140
+ - ".rubocop.yml"
141
+ - ".travis.yml"
142
+ - ".yardopts"
143
+ - Gemfile
144
+ - LICENSE.md
145
+ - README.md
146
+ - Rakefile
147
+ - bin/console
148
+ - bin/setup
149
+ - lib/sidekiq/throttled.rb
150
+ - lib/sidekiq/throttled/basic_fetch.rb
151
+ - lib/sidekiq/throttled/errors.rb
152
+ - lib/sidekiq/throttled/middleware.rb
153
+ - lib/sidekiq/throttled/registry.rb
154
+ - lib/sidekiq/throttled/strategy.rb
155
+ - lib/sidekiq/throttled/strategy/concurrency.lua
156
+ - lib/sidekiq/throttled/strategy/concurrency.rb
157
+ - lib/sidekiq/throttled/strategy/script.rb
158
+ - lib/sidekiq/throttled/strategy/threshold.lua
159
+ - lib/sidekiq/throttled/strategy/threshold.rb
160
+ - lib/sidekiq/throttled/testing.rb
161
+ - lib/sidekiq/throttled/version.rb
162
+ - lib/sidekiq/throttled/web.rb
163
+ - lib/sidekiq/throttled/web/index.html.erb
164
+ - lib/sidekiq/throttled/web/stats.rb
165
+ - lib/sidekiq/throttled/worker.rb
166
+ - sidekiq-throttled.gemspec
167
+ homepage: https://github.com/sensortower/sidekiq-throttled
168
+ licenses:
169
+ - MIT
170
+ metadata: {}
171
+ post_install_message:
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubyforge_project:
187
+ rubygems_version: 2.2.3
188
+ signing_key:
189
+ specification_version: 4
190
+ summary: Concurrency and threshold throttling for Sidekiq.
191
+ test_files: []