pause 0.2.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -10,10 +10,10 @@ module Pause
10
10
  # @return [nil] everything is fine
11
11
  # @return [false] this action is already blocked
12
12
  # @return [Pause::RateLimitedEvent] the action was blocked as a result of this check
13
- def check(action)
14
- return false if adapter.rate_limited?(action.scope, action.identifier)
13
+ def check(action, recalculate: false)
14
+ return false if adapter.rate_limited?(action.scope, action.identifier) && !recalculate
15
15
  timestamp = period_marker(Pause.config.resolution, Time.now.to_i)
16
- set = adapter.key_history(action.scope, action.identifier)
16
+ set = adapter.key_history(action.scope, action.identifier)
17
17
  action.checks.each do |period_check|
18
18
  start_time = timestamp - period_check.period_seconds
19
19
  set.reverse.inject(0) do |sum, element|
@@ -3,7 +3,7 @@ module Pause
3
3
  attr_writer :redis_host, :redis_port, :redis_db, :resolution, :history, :sharded
4
4
 
5
5
  def configure
6
- yield self
6
+ yield self if block_given?
7
7
  self
8
8
  end
9
9
 
@@ -5,28 +5,42 @@ module Pause
5
5
 
6
6
  # This class encapsulates Redis operations used by Pause
7
7
  class Adapter
8
+ class << self
9
+ def redis
10
+ @redis_conn ||= ::Redis.new(redis_connection_opts)
11
+ end
12
+
13
+ def redis_connection_opts
14
+ { host: Pause.config.redis_host,
15
+ port: Pause.config.redis_port,
16
+ db: Pause.config.redis_db }
17
+ end
18
+ end
8
19
 
9
20
  include Pause::Helper::Timing
10
21
  attr_accessor :resolution, :time_blocks_to_keep, :history
11
22
 
12
23
  def initialize(config)
13
- @resolution = config.resolution
24
+ @resolution = config.resolution
14
25
  @time_blocks_to_keep = config.history / @resolution
15
- @history = config.history
26
+ @history = config.history
27
+ end
28
+
29
+ # Override in subclasses to disable
30
+ def with_multi
31
+ redis.multi do |redis|
32
+ yield(redis) if block_given?
33
+ end
16
34
  end
17
35
 
18
36
  def increment(scope, identifier, timestamp, count = 1)
19
37
  k = tracked_key(scope, identifier)
20
- redis.multi do |redis|
38
+ with_multi do |redis|
21
39
  redis.zincrby k, count, period_marker(resolution, timestamp)
22
40
  redis.expire k, history
23
41
  end
24
42
 
25
- if redis.zcard(k) > time_blocks_to_keep
26
- list = extract_set_elements(k)
27
- to_remove = list.slice(0, (list.size - time_blocks_to_keep))
28
- redis.zrem(k, to_remove.map(&:ts))
29
- end
43
+ truncate_set_for(k)
30
44
  end
31
45
 
32
46
  def key_history(scope, identifier)
@@ -78,7 +92,7 @@ module Pause
78
92
  end
79
93
 
80
94
  def disabled?(scope)
81
- ! enabled?(scope)
95
+ !enabled?(scope)
82
96
  end
83
97
 
84
98
  def enabled?(scope)
@@ -91,15 +105,21 @@ module Pause
91
105
 
92
106
  private
93
107
 
94
- def delete_tracking_keys(scope, ids)
95
- increment_keys = ids.map{ |key| tracked_key(scope, key) }
96
- redis.del(increment_keys)
108
+ def redis
109
+ self.class.redis
97
110
  end
98
111
 
99
- def redis
100
- @redis_conn ||= ::Redis.new(host: Pause.config.redis_host,
101
- port: Pause.config.redis_port,
102
- db: Pause.config.redis_db)
112
+ def truncate_set_for(k)
113
+ if redis.zcard(k) > time_blocks_to_keep
114
+ list = extract_set_elements(k)
115
+ to_remove = list.slice(0, (list.size - time_blocks_to_keep)).map(&:ts)
116
+ redis.zrem(k, to_remove) if k && to_remove && to_remove.size > 0
117
+ end
118
+ end
119
+
120
+ def delete_tracking_keys(scope, ids)
121
+ increment_keys = ids.map { |key| tracked_key(scope, key) }
122
+ redis.del(increment_keys)
103
123
  end
