restrainer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/MIT_LICENSE.txt +22 -0
- data/README.md +40 -0
- data/Rakefile +18 -0
- data/VERSION +1 -0
- data/lib/restrainer.rb +130 -0
- data/restrainer.gemspec +27 -0
- data/spec/restrainer_spec.rb +70 -0
- data/spec/spec_helper.rb +15 -0
- metadata +127 -0
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
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
|
data/restrainer.gemspec
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|