restrainer 1.0.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: c864dec39322b1a42a062119de6bac2a5a9134db
4
+ data.tar.gz: 2cbc9b6c3b78a4b6bb4fd706bdc645543366a6a6
5
+ SHA512:
6
+ metadata.gz: 275f22b4379067198e130603461ccd92b0da8e85ef05dd9fe142fa02c6e202b358b9512cbd8146b7c5ded56145380e2ab9b168f729ed8c00223300321217ab18
7
+ data.tar.gz: 9c30e5358e500d7172a4dd3924e5c64018fb4daed80d8a781786846ae9b7f0069e167af5156fc38b389c991e8a2c7ce08d1262155ed89c43dd6ae39d2fefcd13
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/MIT_LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 WHI, Inc.
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,40 @@
1
+ This gem provides a method of throttling calls across processes that can be very useful if you have to call an external service with limited resources.
2
+
3
+ A [redis server](http://redis.io/) is required to use this gem.
4
+
5
+ ### Usage
6
+
7
+ This code will throttle all calls to the mythical MyService so that no more than 100 calls are ever being made at a single time across all application processes.
8
+
9
+ ```ruby
10
+ restrainer = Restrainer.new(:my_service, limit: 100)
11
+ restrainer.throttle do
12
+ MyServiceClient.call
13
+ end
14
+ ```
15
+
16
+ You can also override the limit in the `throttle` method. Setting a limit of zero will disable processing entirely. Setting a limit less than zero will remove the limit. Note that the limit set in the throttle is not shared with other processes, but the count of the number of processes is shared. Thus it is possible to have the throttle allow one process but reject another if the limits are different.
17
+
18
+ Instances of Restrainer do not use any internal state to keep track of number of running processes. All of that information is maintained in redis. Therefore you don't need to worry about maintaining references to Restrainer instances and you can create them as needed as long as they are named consistently. You can create multiple Restrainers for different uses in your application by simply giving them different names.
19
+
20
+ ### Configuration
21
+
22
+ To set the redis connection used by for the gem you can either specify a block that yields a Redis object (from the [redis](https://github.com/redis/redis-rb) gem) or you can explicitly set the attribute. The block form is generally preferred since it can work with connection pools, etc.
23
+
24
+ ```ruby
25
+ Restrainer.redis{ connection_pool.redis }
26
+
27
+ Restrainer.redis = redis_client
28
+ ```
29
+
30
+ ### Internals
31
+
32
+ To protect against situations where a process is killed without a chance to cleanup after itself (i.e. `kill -9`), each process is only tracked for a limited amount of time (one minute by default). After this time, the Restrainer will assume that the process has been orphaned and removes it from the list.
33
+
34
+ The timeout can be set by the timeout option on the constructor. If you have any timeouts set on the services being called in the block, you should set the Restrainer timeout to a slightly higher value.
35
+
36
+ ```ruby
37
+ restrainer = Restrainer.new(:my_service, 100, timeout: 10)
38
+ ```
39
+
40
+ This gem does clean up after itself nicely, so that it won't ever leave unused data lying around in redis.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ desc 'Default: run unit tests.'
4
+ task :default => :test
5
+
6
+ desc 'RVM likes to call it tests'
7
+ task :tests => :test
8
+
9
+ begin
10
+ require 'rspec'
11
+ require 'rspec/core/rake_task'
12
+ desc 'Run the unit tests'
13
+ RSpec::Core::RakeTask.new(:test)
14
+ rescue LoadError
15
+ task :test do
16
+ STDERR.puts "You must have rspec 2.0 installed to run the tests"
17
+ end
18
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
data/lib/restrainer.rb ADDED
@@ -0,0 +1,130 @@
1
+ require 'redis'
2
+ require 'securerandom'
3
+
4
+ # Redis backed throttling mechanism to ensure that only a limited number of processes can
5
+ # be executed at any one time.
6
+ #
7
+ # Usage:
8
+ # Restrainer.new(:foo, 10).throttle do
9
+ # # Do something
10
+ # end
11
+ #
12
+ # If more than the specified number of processes as identified by the name argument is currently
13
+ # running, then the throttle block will raise an error.
14
+ class Restrainer
15
+
16
+ attr_reader :name, :limit
17
+
18
+ class ThrottledError < StandardError
19
+ end
20
+
21
+ class << self
22
+ # Either configure the redis instance using a block or yield the instance. Configuring with
23
+ # a block allows you to use things like connection pools etc. without hard coding a single
24
+ # instance.
25
+ #
26
+ # Example: `Restrainer.redis{ redis_pool.instance }
27
+ def redis(&block)
28
+ if block
29
+ @redis = block
30
+ elsif defined?(@redis) && @redis
31
+ @redis.call
32
+ else
33
+ raise "#{self.class.name}.redis not configured"
34
+ end
35
+ end
36
+
37
+ # Set the redis instance to a specific instance. It is usually preferable to use the block
38
+ # form for configurating the instance.
39
+ def redis=(conn)
40
+ @redis = lambda{ conn }
41
+ end
42
+ end
43
+
44
+ # Create a new restrainer. The name is used to identify the Restrainer and group processes together.
45
+ # You can create any number of Restrainers with different names.
46
+ #
47
+ # The required limit parameter specifies the maximum number of processes that will be allowed to execute the
48
+ # throttle block at any point in time.
49
+ #
50
+ # The timeout parameter is used for cleaning up internal data structures so that jobs aren't orphaned
51
+ # if their process is killed. Processes will automatically be removed from the running jobs list after the
52
+ # specified number of seconds. Note that the Restrainer will not handle timing out any code itself. This
53
+ # value is just used to insure the integrity of internal data structures.
54
+ def initialize(name, limit:, timeout: 60)
55
+ @name = name
56
+ @limit = limit
57
+ @timeout = timeout
58
+ @key = "#{self.class.name}.#{name.to_s}"
59
+ end
60
+
61
+ # Wrap a block with this method to throttle concurrent execution. If more than the alotted number
62
+ # of processes (as identified by the name) are currently executing, then a Restrainer::ThrottledError
63
+ # will be raised.
64
+ #
65
+ # The limit argument can be used to override the value set in the constructor.
66
+ def throttle(limit: nil)
67
+ limit ||= self.limit
68
+
69
+ # limit of less zero is no limit; limit of zero is allow none
70
+ return yield if limit < 0
71
+ raise ThrottledError.new("#{self.class}: #{@name} is not allowing any processing") if limit == 0
72
+
73
+ # Grab a reference to the redis instance to that it will be consistent throughout the method
74
+ redis = self.class.redis
75
+ check_running_count!(redis, limit)
76
+ process_id = SecureRandom.uuid
77
+ begin
78
+ add_process!(redis, process_id)
79
+ yield
80
+ ensure
81
+ remove_process!(redis, process_id)
82
+ end
83
+ end
84
+
85
+ # Get the number of processes currently being executed for this restrainer.
86
+ def current(redis = nil)
87
+ redis ||= self.class.redis
88
+ redis.zcard(key).to_i
89
+ end
90
+
91
+ private
92
+
93
+ # Hash key in redis to story a sorted set of current processes.
94
+ def key
95
+ @key
96
+ end
97
+
98
+ # Raise an error if there are too many running processes.
99
+ def check_running_count!(redis, limit)
100
+ running_count = current(redis)
101
+ if running_count >= limit
102
+ running_count = current(redis) if cleanup!(redis)
103
+ if running_count >= limit
104
+ raise ThrottledError.new("#{self.class}: #{@name} already has #{running_count} processes running")
105
+ end
106
+ end
107
+ end
108
+
109
+ # Add a process to the currently run set.
110
+ def add_process!(redis, process_id)
111
+ redis.multi do |conn|
112
+ conn.zadd(key, Time.now.to_i, process_id)
113
+ conn.expire(key, @timeout)
114
+ end
115
+ end
116
+
117
+ # Remove a process to the currently run set.
118
+ def remove_process!(redis, process_id)
119
+ redis.zrem(key, process_id)
120
+ end
121
+
122
+ # Protect against kill -9 which can cause processes to not be removed from the lists.
123
+ # Processes will be assumed to have finished by a specified timeout (in seconds).
124
+ def cleanup!(redis)
125
+ max_score = Time.now.to_i - @timeout
126
+ expired = redis.zremrangebyscore(key, "-inf", max_score)
127
+ expired > 0 ? true : false
128
+ end
129
+
130
+ end
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "restrainer"
7
+ spec.version = File.read(File.expand_path("../VERSION", __FILE__)).chomp
8
+ spec.authors = ["We Heart It", "Brian Durand"]
9
+ spec.email = ["dev@weheartit.com", "bbdurand@gmail.com"]
10
+ spec.summary = "Code for throttling workloads so as not to overwhelm external services"
11
+ spec.description = "Code for throttling workloads so as not to overwhelm external services."
12
+ spec.homepage = "https://github.com/weheartit/restrainer"
13
+ spec.license = "MIT"
14
+ spec.files = `git ls-files`.split($/)
15
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.required_ruby_version = '>=2.0'
20
+
21
+ spec.add_dependency('redis')
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.3"
24
+ spec.add_development_dependency "rake"
25
+ spec.add_development_dependency "rspec"
26
+ spec.add_development_dependency "timecop"
27
+ end
@@ -0,0 +1,70 @@
1
+ require 'spec_helper'
2
+
3
+ describe Restrainer do
4
+
5
+ it "should have a name and max_processes" do
6
+ restrainer = Restrainer.new(:restrainer_test, limit: 1)
7
+ expect(restrainer.name).to eq(:restrainer_test)
8
+ expect(restrainer.limit).to eq(1)
9
+ end
10
+
11
+ it "should run a block" do
12
+ restrainer = Restrainer.new(:restrainer_test, limit: 1)
13
+ x = nil
14
+ expect(restrainer.throttle{ x = restrainer.current }).to eq(1)
15
+ expect(x).to eq(1)
16
+ expect(restrainer.current).to eq(0)
17
+ end
18
+
19
+ it "should throw an error if too many processes are already running" do
20
+ restrainer = Restrainer.new(:restrainer_test, limit: 1)
21
+ x = nil
22
+ restrainer.throttle do
23
+ expect(lambda{restrainer.throttle{ x = 1 }}).to raise_error(Restrainer::ThrottledError)
24
+ end
25
+ expect(x).to eq(nil)
26
+ end
27
+
28
+ it "should not throw an error if the number of processes is under the limit" do
29
+ restrainer = Restrainer.new(:restrainer_test, limit: 2)
30
+ x = nil
31
+ restrainer.throttle do
32
+ restrainer.throttle{ x = 1 }
33
+ end
34
+ expect(x).to eq(1)
35
+ end
36
+
37
+ it "should let the throttle method override the limit" do
38
+ restrainer = Restrainer.new(:restrainer_test, limit: 1)
39
+ x = nil
40
+ restrainer.throttle do
41
+ restrainer.throttle(limit: 2){ x = 1 }
42
+ end
43
+ expect(x).to eq(1)
44
+ end
45
+
46
+ it "should allow processing to be turned off entirely by setting the limit to zero" do
47
+ restrainer = Restrainer.new(:restrainer_test, limit: 1)
48
+ x = nil
49
+ expect(lambda{restrainer.throttle(limit: 0){ x = 1 }}).to raise_error(Restrainer::ThrottledError)
50
+ expect(x).to eq(nil)
51
+ end
52
+
53
+ it "should allow the throttle to be opened up entirely with a negative limit" do
54
+ restrainer = Restrainer.new(:restrainer_test, limit: 0)
55
+ x = nil
56
+ restrainer.throttle(limit: -1){ x = 1 }
57
+ expect(x).to eq(1)
58
+ end
59
+
60
+ it "should cleanup the running process list if orphaned processes exist" do
61
+ restrainer = Restrainer.new(:restrainer_test, limit: 1, timeout: 10)
62
+ x = nil
63
+ restrainer.throttle do
64
+ Timecop.travel(11) do
65
+ restrainer.throttle{ x = 1 }
66
+ end
67
+ end
68
+ expect(x).to eq(1)
69
+ end
70
+ end
@@ -0,0 +1,15 @@
1
+ require File.expand_path('../../lib/restrainer', __FILE__)
2
+ require 'timecop'
3
+
4
+ RSpec.configure do |config|
5
+ config.run_all_when_everything_filtered = true
6
+ config.filter_run :focus
7
+
8
+ # Run specs in random order to surface order dependencies. If you find an
9
+ # order dependency and want to debug it, you can fix the order by providing
10
+ # the seed, which is printed after each run.
11
+ # --seed 1234
12
+ config.order = 'random'
13
+
14
+ Restrainer.redis = Redis.new
15
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: restrainer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - We Heart It
8
+ - Brian Durand
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2015-10-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: bundler
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '1.3'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '1.3'
42
+ - !ruby/object:Gem::Dependency
43
+ name: rake
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: rspec
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: timecop
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ description: Code for throttling workloads so as not to overwhelm external services.
85
+ email:
86
+ - dev@weheartit.com
87
+ - bbdurand@gmail.com
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - ".gitignore"
93
+ - MIT_LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - VERSION
97
+ - lib/restrainer.rb
98
+ - restrainer.gemspec
99
+ - spec/restrainer_spec.rb
100
+ - spec/spec_helper.rb
101
+ homepage: https://github.com/weheartit/restrainer
102
+ licenses:
103
+ - MIT
104
+ metadata: {}
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '2.0'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubyforge_project:
121
+ rubygems_version: 2.4.5
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: Code for throttling workloads so as not to overwhelm external services
125
+ test_files:
126
+ - spec/restrainer_spec.rb
127
+ - spec/spec_helper.rb