restrainer 1.0.0 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/CHANGE_LOG.md +3 -0
- data/README.md +3 -1
- data/Rakefile +3 -15
- data/VERSION +1 -1
- data/lib/restrainer.rb +79 -41
- data/spec/restrainer_spec.rb +12 -2
- data/spec/spec_helper.rb +3 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ff5a0d8ca6659d4feeb9d6fd8e453e1b1733917c20dbfadb4686173998085903
|
4
|
+
data.tar.gz: 5f3bb001fd3fde135999d50e2383d6f4e826c54a35c4ab02a11d45c7cc29bce3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e9c097fbdb451fb6de6574427f3b672a09db445cb0df2f0784bd9133adbb97c614a73177c6017f0b6fe57618da24b69734538d127c84b54ec8495514d1c86660
|
7
|
+
data.tar.gz: 0404b8096e47387750cbef9b2ba89d5de6e795f19633c6d613ae202d211b510758db3d1d6d342a2f8c1da4a1d2e1d15727ec6034c38e2de4d6cac23341ffc4d2
|
data/CHANGE_LOG.md
ADDED
data/README.md
CHANGED
@@ -13,9 +13,11 @@ restrainer.throttle do
|
|
13
13
|
end
|
14
14
|
```
|
15
15
|
|
16
|
+
If the throttle is already full, the block will not be run and a `Restrainer::ThrottledError` will be raised.
|
17
|
+
|
16
18
|
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
19
|
|
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.
|
20
|
+
Instances of Restrainer do not use any internal state to keep track of the 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
21
|
|
20
22
|
### Configuration
|
21
23
|
|
data/Rakefile
CHANGED
@@ -1,18 +1,6 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
2
3
|
|
3
|
-
|
4
|
-
task :default => :test
|
4
|
+
RSpec::Core::RakeTask.new(:spec)
|
5
5
|
|
6
|
-
|
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
|
6
|
+
task :default => :spec
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.0.
|
1
|
+
1.0.1
|
data/lib/restrainer.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'redis'
|
2
4
|
require 'securerandom'
|
3
5
|
|
@@ -12,18 +14,51 @@ require 'securerandom'
|
|
12
14
|
# If more than the specified number of processes as identified by the name argument is currently
|
13
15
|
# running, then the throttle block will raise an error.
|
14
16
|
class Restrainer
|
15
|
-
|
17
|
+
|
16
18
|
attr_reader :name, :limit
|
17
|
-
|
19
|
+
|
20
|
+
ADD_PROCESS_SCRIPT = <<-LUA
|
21
|
+
-- Parse arguments
|
22
|
+
local sorted_set = ARGV[1]
|
23
|
+
local process_id = ARGV[2]
|
24
|
+
local limit = tonumber(ARGV[3])
|
25
|
+
local ttl = tonumber(ARGV[4])
|
26
|
+
local now = tonumber(ARGV[5])
|
27
|
+
|
28
|
+
-- Get count of current processes. If more than the max, check if any of the processes have timed out
|
29
|
+
-- and try again.
|
30
|
+
local process_count = redis.call('zcard', sorted_set)
|
31
|
+
if process_count >= limit then
|
32
|
+
local max_score = now - ttl
|
33
|
+
local expired_keys = redis.call('zremrangebyscore', sorted_set, '-inf', max_score)
|
34
|
+
if expired_keys > 0 then
|
35
|
+
process_count = redis.call('zcard', sorted_set)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
-- Success so add to the list and set a global expiration so the list cleans up after itself.
|
40
|
+
if process_count < limit then
|
41
|
+
redis.call('zadd', sorted_set, now, process_id)
|
42
|
+
redis.call('expire', sorted_set, ttl)
|
43
|
+
end
|
44
|
+
|
45
|
+
-- Return the number of processes running before the process was added.
|
46
|
+
return process_count
|
47
|
+
LUA
|
48
|
+
|
49
|
+
# This class level variable will be used to load the SHA1 of the script at runtime.
|
50
|
+
@add_process_sha1 = nil
|
51
|
+
|
52
|
+
# This error will be thrown when the throttle block can't be executed.
|
18
53
|
class ThrottledError < StandardError
|
19
54
|
end
|
20
|
-
|
55
|
+
|
21
56
|
class << self
|
22
57
|
# Either configure the redis instance using a block or yield the instance. Configuring with
|
23
58
|
# a block allows you to use things like connection pools etc. without hard coding a single
|
24
59
|
# instance.
|
25
60
|
#
|
26
|
-
# Example: `Restrainer.redis{ redis_pool.instance }
|
61
|
+
# Example: `Restrainer.redis { redis_pool.instance }`
|
27
62
|
def redis(&block)
|
28
63
|
if block
|
29
64
|
@redis = block
|
@@ -33,21 +68,23 @@ class Restrainer
|
|
33
68
|
raise "#{self.class.name}.redis not configured"
|
34
69
|
end
|
35
70
|
end
|
36
|
-
|
71
|
+
|
37
72
|
# Set the redis instance to a specific instance. It is usually preferable to use the block
|
38
|
-
# form for configurating the instance.
|
73
|
+
# form for configurating the instance so that it can be evaluated at runtime.
|
74
|
+
#
|
75
|
+
# Example: `Restrainer.redis = Redis.new`
|
39
76
|
def redis=(conn)
|
40
77
|
@redis = lambda{ conn }
|
41
78
|
end
|
42
79
|
end
|
43
|
-
|
80
|
+
|
44
81
|
# Create a new restrainer. The name is used to identify the Restrainer and group processes together.
|
45
82
|
# You can create any number of Restrainers with different names.
|
46
83
|
#
|
47
84
|
# The required limit parameter specifies the maximum number of processes that will be allowed to execute the
|
48
85
|
# throttle block at any point in time.
|
49
86
|
#
|
50
|
-
# The timeout parameter is used for cleaning up internal data structures so that jobs aren't orphaned
|
87
|
+
# The timeout parameter is used for cleaning up internal data structures so that jobs aren't orphaned
|
51
88
|
# if their process is killed. Processes will automatically be removed from the running jobs list after the
|
52
89
|
# specified number of seconds. Note that the Restrainer will not handle timing out any code itself. This
|
53
90
|
# value is just used to insure the integrity of internal data structures.
|
@@ -57,7 +94,7 @@ class Restrainer
|
|
57
94
|
@timeout = timeout
|
58
95
|
@key = "#{self.class.name}.#{name.to_s}"
|
59
96
|
end
|
60
|
-
|
97
|
+
|
61
98
|
# Wrap a block with this method to throttle concurrent execution. If more than the alotted number
|
62
99
|
# of processes (as identified by the name) are currently executing, then a Restrainer::ThrottledError
|
63
100
|
# will be raised.
|
@@ -65,66 +102,67 @@ class Restrainer
|
|
65
102
|
# The limit argument can be used to override the value set in the constructor.
|
66
103
|
def throttle(limit: nil)
|
67
104
|
limit ||= self.limit
|
68
|
-
|
105
|
+
|
69
106
|
# limit of less zero is no limit; limit of zero is allow none
|
70
107
|
return yield if limit < 0
|
71
108
|
raise ThrottledError.new("#{self.class}: #{@name} is not allowing any processing") if limit == 0
|
72
|
-
|
109
|
+
|
73
110
|
# Grab a reference to the redis instance to that it will be consistent throughout the method
|
74
111
|
redis = self.class.redis
|
75
|
-
check_running_count!(redis, limit)
|
76
112
|
process_id = SecureRandom.uuid
|
113
|
+
add_process!(redis, process_id, limit)
|
114
|
+
|
77
115
|
begin
|
78
|
-
add_process!(redis, process_id)
|
79
116
|
yield
|
80
117
|
ensure
|
81
118
|
remove_process!(redis, process_id)
|
82
119
|
end
|
83
120
|
end
|
84
|
-
|
121
|
+
|
85
122
|
# Get the number of processes currently being executed for this restrainer.
|
86
123
|
def current(redis = nil)
|
87
124
|
redis ||= self.class.redis
|
88
125
|
redis.zcard(key).to_i
|
89
126
|
end
|
90
|
-
|
127
|
+
|
91
128
|
private
|
92
|
-
|
129
|
+
|
93
130
|
# Hash key in redis to story a sorted set of current processes.
|
94
131
|
def key
|
95
132
|
@key
|
96
133
|
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
|
-
|
134
|
+
|
109
135
|
# Add a process to the currently run set.
|
110
|
-
def add_process!(redis, process_id)
|
111
|
-
redis
|
112
|
-
|
113
|
-
|
136
|
+
def add_process!(redis, process_id, throttle_limit)
|
137
|
+
process_count = eval_script(redis, process_id, throttle_limit)
|
138
|
+
if process_count >= throttle_limit
|
139
|
+
raise ThrottledError.new("#{self.class}: #{@name} already has #{process_count} processes running")
|
114
140
|
end
|
115
141
|
end
|
116
|
-
|
142
|
+
|
117
143
|
# Remove a process to the currently run set.
|
118
144
|
def remove_process!(redis, process_id)
|
119
145
|
redis.zrem(key, process_id)
|
120
146
|
end
|
121
|
-
|
122
|
-
#
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
147
|
+
|
148
|
+
# Evaluate and execute a Lua script on the redis server.
|
149
|
+
def eval_script(redis, process_id, throttle_limit)
|
150
|
+
sha1 = @add_process_sha1
|
151
|
+
if sha1 == nil
|
152
|
+
sha1 = redis.script(:load, ADD_PROCESS_SCRIPT)
|
153
|
+
@add_process_sha1 = sha1
|
154
|
+
end
|
155
|
+
|
156
|
+
begin
|
157
|
+
redis.evalsha(sha1, [], [key, process_id, throttle_limit, @timeout, Time.now.to_i])
|
158
|
+
rescue Redis::CommandError => e
|
159
|
+
if e.message.include?('NOSCRIPT')
|
160
|
+
sha1 = redis.script(:load, ADD_PROCESS_SCRIPT)
|
161
|
+
@add_process_sha1 = sha1
|
162
|
+
retry
|
163
|
+
else
|
164
|
+
raise e
|
165
|
+
end
|
166
|
+
end
|
128
167
|
end
|
129
|
-
|
130
168
|
end
|
data/spec/restrainer_spec.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'spec_helper'
|
2
4
|
|
3
5
|
describe Restrainer do
|
@@ -17,10 +19,18 @@ describe Restrainer do
|
|
17
19
|
end
|
18
20
|
|
19
21
|
it "should throw an error if too many processes are already running" do
|
20
|
-
restrainer = Restrainer.new(:restrainer_test, limit:
|
22
|
+
restrainer = Restrainer.new(:restrainer_test, limit: 5)
|
21
23
|
x = nil
|
22
24
|
restrainer.throttle do
|
23
|
-
|
25
|
+
restrainer.throttle do
|
26
|
+
restrainer.throttle do
|
27
|
+
restrainer.throttle do
|
28
|
+
restrainer.throttle do
|
29
|
+
expect(lambda{restrainer.throttle{ x = 1 }}).to raise_error(Restrainer::ThrottledError)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
24
34
|
end
|
25
35
|
expect(x).to eq(nil)
|
26
36
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require File.expand_path('../../lib/restrainer', __FILE__)
|
2
4
|
require 'timecop'
|
3
5
|
|
@@ -10,6 +12,6 @@ RSpec.configure do |config|
|
|
10
12
|
# the seed, which is printed after each run.
|
11
13
|
# --seed 1234
|
12
14
|
config.order = 'random'
|
13
|
-
|
15
|
+
|
14
16
|
Restrainer.redis = Redis.new
|
15
17
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: restrainer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- We Heart It
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2019-07-03 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: redis
|
@@ -90,6 +90,7 @@ extensions: []
|
|
90
90
|
extra_rdoc_files: []
|
91
91
|
files:
|
92
92
|
- ".gitignore"
|
93
|
+
- CHANGE_LOG.md
|
93
94
|
- MIT_LICENSE.txt
|
94
95
|
- README.md
|
95
96
|
- Rakefile
|
@@ -117,8 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
118
|
- !ruby/object:Gem::Version
|
118
119
|
version: '0'
|
119
120
|
requirements: []
|
120
|
-
|
121
|
-
rubygems_version: 2.4.5
|
121
|
+
rubygems_version: 3.0.3
|
122
122
|
signing_key:
|
123
123
|
specification_version: 4
|
124
124
|
summary: Code for throttling workloads so as not to overwhelm external services
|