pause 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +18 -0
- data/.pairs +13 -0
- data/.rspec +2 -0
- data/.rvmrc +1 -0
- data/Gemfile +4 -0
- data/Guardfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +117 -0
- data/Rakefile +1 -0
- data/lib/pause.rb +34 -0
- data/lib/pause/action.rb +84 -0
- data/lib/pause/analyzer.rb +52 -0
- data/lib/pause/blocked_action.rb +14 -0
- data/lib/pause/configuration.rb +30 -0
- data/lib/pause/helper/timing.rb +9 -0
- data/lib/pause/redis/adapter.rb +86 -0
- data/lib/pause/version.rb +3 -0
- data/pause.gemspec +27 -0
- data/spec/pause/action_spec.rb +185 -0
- data/spec/pause/analyzer_spec.rb +64 -0
- data/spec/pause/configuration_spec.rb +36 -0
- data/spec/pause/redis/adapter_spec.rb +78 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/fakeredis.rb +1 -0
- metadata +174 -0
data/.gitignore
ADDED
data/.pairs
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
pairs:
|
2
|
+
ag: Atasay Gokkaya; atasay
|
3
|
+
km: Kaan Meralan; kaan
|
4
|
+
kg: Konstantin Gredeskoul; kig
|
5
|
+
ph: Paul Henry; paul
|
6
|
+
sf: Sean Flannagan; sean
|
7
|
+
es: Eric Saxby; sax
|
8
|
+
tn: Truong Nguyen; constantx
|
9
|
+
cc: Cihan Cimen; cihan
|
10
|
+
sc: Server Cimen; server
|
11
|
+
email:
|
12
|
+
prefix: pair
|
13
|
+
domain: wanelo.com
|
data/.rspec
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm use 1.9.3@pause --create
|
data/Gemfile
ADDED
data/Guardfile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#^syntax detection
|
3
|
+
|
4
|
+
# A sample Guardfile
|
5
|
+
# More info at https://github.com/guard/guard#readme
|
6
|
+
|
7
|
+
guard 'rspec' do
|
8
|
+
watch(%r{^spanx\.gemspec}) { "spec"}
|
9
|
+
watch(%r{^spec/.+_spec\.rb$})
|
10
|
+
watch(%r{^lib/(.+)\.rb$}) { "spec" }
|
11
|
+
watch('spec/spec_helper.rb') { "spec" }
|
12
|
+
end
|
13
|
+
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 TODO: Write your name
|
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,117 @@
|
|
1
|
+
# Pause
|
2
|
+
|
3
|
+
Pause is a redis-backed rate-limiting client. Use it to track events, with
|
4
|
+
rules around how often they are allowed to occur within configured time checks.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
gem 'pause'
|
11
|
+
|
12
|
+
And then execute:
|
13
|
+
|
14
|
+
$ bundle
|
15
|
+
|
16
|
+
Or install it yourself as:
|
17
|
+
|
18
|
+
$ gem install pause
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Configure Pause. This could be in a Rails initializer.
|
23
|
+
|
24
|
+
* resolution - The time resolution (in seconds) defining the minimum period into which action counts are
|
25
|
+
aggregated. This defines the size of the persistent store. The higher the number, the less data needs
|
26
|
+
to be persisted in Redis.
|
27
|
+
* history - The maximum amount of time (in seconds) that data is persisted
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
Pause.configure do |config|
|
31
|
+
config.redis_host = "127.0.0.1"
|
32
|
+
config.redis_port = 6379
|
33
|
+
config.redis_db = 1
|
34
|
+
|
35
|
+
config.resolution = 600
|
36
|
+
config.history = 86400
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
Define local actions for your application. These should define a scope, by
|
41
|
+
which they are identified in the persistent store, and checks.
|
42
|
+
|
43
|
+
Checks are configured with the following arguments:
|
44
|
+
|
45
|
+
* `period_seconds` - this is a period of time against which an action is tested
|
46
|
+
* `max_allowed` - the maximum number of times an action can be incremented during the time block determined by
|
47
|
+
period seconds
|
48
|
+
* `block_ttl` - how long to mark an action as blocked if it goes over max-allowed
|
49
|
+
|
50
|
+
Note that you should not configure a check with `period_seconds` less than the minimum resolution set in the
|
51
|
+
Pause config. If you do so, you will actually be checking sums against the full time period.
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
require 'pause'
|
55
|
+
|
56
|
+
class FollowAction < Pause::Action
|
57
|
+
scope "ipn:follow"
|
58
|
+
check 600, 100, 300
|
59
|
+
check 3600, 200, 1200
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
When an event occurs, you increment an instance of your action, optionally with a timestamp and count. This saves
|
64
|
+
data into a redis store, so it can be checked later by other processes. Timestamps should be in unix epoch format.
|
65
|
+
|
66
|
+
```ruby
|
67
|
+
class FollowsController < ApplicationController
|
68
|
+
def create
|
69
|
+
action = FollowAction.new(user.id)
|
70
|
+
if action.ok?
|
71
|
+
# do stuff
|
72
|
+
action.increment!
|
73
|
+
else
|
74
|
+
# show errors
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
class OtherController < ApplicationController
|
80
|
+
def index
|
81
|
+
action = OtherAction.new(params[:thing])
|
82
|
+
if action.ok?
|
83
|
+
action.increment!(Time.now.to_i, params[:count].to_i)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
If more data is needed about why the action is blocked, the `analyze` can be called
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
action = MyAction.new("thing")
|
93
|
+
|
94
|
+
while true
|
95
|
+
action.increment!
|
96
|
+
|
97
|
+
blocked_action = action.analyze
|
98
|
+
|
99
|
+
if blocked_action
|
100
|
+
puts blocked_action.identifier
|
101
|
+
puts blocked_action.sum
|
102
|
+
puts blocked_action.timestamp
|
103
|
+
|
104
|
+
puts blocked_aciton.period_check.inspect
|
105
|
+
end
|
106
|
+
|
107
|
+
sleep 1
|
108
|
+
end
|
109
|
+
```
|
110
|
+
|
111
|
+
## Contributing
|
112
|
+
|
113
|
+
1. Fork it
|
114
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
115
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
116
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
117
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/lib/pause.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
require "pause/version"
|
2
|
+
require "pause/configuration"
|
3
|
+
require "pause/action"
|
4
|
+
require "pause/analyzer"
|
5
|
+
require "pause/redis/adapter"
|
6
|
+
require 'pause/blocked_action'
|
7
|
+
|
8
|
+
module Pause
|
9
|
+
class PeriodCheck < Struct.new(:period_seconds, :max_allowed, :block_ttl)
|
10
|
+
def <=>(other)
|
11
|
+
self.period_seconds <=> other.period_seconds
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class SetElement < Struct.new(:ts, :count)
|
16
|
+
def <=>(other)
|
17
|
+
self.ts <=> other.ts
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
def analyzer
|
23
|
+
@analyzer ||= Pause::Analyzer.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def configure(&block)
|
27
|
+
@configuration = Pause::Configuration.new.configure(&block)
|
28
|
+
end
|
29
|
+
|
30
|
+
def config
|
31
|
+
@configuration
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/pause/action.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
module Pause
|
2
|
+
class Action
|
3
|
+
attr_accessor :identifier
|
4
|
+
|
5
|
+
def initialize(identifier)
|
6
|
+
@identifier = identifier
|
7
|
+
self.class.checks = [] unless self.class.instance_variable_get(:@checks)
|
8
|
+
end
|
9
|
+
|
10
|
+
# Action subclasses should define their scope as follows
|
11
|
+
#
|
12
|
+
# class MyAction < Pause::Action
|
13
|
+
# scope "my:scope"
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
def scope
|
17
|
+
raise "Should implement scope. (Ex: ipn:follow)"
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.scope(scope_identifier = nil)
|
21
|
+
class_variable_set(:@@class_scope, scope_identifier)
|
22
|
+
define_method(:scope) { scope_identifier }
|
23
|
+
end
|
24
|
+
|
25
|
+
# Action subclasses should define their checks as follows
|
26
|
+
#
|
27
|
+
# period_seconds - compare all activity by an identifier within the time period
|
28
|
+
# max_allowed - if the number of actions by an identifier exceeds max_allowed for the time period marked
|
29
|
+
# by period_seconds, it is no longer ok.
|
30
|
+
# ttl - time to mark identifier as not ok
|
31
|
+
#
|
32
|
+
# class MyAction < Pause::Action
|
33
|
+
# check 10, 20, 30 # period_seconds, max_allowed, ttl
|
34
|
+
# check 20, 30, 40 # period_seconds, max_allowed, ttl
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
def self.check(period_seconds, max_allowed, block_ttl)
|
38
|
+
@checks ||= []
|
39
|
+
@checks << Pause::PeriodCheck.new(period_seconds, max_allowed, block_ttl)
|
40
|
+
end
|
41
|
+
|
42
|
+
def checks
|
43
|
+
self.class.instance_variable_get(:@checks)
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.checks=(period_checks)
|
47
|
+
@checks = period_checks
|
48
|
+
end
|
49
|
+
|
50
|
+
def increment!(timestamp = Time.now.to_i, count = 1)
|
51
|
+
Pause.analyzer.increment(self, timestamp, count)
|
52
|
+
end
|
53
|
+
|
54
|
+
def ok?
|
55
|
+
Pause.analyzer.check(self).nil?
|
56
|
+
end
|
57
|
+
|
58
|
+
def analyze
|
59
|
+
Pause.analyzer.check(self)
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.tracked_identifiers
|
63
|
+
Pause.analyzer.tracked_identifiers(self.class_scope)
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.blocked_identifiers
|
67
|
+
Pause.analyzer.blocked_identifiers(self.class_scope)
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.unblock_all
|
71
|
+
Pause.analyzer.adapter.delete_keys(self.class_scope)
|
72
|
+
end
|
73
|
+
|
74
|
+
def key
|
75
|
+
"#{self.scope}:#{@identifier}"
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def self.class_scope
|
81
|
+
class_variable_get:@@class_scope if class_variable_defined?(:@@class_scope)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'pause/helper/timing'
|
2
|
+
|
3
|
+
module Pause
|
4
|
+
class Analyzer
|
5
|
+
include Pause::Helper::Timing
|
6
|
+
|
7
|
+
attr_accessor :adapter
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@adapter ||= Pause::Redis::Adapter.new(Pause.config)
|
11
|
+
end
|
12
|
+
|
13
|
+
def increment(action, timestamp = Time.now.to_i, count = 1)
|
14
|
+
adapter.increment(action.key, timestamp, count)
|
15
|
+
end
|
16
|
+
|
17
|
+
def check(action)
|
18
|
+
analyze(action)
|
19
|
+
end
|
20
|
+
|
21
|
+
def tracked_identifiers(scope)
|
22
|
+
adapter.all_keys(scope)
|
23
|
+
end
|
24
|
+
|
25
|
+
def blocked_identifiers(scope)
|
26
|
+
adapter.blocked_keys(scope)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def analyze(action)
|
32
|
+
timestamp = period_marker(Pause.config.resolution, Time.now.to_i)
|
33
|
+
set = adapter.key_history(action.key)
|
34
|
+
action.checks.each do |period_check|
|
35
|
+
start_time = timestamp - period_check.period_seconds
|
36
|
+
set.reverse.inject(0) do |sum, element|
|
37
|
+
break if element.ts < start_time
|
38
|
+
sum += element.count
|
39
|
+
if sum >= period_check.max_allowed
|
40
|
+
adapter.block(action.key, period_check.block_ttl)
|
41
|
+
# Note that Time.now is different from period_marker(resolution, Time.now), which
|
42
|
+
# rounds down to the nearest (resolution) seconds
|
43
|
+
return Pause::BlockedAction.new(action, period_check, sum, Time.now.to_i)
|
44
|
+
end
|
45
|
+
sum
|
46
|
+
end
|
47
|
+
end
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Pause
|
2
|
+
class BlockedAction
|
3
|
+
attr_accessor :action, :identifier, :period_check, :sum, :timestamp
|
4
|
+
|
5
|
+
def initialize(action, period_check, sum, timestamp)
|
6
|
+
@action = action
|
7
|
+
@identifier = action.identifier
|
8
|
+
@period_check = period_check
|
9
|
+
@sum = sum
|
10
|
+
@timestamp = timestamp
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Pause
|
2
|
+
class Configuration
|
3
|
+
attr_writer :redis_host, :redis_port, :redis_db, :resolution, :history
|
4
|
+
|
5
|
+
def configure
|
6
|
+
yield self
|
7
|
+
self
|
8
|
+
end
|
9
|
+
|
10
|
+
def redis_host
|
11
|
+
@redis_host || "127.0.0.1"
|
12
|
+
end
|
13
|
+
|
14
|
+
def redis_port
|
15
|
+
(@redis_port || 6379).to_i
|
16
|
+
end
|
17
|
+
|
18
|
+
def redis_db
|
19
|
+
@redis_db || '1'
|
20
|
+
end
|
21
|
+
|
22
|
+
def resolution
|
23
|
+
(@resolution || 600).to_i
|
24
|
+
end
|
25
|
+
|
26
|
+
def history
|
27
|
+
(@history || 86400).to_i
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
require 'pause/helper/timing'
|
2
|
+
|
3
|
+
module Pause
|
4
|
+
module Redis
|
5
|
+
class Adapter
|
6
|
+
|
7
|
+
include Pause::Helper::Timing
|
8
|
+
attr_accessor :resolution, :time_blocks_to_keep, :history
|
9
|
+
|
10
|
+
def initialize(config)
|
11
|
+
@resolution = config.resolution
|
12
|
+
@time_blocks_to_keep = config.history / @resolution
|
13
|
+
@history = config.history
|
14
|
+
end
|
15
|
+
|
16
|
+
def increment(key, timestamp, count = 1)
|
17
|
+
k = white_key(key)
|
18
|
+
redis.multi do |redis|
|
19
|
+
redis.zincrby k, count, period_marker(resolution, timestamp)
|
20
|
+
redis.expire k, history
|
21
|
+
end
|
22
|
+
|
23
|
+
if redis.zcard(k) > time_blocks_to_keep
|
24
|
+
list = extract_set_elements(k)
|
25
|
+
to_remove = list.slice(0, (list.size - time_blocks_to_keep))
|
26
|
+
redis.zrem(k, to_remove.map(&:ts))
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def key_history(key)
|
31
|
+
extract_set_elements(white_key(key))
|
32
|
+
end
|
33
|
+
|
34
|
+
def block(key, block_ttl)
|
35
|
+
redis.setex(blocked_key(key), block_ttl, nil)
|
36
|
+
end
|
37
|
+
|
38
|
+
def blocked?(key)
|
39
|
+
!!redis.get(blocked_key(key))
|
40
|
+
end
|
41
|
+
|
42
|
+
def all_keys(scope)
|
43
|
+
keys(white_key(scope))
|
44
|
+
end
|
45
|
+
|
46
|
+
def blocked_keys(scope)
|
47
|
+
keys(blocked_key(scope))
|
48
|
+
end
|
49
|
+
|
50
|
+
def delete_keys(scope)
|
51
|
+
ids = blocked_keys(scope)
|
52
|
+
increment_keys = ids.map{ |key| white_key(scope, key) }
|
53
|
+
blocked_keys = ids.map{ |key| blocked_key(scope, key) }
|
54
|
+
redis.del (increment_keys + blocked_keys)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def redis
|
60
|
+
@redis_conn ||= ::Redis.new(host: Pause.config.redis_host,
|
61
|
+
port: Pause.config.redis_port,
|
62
|
+
db: Pause.config.redis_db)
|
63
|
+
end
|
64
|
+
|
65
|
+
def white_key(scope, key = nil)
|
66
|
+
["i", scope, key].compact.join(':')
|
67
|
+
end
|
68
|
+
|
69
|
+
def blocked_key(scope, key = nil)
|
70
|
+
["b", scope, key].compact.join(':')
|
71
|
+
end
|
72
|
+
|
73
|
+
def keys(key_scope)
|
74
|
+
redis.keys("#{key_scope}:*").map do |key|
|
75
|
+
key.gsub(/^#{key_scope}:/, "")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def extract_set_elements(key)
|
80
|
+
(redis.zrange key, 0, -1, :with_scores => true).map do |slice|
|
81
|
+
Pause::SetElement.new(slice[0].to_i, slice[1].to_i)
|
82
|
+
end.sort
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
data/pause.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'pause/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "pause"
|
8
|
+
gem.version = Pause::VERSION
|
9
|
+
gem.authors = ["Atasay Gokkaya", "Paul Henry"]
|
10
|
+
gem.email = %w(atasay@wanelo.com paul@wanelo.com)
|
11
|
+
gem.description = %q(Real time redis rate limiting)
|
12
|
+
gem.summary = %q(Real time redis rate limiting)
|
13
|
+
gem.homepage = "https://github.com/wanelo/pause"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_dependency 'redis'
|
21
|
+
|
22
|
+
gem.add_development_dependency 'rspec'
|
23
|
+
gem.add_development_dependency 'fakeredis'
|
24
|
+
gem.add_development_dependency 'timecop'
|
25
|
+
gem.add_development_dependency 'guard-rspec'
|
26
|
+
gem.add_development_dependency 'rb-fsevent'
|
27
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'timecop'
|
3
|
+
|
4
|
+
describe Pause::Action do
|
5
|
+
include Pause::Helper::Timing
|
6
|
+
|
7
|
+
class MyNotification < Pause::Action
|
8
|
+
scope "ipn:follow"
|
9
|
+
check 20, 5, 40
|
10
|
+
check 40, 7, 40
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:resolution) { 10 }
|
14
|
+
let(:history) { 60 }
|
15
|
+
let(:configuration) { Pause::Configuration.new }
|
16
|
+
|
17
|
+
before do
|
18
|
+
Pause.stub(:config).and_return(configuration)
|
19
|
+
Pause.config.stub(:resolution).and_return(resolution)
|
20
|
+
Pause.config.stub(:history).and_return(history)
|
21
|
+
end
|
22
|
+
|
23
|
+
let(:action) { MyNotification.new("1237612") }
|
24
|
+
let(:other_action) { MyNotification.new("1237613") }
|
25
|
+
|
26
|
+
describe "#increment!" do
|
27
|
+
it "should increment" do
|
28
|
+
time = Time.now
|
29
|
+
Timecop.freeze time do
|
30
|
+
Pause.analyzer.should_receive(:increment).with(action, time.to_i, 1)
|
31
|
+
action.increment!
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe "#ok?" do
|
37
|
+
it "should successfully return if the action is blocked or not" do
|
38
|
+
time = Time.now
|
39
|
+
Timecop.freeze time do
|
40
|
+
4.times do
|
41
|
+
action.increment!
|
42
|
+
action.ok?.should be_true
|
43
|
+
end
|
44
|
+
action.increment!
|
45
|
+
action.ok?.should be_false
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should successfully consider different period checks" do
|
50
|
+
time = Time.now
|
51
|
+
Timecop.freeze time do
|
52
|
+
4.times do
|
53
|
+
action.increment!
|
54
|
+
action.ok?.should be_true
|
55
|
+
end
|
56
|
+
end
|
57
|
+
Timecop.freeze Time.at(time.to_i + 30) do
|
58
|
+
2.times do
|
59
|
+
action.increment!
|
60
|
+
action.ok?.should be_true
|
61
|
+
end
|
62
|
+
action.increment!
|
63
|
+
action.ok?.should be_false
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "#analyze" do
|
69
|
+
context "action should not be blocked" do
|
70
|
+
it "returns nil" do
|
71
|
+
action.analyze.should be_nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
context "action should be blocked" do
|
76
|
+
it "returns a BlockedAction object" do
|
77
|
+
time = Time.now
|
78
|
+
blocked_action = nil
|
79
|
+
|
80
|
+
Timecop.freeze time do
|
81
|
+
7.times { action.increment! }
|
82
|
+
blocked_action = action.analyze
|
83
|
+
end
|
84
|
+
|
85
|
+
expected_blocked_action = Pause::BlockedAction.new(action, action.checks[0], 7, time.to_i)
|
86
|
+
|
87
|
+
blocked_action.should be_a(Pause::BlockedAction)
|
88
|
+
blocked_action.identifier.should == expected_blocked_action.identifier
|
89
|
+
blocked_action.sum.should == expected_blocked_action.sum
|
90
|
+
blocked_action.period_check.should == expected_blocked_action.period_check
|
91
|
+
blocked_action.timestamp.should == expected_blocked_action.timestamp
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
describe "#tracked_identifiers" do
|
97
|
+
it "should return all the identifiers tracked (but not blocked) so far" do
|
98
|
+
action.increment!
|
99
|
+
other_action.increment!
|
100
|
+
|
101
|
+
action.ok?
|
102
|
+
other_action.ok?
|
103
|
+
|
104
|
+
MyNotification.tracked_identifiers.should include(action.identifier)
|
105
|
+
MyNotification.tracked_identifiers.should include(other_action.identifier)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "#blocked_identifiers" do
|
110
|
+
it "should return all the identifiers blocked" do
|
111
|
+
action.increment!(Time.now.to_i, 100)
|
112
|
+
other_action.increment!(Time.now.to_i, 100)
|
113
|
+
|
114
|
+
action.ok?
|
115
|
+
other_action.ok?
|
116
|
+
|
117
|
+
MyNotification.blocked_identifiers.should include(action.identifier)
|
118
|
+
MyNotification.blocked_identifiers.should include(other_action.identifier)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
describe "#unblock_all" do
|
123
|
+
it "should unblock all the identifiers for a scope" do
|
124
|
+
10.times { action.increment! }
|
125
|
+
other_action.increment!
|
126
|
+
|
127
|
+
action.ok?
|
128
|
+
other_action.ok?
|
129
|
+
|
130
|
+
MyNotification.tracked_identifiers.should include(action.identifier, other_action.identifier)
|
131
|
+
MyNotification.blocked_identifiers.should == [action.identifier]
|
132
|
+
|
133
|
+
MyNotification.unblock_all
|
134
|
+
|
135
|
+
MyNotification.blocked_identifiers.should be_empty
|
136
|
+
MyNotification.tracked_identifiers.should == [other_action.identifier]
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe Pause::Action, ".check" do
|
142
|
+
class ActionWithCheck < Pause::Action
|
143
|
+
check 100, 150, 200
|
144
|
+
end
|
145
|
+
|
146
|
+
class ActionWithMultipleChecks < Pause::Action
|
147
|
+
check 100, 150, 200
|
148
|
+
check 200, 150, 200
|
149
|
+
check 300, 150, 200
|
150
|
+
end
|
151
|
+
|
152
|
+
it "should define a period check on new instances" do
|
153
|
+
ActionWithCheck.new("id").checks.should == [
|
154
|
+
Pause::PeriodCheck.new(100, 150, 200),
|
155
|
+
]
|
156
|
+
end
|
157
|
+
|
158
|
+
it "should define a period check on new instances" do
|
159
|
+
ActionWithMultipleChecks.new("id").checks.should == [
|
160
|
+
Pause::PeriodCheck.new(100, 150, 200),
|
161
|
+
Pause::PeriodCheck.new(200, 150, 200),
|
162
|
+
Pause::PeriodCheck.new(300, 150, 200)
|
163
|
+
]
|
164
|
+
end
|
165
|
+
|
166
|
+
end
|
167
|
+
|
168
|
+
describe Pause::Action, ".scope" do
|
169
|
+
class UndefinedScopeAction < Pause::Action
|
170
|
+
end
|
171
|
+
|
172
|
+
it "should raise if scope is not defined" do
|
173
|
+
lambda {
|
174
|
+
UndefinedScopeAction.new("1.2.3.4").scope
|
175
|
+
}.should raise_error("Should implement scope. (Ex: ipn:follow)")
|
176
|
+
end
|
177
|
+
|
178
|
+
class DefinedScopeAction < Pause::Action
|
179
|
+
scope "my:scope"
|
180
|
+
end
|
181
|
+
|
182
|
+
it "should set scope on class" do
|
183
|
+
DefinedScopeAction.new("1.2.3.4").scope.should == "my:scope"
|
184
|
+
end
|
185
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'timecop'
|
3
|
+
|
4
|
+
describe Pause::Analyzer do
|
5
|
+
include Pause::Helper::Timing
|
6
|
+
|
7
|
+
class FollowPushNotification < Pause::Action
|
8
|
+
scope "ipn:follow"
|
9
|
+
check 20, 5, 12
|
10
|
+
check 40, 7, 12
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:resolution) { 10 }
|
14
|
+
let(:history) { 60 }
|
15
|
+
let(:configuration) { Pause::Configuration.new }
|
16
|
+
|
17
|
+
before do
|
18
|
+
Pause.stub(:config).and_return(configuration)
|
19
|
+
Pause.config.stub(:resolution).and_return(resolution)
|
20
|
+
Pause.config.stub(:history).and_return(history)
|
21
|
+
end
|
22
|
+
|
23
|
+
let(:analyzer) { Pause.analyzer }
|
24
|
+
let(:adapter) { Pause.analyzer.adapter }
|
25
|
+
let(:action) { FollowPushNotification.new("1243123") }
|
26
|
+
|
27
|
+
describe "#increment" do
|
28
|
+
it "should increment an action" do
|
29
|
+
time = Time.now
|
30
|
+
adapter.should_receive(:increment).with(action.key, time.to_i, 1)
|
31
|
+
Timecop.freeze time do
|
32
|
+
analyzer.increment(action)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "#analyze" do
|
38
|
+
it "checks and blocks if max_allowed is reached" do
|
39
|
+
time = Time.now
|
40
|
+
adapter.should_receive(:block).once.with(action.key, 12)
|
41
|
+
Timecop.freeze time do
|
42
|
+
5.times do
|
43
|
+
analyzer.increment(action)
|
44
|
+
analyzer.check(action)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe "#check" do
|
51
|
+
it "should return nil if action is NOT blocked" do
|
52
|
+
analyzer.check(action).should be_nil
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should return blocked action if action is blocked" do
|
56
|
+
Timecop.freeze Time.now do
|
57
|
+
5.times do
|
58
|
+
analyzer.increment(action)
|
59
|
+
end
|
60
|
+
analyzer.check(action).should be_a(Pause::BlockedAction)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Pause::Configuration, "#configure" do
|
4
|
+
|
5
|
+
subject { Pause::Configuration.new }
|
6
|
+
|
7
|
+
it "should allow configuration via block" do
|
8
|
+
subject.configure do |c|
|
9
|
+
c.redis_host = "128.23.12.8"
|
10
|
+
c.redis_port = "2134"
|
11
|
+
c.redis_db = "13"
|
12
|
+
|
13
|
+
c.resolution = 5000
|
14
|
+
c.history = 6000
|
15
|
+
end
|
16
|
+
|
17
|
+
subject.redis_host.should == "128.23.12.8"
|
18
|
+
subject.redis_port.should == 2134
|
19
|
+
subject.redis_db.should == "13"
|
20
|
+
|
21
|
+
subject.resolution.should == 5000
|
22
|
+
subject.history.should == 6000
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should provide redis defaults" do
|
26
|
+
subject.configure do |config|
|
27
|
+
# do nothing
|
28
|
+
end
|
29
|
+
|
30
|
+
subject.redis_host.should == "127.0.0.1"
|
31
|
+
subject.redis_port.should == 6379
|
32
|
+
subject.redis_db.should == "1"
|
33
|
+
subject.resolution.should == 600 # 10 minutes
|
34
|
+
subject.history.should == 86400 # one day
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'date'
|
3
|
+
require 'timecop'
|
4
|
+
|
5
|
+
describe Pause::Redis::Adapter do
|
6
|
+
|
7
|
+
let(:resolution) { 10 }
|
8
|
+
let(:history) { 60 }
|
9
|
+
let(:configuration) { Pause::Configuration.new }
|
10
|
+
|
11
|
+
before do
|
12
|
+
Pause.stub(:config).and_return(configuration)
|
13
|
+
Pause.config.stub(:resolution).and_return(resolution)
|
14
|
+
Pause.config.stub(:history).and_return(history)
|
15
|
+
end
|
16
|
+
|
17
|
+
let(:adapter) { Pause::Redis::Adapter.new(Pause.config) }
|
18
|
+
let(:redis_conn) { adapter.send(:redis) }
|
19
|
+
|
20
|
+
describe '#increment' do
|
21
|
+
let(:key) { "213213" }
|
22
|
+
|
23
|
+
it "should add key to a redis set" do
|
24
|
+
adapter.increment(key, Time.now.to_i)
|
25
|
+
set = redis_conn.zrange(adapter.send(:white_key, key), 0, -1, :with_scores => true)
|
26
|
+
set.should_not be_empty
|
27
|
+
set.size.should eql(1)
|
28
|
+
set[0].size.should eql(2)
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should remove old key from a redis set" do
|
32
|
+
time = Time.now
|
33
|
+
redis_conn.should_receive(:zrem).with(adapter.send(:white_key, key), [adapter.period_marker(resolution, time)])
|
34
|
+
|
35
|
+
adapter.time_blocks_to_keep = 1
|
36
|
+
Timecop.freeze time do
|
37
|
+
adapter.increment(key, Time.now.to_i)
|
38
|
+
end
|
39
|
+
Timecop.freeze time + (adapter.resolution + 1) do
|
40
|
+
adapter.increment(key, Time.now.to_i)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it "sets expiry on key" do
|
45
|
+
redis_conn.should_receive(:expire).with(adapter.send(:white_key, key), history)
|
46
|
+
adapter.increment(key, Time.now.to_i)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe "#block" do
|
51
|
+
let(:key) { "ipn:follow:123461234" }
|
52
|
+
let(:blocked_key) { "b:#{key}" }
|
53
|
+
let(:ttl) { 110000 }
|
54
|
+
|
55
|
+
it "saves ip to redis with expiration" do
|
56
|
+
adapter.block(key, ttl)
|
57
|
+
redis_conn.get(blocked_key).should_not be_nil
|
58
|
+
redis_conn.ttl(blocked_key).should == ttl
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "#blocked?" do
|
63
|
+
let(:key) { "ipn:follow:123461234" }
|
64
|
+
let(:blocked_key) { "b:#{key}" }
|
65
|
+
let(:ttl) { 110000 }
|
66
|
+
|
67
|
+
it "should return true if blocked" do
|
68
|
+
adapter.block(key, ttl)
|
69
|
+
(!!redis_conn.get(blocked_key).should) == adapter.blocked?(key)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe "#white_key" do
|
74
|
+
it "prefixes key" do
|
75
|
+
adapter.send(:white_key, "abc").should == "i:abc"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# This file was generated by the `rspec --init` command. Conventionally, all
|
2
|
+
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
3
|
+
# Require this file using `require "spec_helper"` to ensure that it is only
|
4
|
+
# loaded once.
|
5
|
+
#
|
6
|
+
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
7
|
+
|
8
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
|
9
|
+
require 'rubygems'
|
10
|
+
require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])
|
11
|
+
require 'pause'
|
12
|
+
|
13
|
+
Dir['spec/support/**/*.rb'].each { |filename| require_relative "../#{filename}" }
|
14
|
+
|
15
|
+
RSpec.configure do |config|
|
16
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
17
|
+
config.run_all_when_everything_filtered = true
|
18
|
+
config.filter_run :focus
|
19
|
+
|
20
|
+
# Run specs in random order to surface order dependencies. If you find an
|
21
|
+
# order dependency and want to debug it, you can fix the order by providing
|
22
|
+
# the seed, which is printed after each run.
|
23
|
+
# --seed 1234
|
24
|
+
config.order = 'random'
|
25
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'fakeredis/rspec'
|
metadata
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pause
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Atasay Gokkaya
|
9
|
+
- Paul Henry
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-11-13 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: redis
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ! '>='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '0'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
none: false
|
27
|
+
requirements:
|
28
|
+
- - ! '>='
|
29
|
+
- !ruby/object:Gem::Version
|
30
|
+
version: '0'
|
31
|
+
- !ruby/object:Gem::Dependency
|
32
|
+
name: rspec
|
33
|
+
requirement: !ruby/object:Gem::Requirement
|
34
|
+
none: false
|
35
|
+
requirements:
|
36
|
+
- - ! '>='
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
type: :development
|
40
|
+
prerelease: false
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
42
|
+
none: false
|
43
|
+
requirements:
|
44
|
+
- - ! '>='
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: fakeredis
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
none: false
|
51
|
+
requirements:
|
52
|
+
- - ! '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
type: :development
|
56
|
+
prerelease: false
|
57
|
+
version_requirements: !ruby/object:Gem::Requirement
|
58
|
+
none: false
|
59
|
+
requirements:
|
60
|
+
- - ! '>='
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0'
|
63
|
+
- !ruby/object:Gem::Dependency
|
64
|
+
name: timecop
|
65
|
+
requirement: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - ! '>='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
type: :development
|
72
|
+
prerelease: false
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
74
|
+
none: false
|
75
|
+
requirements:
|
76
|
+
- - ! '>='
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
- !ruby/object:Gem::Dependency
|
80
|
+
name: guard-rspec
|
81
|
+
requirement: !ruby/object:Gem::Requirement
|
82
|
+
none: false
|
83
|
+
requirements:
|
84
|
+
- - ! '>='
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
type: :development
|
88
|
+
prerelease: false
|
89
|
+
version_requirements: !ruby/object:Gem::Requirement
|
90
|
+
none: false
|
91
|
+
requirements:
|
92
|
+
- - ! '>='
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0'
|
95
|
+
- !ruby/object:Gem::Dependency
|
96
|
+
name: rb-fsevent
|
97
|
+
requirement: !ruby/object:Gem::Requirement
|
98
|
+
none: false
|
99
|
+
requirements:
|
100
|
+
- - ! '>='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
type: :development
|
104
|
+
prerelease: false
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
106
|
+
none: false
|
107
|
+
requirements:
|
108
|
+
- - ! '>='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
description: Real time redis rate limiting
|
112
|
+
email:
|
113
|
+
- atasay@wanelo.com
|
114
|
+
- paul@wanelo.com
|
115
|
+
executables: []
|
116
|
+
extensions: []
|
117
|
+
extra_rdoc_files: []
|
118
|
+
files:
|
119
|
+
- .gitignore
|
120
|
+
- .pairs
|
121
|
+
- .rspec
|
122
|
+
- .rvmrc
|
123
|
+
- Gemfile
|
124
|
+
- Guardfile
|
125
|
+
- LICENSE.txt
|
126
|
+
- README.md
|
127
|
+
- Rakefile
|
128
|
+
- lib/pause.rb
|
129
|
+
- lib/pause/action.rb
|
130
|
+
- lib/pause/analyzer.rb
|
131
|
+
- lib/pause/blocked_action.rb
|
132
|
+
- lib/pause/configuration.rb
|
133
|
+
- lib/pause/helper/timing.rb
|
134
|
+
- lib/pause/redis/adapter.rb
|
135
|
+
- lib/pause/version.rb
|
136
|
+
- pause.gemspec
|
137
|
+
- spec/pause/action_spec.rb
|
138
|
+
- spec/pause/analyzer_spec.rb
|
139
|
+
- spec/pause/configuration_spec.rb
|
140
|
+
- spec/pause/redis/adapter_spec.rb
|
141
|
+
- spec/spec_helper.rb
|
142
|
+
- spec/support/fakeredis.rb
|
143
|
+
homepage: https://github.com/wanelo/pause
|
144
|
+
licenses: []
|
145
|
+
post_install_message:
|
146
|
+
rdoc_options: []
|
147
|
+
require_paths:
|
148
|
+
- lib
|
149
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
150
|
+
none: false
|
151
|
+
requirements:
|
152
|
+
- - ! '>='
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '0'
|
155
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
156
|
+
none: false
|
157
|
+
requirements:
|
158
|
+
- - ! '>='
|
159
|
+
- !ruby/object:Gem::Version
|
160
|
+
version: '0'
|
161
|
+
requirements: []
|
162
|
+
rubyforge_project:
|
163
|
+
rubygems_version: 1.8.24
|
164
|
+
signing_key:
|
165
|
+
specification_version: 3
|
166
|
+
summary: Real time redis rate limiting
|
167
|
+
test_files:
|
168
|
+
- spec/pause/action_spec.rb
|
169
|
+
- spec/pause/analyzer_spec.rb
|
170
|
+
- spec/pause/configuration_spec.rb
|
171
|
+
- spec/pause/redis/adapter_spec.rb
|
172
|
+
- spec/spec_helper.rb
|
173
|
+
- spec/support/fakeredis.rb
|
174
|
+
has_rdoc:
|