104
124
 
105
125
  def tracked_scope(scope)
@@ -117,7 +137,7 @@ module Pause
117
137
 
118
138
  def keys(key_scope)
119
139
  redis.keys("#{key_scope}:*").map do |key|
120
- key.gsub(/^#{key_scope}:/, "").tr('|','')
140
+ key.gsub(/^#{key_scope}:/, "").tr('|', '')
121
141
  end
122
142
  end
123
143
 
@@ -7,26 +7,21 @@ module Pause
7
7
  # Operations that are not possible when data is sharded
8
8
  # raise an error.
9
9
  class ShardedAdapter < Adapter
10
- def increment(scope, identifier, timestamp, count = 1)
11
- k = tracked_key(scope, identifier)
12
- redis.zincrby k, count, period_marker(resolution, timestamp)
13
- redis.expire k, history
14
10
 
15
- if redis.zcard(k) > time_blocks_to_keep
16
- list = extract_set_elements(k)
17
- to_remove = list.slice(0, (list.size - time_blocks_to_keep))
18
- redis.zrem(k, to_remove.map(&:ts))
19
- end
11
+ # Overrides real multi which is not possible when sharded.
12
+ def with_multi
13
+ yield(redis) if block_given?
20
14
  end
21
15
 
16
+ protected
22
17
 
23
- private
24
-
25
- def redis
26
- @redis_conn ||= ::Redis.new(host: Pause.config.redis_host,
27
- port: Pause.config.redis_port)
18
+ def redis_connection_opts
19
+ { host: Pause.config.redis_host,
20
+ port: Pause.config.redis_port }
28
21
  end
29
22
 
23
+ private
24
+
30
25
  def keys(_key_scope)
31
26
  raise OperationNotSupported.new('Can not be executed when Pause is configured in sharded mode')
32
27
  end
@@ -1,3 +1,3 @@
1
1
  module Pause
2
- VERSION = '0.2.1'
2
+ VERSION = '0.4.0'
3
3
  end
@@ -4,18 +4,27 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  require 'pause/version'
5
5
 
6
6
  Gem::Specification.new do |gem|
7
- gem.name = "pause"
7
+ gem.name = 'pause'
8
8
  gem.version = Pause::VERSION
9
- gem.authors = ["Atasay Gokkaya", "Paul Henry", "Eric Saxby", "Konstantin Gredeskoul"]
10
- gem.email = %w(atasay@wanelo.com paul@wanelo.com sax@wanelo.com kig@wanelo.com)
11
- gem.description = %q(Real time rate limiting for multi-process ruby environments based on Redis)
12
- gem.summary = %q(RReal time rate limiting for multi-process ruby environments based on Redis)
13
- gem.homepage = "https://github.com/wanelo/pause"
9
+ gem.authors = ['Atasay Gokkaya', 'Paul Henry', 'Eric Saxby', 'Konstantin Gredeskoul']
10
+ gem.email = %w(atasay@wanelo.com paul@wanelo.com sax@ericsaxby.com kigster@gmail.com)
11
+ gem.summary = %q(Fast, scalable, and flexible real time rate limiting library for distributed Ruby environments backed by Redis.)
12
+ gem.description = %q(This gem provides highly flexible and easy to use interface to define rate limit checks, register events as they come, and verify if the rate limit is reached. Multiple checks for the same metric are easily supported. This gem is used at very high scale on several popular web sites.)
13
+ gem.homepage = 'https://github.com/kigster/pause'
14
14
 
15
15
  gem.files = `git ls-files`.split($/)
16
16
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
17
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
- gem.require_paths = ["lib"]
18
+ gem.require_paths = ['lib']
19
19
 
20
20
  gem.add_dependency 'redis'
21
+ gem.add_dependency 'hiredis'
22
+
23
+ gem.add_development_dependency 'simplecov'
24
+ gem.add_development_dependency 'yard'
25
+ gem.add_development_dependency 'rspec'
26
+ gem.add_development_dependency 'fakeredis'
27
+ gem.add_development_dependency 'guard-rspec'
28
+ gem.add_development_dependency 'timecop'
29
+ gem.add_development_dependency 'rake'
21
30
  end
@@ -22,86 +22,169 @@ describe Pause::Action do
22
22
  allow(Pause).to receive(:adapter).and_return(adapter)
23
23
  end
24
24
 
25
- let(:action) { MyNotification.new('1237612') }
26
- let(:other_action) { MyNotification.new('1237613') }
27
-
28
- describe '#increment!' do
29
- it 'should increment' do
30
- time = Time.now
31
- Timecop.freeze time do
32
- expect(Pause.adapter).to receive(:increment).with(action.scope, '1237612', time.to_i, 1)
33
- action.increment!
25
+ let(:identifier) { '11112222' }
26
+ let(:action) { MyNotification.new(identifier) }
27
+
28
+ let(:other_identifier) { '8798734' }
29
+ let(:other_action) { MyNotification.new(other_identifier) }
30
+
31
+ RSpec.shared_examples 'an action' do
32
+ describe '#increment!' do
33
+ it 'should increment' do
34
+ time = Time.now
35
+ Timecop.freeze time do
36
+ expect(Pause.adapter).to receive(:increment).with(action.scope, identifier, time.to_i, 1)
37
+ action.increment!
38
+ end
34
39
  end
35
40
  end
36
- end
37
41
 
38
- describe '#ok?' do
39
- it 'should successfully return if the action is blocked or not' do
40
- time = Time.now
41
- Timecop.freeze time do
42
- 4.times do
42
+ describe '#ok?' do
43
+ it 'should successfully return if the action is blocked or not' do
44
+ time = Time.now
45
+ Timecop.freeze time do
46
+ 4.times do
47
+ action.increment!
48
+ expect(action.ok?).to be true
49
+ end
43
50
  action.increment!
51
+ expect(action.ok?).to be false
52
+ end
53
+ end
54
+
55
+ it 'should successfully consider different period checks' do
56
+ time = Time.parse('Sept 22, 11:34:00')
57
+
58
+ Timecop.freeze time - 30 do
59
+ action.increment! 4
60
+ expect(action.ok?).to be true
61
+ end
62
+
63
+ Timecop.freeze time do
64
+ action.increment! 2
44
65
  expect(action.ok?).to be true
45
66
  end
46
- action.increment!
67
+
68
+ Timecop.freeze time do
69
+ action.increment! 1
70
+ expect(action.ok?).to be false
71
+ end
72
+ end
73
+
74
+ it 'should return false and silently fail if redis is not available' do
75
+ allow(Pause::Logger).to receive(:fatal)
76
+ allow_any_instance_of(Redis).to receive(:zrange).and_raise Redis::CannotConnectError
77
+ time = period_marker(resolution, Time.now.to_i)
78
+
79
+ action.increment! 4, time - 25
80
+
47
81
  expect(action.ok?).to be false
48
82
  end
49
83
  end
50
84
 
51
- it 'should successfully consider different period checks' do
52
- time = Time.parse('Sept 22, 11:34:00')
85
+ describe '#analyze' do
86
+ context 'action should not be rate limited' do
87
+ it 'returns nil' do
88
+ expect(adapter.rate_limited?(action.scope, action.identifier)).to be false
89
+ expect(action.analyze).to be nil
90
+ end
91
+ end
53
92
 
54
- Timecop.freeze time - 30 do
55
- action.increment! 4
56
- expect(action.ok?).to be true
93
+ context 'action should be rate limited' do
94
+ it 'returns a RateLimitedEvent object' do
95
+ time = Time.now
96
+ rate_limit = nil
97
+
98
+ Timecop.freeze time do
99
+ 7.times { action.increment! }
100
+ rate_limit = action.analyze
101
+ end
102
+
103
+ expected_rate_limit = Pause::RateLimitedEvent.new(action, action.checks[0], 7, time.to_i)
104
+
105
+ expect(rate_limit).to be_a(Pause::RateLimitedEvent)
106
+ expect(rate_limit.identifier).to eq(expected_rate_limit.identifier)
107
+ expect(rate_limit.sum).to eq(expected_rate_limit.sum)
108
+ expect(rate_limit.period_check).to eq(expected_rate_limit.period_check)
109
+ expect(rate_limit.timestamp).to eq(expected_rate_limit.timestamp)
110
+ end
57
111
  end
112
+ end
58
113
 
59
- Timecop.freeze time do
60
- action.increment! 2
114
+ describe '#unblock' do
115
+ it 'unblocks the specified id' do
116
+ 10.times { action.increment! }
117
+ expect(action.ok?).to be false
118
+ action.unblock
61
119
  expect(action.ok?).to be true
62
120
  end
121
+ end
63
122
 
64
- Timecop.freeze time do
65
- action.increment! 1
123
+ describe '#block_for' do
124
+ it 'blocks the IP for N seconds' do
125
+ expect(adapter).to receive(:rate_limit!).with(action.scope, action.identifier, 10).and_call_original
126
+ action.block_for(10)
66
127
  expect(action.ok?).to be false
67
128
  end
68
129
  end
130
+ end
69
131
 
70
- it 'should return false and silently fail if redis is not available' do
71
- allow(Pause::Logger).to receive(:fatal)
72
- allow_any_instance_of(Redis).to receive(:zrange).and_raise Redis::CannotConnectError
73
- time = period_marker(resolution, Time.now.to_i)
74
-
75
- action.increment! 4, time - 25
76
-
77
- expect(action.ok?).to be false
132
+ context 'actions under test' do
133
+ ['123456', 'hello', 0, 999999].each do |id|
134
+ let(:identifier) { id }
135
+ let(:action) { MyNotification.new(identifier) }
136
+ describe "action with identifier #{id}" do
137
+ it_behaves_like 'an action'
138
+ end
78
139
  end
79
140
  end
80
141
 
81
- describe '#analyze' do
82
- context 'action should not be rate limited' do
83
- it 'returns nil' do
84
- expect(action.analyze).to be nil
142
+ context 'DSL usage' do
143
+ class CowRateLimited < Pause::Action
144
+ scope 'cow:moo'
145
+ check period_seconds: 10, max_allowed: 2, block_ttl: 40
146
+ check period_seconds: 20, max_allowed: 4, block_ttl: 40
147
+ end
148
+
149
+ let(:identifier) { 'cow-moo' }
150
+ let(:action) { CowRateLimited.new(identifier) }
151
+ let(:bogus) { Struct.new(:name, :event).new }
152
+
153
+ describe '#unless_rate_limited' do
154
+ before do
155
+ expect(bogus).to receive(:name).exactly(2).times
156
+ end
157
+ it 'should call through the block' do
158
+ action.unless_rate_limited { bogus.name }
159
+ action.unless_rate_limited { bogus.name }
160
+ result = action.unless_rate_limited { bogus.name }
161
+ expect(result).to be_a_kind_of(::Pause::RateLimitedEvent)
85
162
  end
86
163
  end
87
164
 
88
- context 'action should be rate limited' do
89
- it 'returns a RateLimitedEvent object' do
90
- time = Time.now
91
- rate_limit = nil
165
+ describe '#unless_rate_limited' do
166
+ before { expect(bogus).to receive(:name).exactly(2).times }
92
167
 
93
- Timecop.freeze time do
94
- 7.times { action.increment! }
95
- rate_limit = action.analyze
96
- end
168
+ it 'should call through the block' do
169
+ 3.times { action.unless_rate_limited { bogus.name } }
170
+ end
171
+
172
+ describe '#if_rate_limited' do
173
+ before { 2.times { action.unless_rate_limited { bogus.name } } }
97
174
 
98
- expected_rate_limit = Pause::RateLimitedEvent.new(action, action.checks[0], 7, time.to_i)
175
+ it 'it should not analyze during method call' do
176
+ bogus.event = 1
177
+ action.if_rate_limited { |event| bogus.event = event }
178
+ expect(bogus.event).to be_a_kind_of(::Pause::RateLimitedEvent)
179
+ expect(bogus.event.identifier).to eq(identifier)
180
+ end
99
181
 
100
- expect(rate_limit).to be_a(Pause::RateLimitedEvent)
101
- expect(rate_limit.identifier).to eq(expected_rate_limit.identifier)
102
- expect(rate_limit.sum).to eq(expected_rate_limit.sum)
103
- expect(rate_limit.period_check).to eq(expected_rate_limit.period_check)
104
- expect(rate_limit.timestamp).to eq(expected_rate_limit.timestamp)
182
+ it 'should analyze if requested' do
183
+ action.unless_rate_limited { bogus.name }
184
+ result = action.if_rate_limited { |event| bogus.event = event }
185
+ expect(bogus.event).to be_a_kind_of(::Pause::RateLimitedEvent)
186
+ expect(result).to eq(bogus.event)
187
+ end
105
188
  end
106
189
  end
107
190
  end
@@ -150,25 +233,6 @@ describe Pause::Action do
150
233
  end
151
234
  end
152
235
 
153
- describe '#unblock' do
154
- it 'unblocks the specified id' do
155
- 10.times { action.increment! }
156
-
157
- expect(action.ok?).to be false
158
-
159
- action.unblock
160
-
161
- expect(action.ok?).to be true
162
- end
163
- end
164
-
165
- describe '#block_for' do
166
- it 'blocks the IP for N seconds' do
167
- expect(adapter).to receive(:rate_limit!).with(action.scope, action.identifier, 10).and_call_original
168
- action.block_for(10)
169
- expect(action.ok?).to be false
170
- end
171
- end
172
236
  end
173
237
 
174
238
  describe Pause::Action, '.check' do
@@ -188,34 +252,34 @@ describe Pause::Action, '.check' do
188
252
 
189
253
  it 'should define a period check on new instances' do
190
254
  expect(ActionWithCheck.new('id').checks).to eq([
191
- Pause::PeriodCheck.new(100, 150, 200)
192
- ])
255
+ Pause::PeriodCheck.new(100, 150, 200)
256
+ ])
193
257
  end
