pause 0.2.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +2 -0
- data/.travis.yml +7 -3
- data/Gemfile +0 -8
- data/README.md +126 -21
- data/Rakefile +43 -2
- data/lib/pause.rb +3 -3
- data/lib/pause/action.rb +114 -93
- data/lib/pause/analyzer.rb +3 -3
- data/lib/pause/configuration.rb +1 -1
- data/lib/pause/redis/adapter.rb +37 -17
- data/lib/pause/redis/sharded_adapter.rb +9 -14
- data/lib/pause/version.rb +1 -1
- data/pause.gemspec +16 -7
- data/spec/pause/action_spec.rb +149 -85
- data/spec/pause/redis/adapter_spec.rb +15 -9
- data/spec/pause/redis/sharded_adapter_spec.rb +14 -0
- data/spec/spec_helper.rb +18 -9
- metadata +124 -11
- data/spec/support/fakeredis.rb +0 -2
data/lib/pause/analyzer.rb
CHANGED
@@ -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
|
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|
|
data/lib/pause/configuration.rb
CHANGED
data/lib/pause/redis/adapter.rb
CHANGED
@@ -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
|
24
|
+
@resolution = config.resolution
|
14
25
|
@time_blocks_to_keep = config.history / @resolution
|
15
|
-
@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
|
-
|
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
|
-
|
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
|
-
!
|
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
|
95
|
-
|
96
|
-
redis.del(increment_keys)
|
108
|
+
def redis
|
109
|
+
self.class.redis
|
97
110
|
end
|
98
111
|
|
99
|
-
def
|
100
|
-
|
101
|
-
|
102
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
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
|
data/lib/pause/version.rb
CHANGED
data/pause.gemspec
CHANGED
@@ -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 =
|
7
|
+
gem.name = 'pause'
|
8
8
|
gem.version = Pause::VERSION
|
9
|
-
gem.authors = [
|
10
|
-
gem.email = %w(atasay@wanelo.com paul@wanelo.com sax@
|
11
|
-
gem.
|
12
|
-
gem.
|
13
|
-
gem.homepage =
|
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 = [
|
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
|
data/spec/pause/action_spec.rb
CHANGED
@@ -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(:
|
26
|
-
let(:
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
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
|
-
|
52
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
60
|
-
|
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
|
-
|
65
|
-
|
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
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
89
|
-
|
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
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
-
|
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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
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
|
-
|
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
|
197
|
-
|
198
|
-
|
199
|
-
|
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
|
-
|
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
|
-
|
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
|
303
|
+
c.history = 10
|
240
304
|
end
|
241
305
|
end
|
242
306
|
|