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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: c864dec39322b1a42a062119de6bac2a5a9134db
4
- data.tar.gz: 2cbc9b6c3b78a4b6bb4fd706bdc645543366a6a6
2
+ SHA256:
3
+ metadata.gz: ff5a0d8ca6659d4feeb9d6fd8e453e1b1733917c20dbfadb4686173998085903
4
+ data.tar.gz: 5f3bb001fd3fde135999d50e2383d6f4e826c54a35c4ab02a11d45c7cc29bce3
5
5
  SHA512:
6
- metadata.gz: 275f22b4379067198e130603461ccd92b0da8e85ef05dd9fe142fa02c6e202b358b9512cbd8146b7c5ded56145380e2ab9b168f729ed8c00223300321217ab18
7
- data.tar.gz: 9c30e5358e500d7172a4dd3924e5c64018fb4daed80d8a781786846ae9b7f0069e167af5156fc38b389c991e8a2c7ce08d1262155ed89c43dd6ae39d2fefcd13
6
+ metadata.gz: e9c097fbdb451fb6de6574427f3b672a09db445cb0df2f0784bd9133adbb97c614a73177c6017f0b6fe57618da24b69734538d127c84b54ec8495514d1c86660
7
+ data.tar.gz: 0404b8096e47387750cbef9b2ba89d5de6e795f19633c6d613ae202d211b510758db3d1d6d342a2f8c1da4a1d2e1d15727ec6034c38e2de4d6cac23341ffc4d2
@@ -0,0 +1,3 @@
1
+ # 1.0.1
2
+
3
+ * Use Lua script to avoid race conditions and ensure no extra processes slip through.
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
- desc 'Default: run unit tests.'
4
- task :default => :test
4
+ RSpec::Core::RakeTask.new(:spec)
5
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
6
+ task :default => :spec
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.0.1
@@ -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.multi do |conn|
112
- conn.zadd(key, Time.now.to_i, process_id)
113
- conn.expire(key, @timeout)
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
- # 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
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
@@ -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: 1)
22
+ restrainer = Restrainer.new(:restrainer_test, limit: 5)
21
23
  x = nil
22
24
  restrainer.throttle do
23
- expect(lambda{restrainer.throttle{ x = 1 }}).to raise_error(Restrainer::ThrottledError)
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
@@ -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.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: 2015-10-30 00:00:00.000000000 Z
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
- rubyforge_project:
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