194
258
 
195
259
  it 'should define a period check on new instances' do
196
- expect(ActionWithMultipleChecks.new('id').checks).to eq([
197
- Pause::PeriodCheck.new(100, 150, 200),
198
- Pause::PeriodCheck.new(200, 150, 200),
199
- Pause::PeriodCheck.new(300, 150, 200)
200
- ])
260
+ expect(ActionWithMultipleChecks.new('id').checks).to \
261
+ eq([
262
+ Pause::PeriodCheck.new(100, 150, 200),
263
+ Pause::PeriodCheck.new(200, 150, 200),
264
+ Pause::PeriodCheck.new(300, 150, 200)
265
+ ])
201
266
  end
202
267
 
203
268
  it 'should accept hash arguments' do
204
269
  expect(ActionWithHashChecks.new('id').checks).to eq([
205
- Pause::PeriodCheck.new(50, 100, 60)
206
- ])
270
+ Pause::PeriodCheck.new(50, 100, 60)
271
+ ])
207
272
  end
208
-
209
273
  end
210
274
 
211
275
  describe Pause::Action, '.scope' do
212
- class UndefinedScopeAction < Pause::Action
276
+ module MyApp
277
+ class NoScope < ::Pause::Action
278
+ end
213
279
  end
214
280
 
215
281
  it 'should raise if scope is not defined' do
216
- expect {
217
- UndefinedScopeAction.new('1.2.3.4').scope
218
- }.to raise_error('Should implement scope. (Ex: ipn:follow)')
282
+ expect(MyApp::NoScope.new('1.2.3.4').scope).to eq 'myapp.noscope'
219
283
  end
220
284
 
221
285
  class DefinedScopeAction < Pause::Action
@@ -236,7 +300,7 @@ describe Pause::Action, 'enabled/disabled states' do
236
300
  before do
237
301
  Pause.configure do |c|
238
302
  c.resolution = 10
239
- c.history = 10
303
+ c.history = 10
240
304
  end
241
305
  end
242
306