berater 0.1.1 → 0.3.0
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.
- checksums.yaml +4 -4
- data/lib/berater.rb +35 -24
- data/lib/berater/concurrency_limiter.rb +31 -88
- data/lib/berater/dsl.rb +57 -0
- data/lib/berater/inhibitor.rb +4 -10
- data/lib/berater/limiter.rb +30 -0
- data/lib/berater/lock.rb +36 -0
- data/lib/berater/rate_limiter.rb +37 -23
- data/lib/berater/rspec.rb +12 -0
- data/lib/berater/rspec/matchers.rb +60 -0
- data/lib/berater/test_mode.rb +43 -0
- data/lib/berater/unlimiter.rb +14 -10
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +69 -90
- data/spec/concurrency_limiter_spec.rb +63 -74
- data/spec/inhibitor_spec.rb +3 -15
- data/spec/{matcher_spec.rb → matchers_spec.rb} +4 -4
- data/spec/rate_limiter_spec.rb +83 -58
- data/spec/test_mode_spec.rb +183 -0
- data/spec/unlimiter_spec.rb +6 -31
- metadata +12 -7
- data/lib/berater/base_limiter.rb +0 -34
- data/spec/concurrency_lock_spec.rb +0 -50
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d007dbb664fa57c921047a28dd1e5a591cf5c51fba4931dbc367ab552e4b8a8d
|
4
|
+
data.tar.gz: c710d31a2d8cfdc50af4c2fae87cabad177096d13da52a7f9c48b61aa31b6038
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0d9c211bb65e7341b98e637d3f0ce0e31f0a879437c1c309fb01e1efab5e5c6b2d5044158931ab16a67cbbdebd00ddb5f10afc97430230b153d8cc236f7517ab
|
7
|
+
data.tar.gz: 5d8b38bfd7683df641fd6891cd1a493079453ea6c61b2880581eeb2d963fb171365fb3448443e2548b34297d8dcfa352e67cff5688cd24d25422efb5e55d4afd
|
data/lib/berater.rb
CHANGED
@@ -1,53 +1,50 @@
|
|
1
|
-
require 'berater/base_limiter'
|
2
|
-
require 'berater/concurrency_limiter'
|
3
|
-
require 'berater/inhibitor'
|
4
|
-
require 'berater/rate_limiter'
|
5
|
-
require 'berater/unlimiter'
|
6
1
|
require 'berater/version'
|
2
|
+
require 'berater/lock'
|
7
3
|
|
8
4
|
|
9
5
|
module Berater
|
10
6
|
extend self
|
11
7
|
|
12
|
-
Overloaded
|
8
|
+
class Overloaded < StandardError; end
|
13
9
|
|
14
10
|
MODES = {}
|
15
11
|
|
16
|
-
attr_accessor :redis
|
12
|
+
attr_accessor :redis
|
17
13
|
|
18
14
|
def configure
|
19
|
-
self.mode = :unlimited # default
|
20
|
-
|
21
15
|
yield self
|
22
16
|
end
|
23
17
|
|
24
|
-
def new(mode, *args, **opts)
|
18
|
+
def new(key, mode = nil, *args, **opts, &block)
|
19
|
+
if mode.nil?
|
20
|
+
unless args.empty?
|
21
|
+
raise ArgumentError, '0 arguments expected with block'
|
22
|
+
end
|
23
|
+
|
24
|
+
unless block_given?
|
25
|
+
raise ArgumentError, 'expected either mode or block'
|
26
|
+
end
|
27
|
+
|
28
|
+
mode, *args = DSL.eval(&block)
|
29
|
+
else
|
30
|
+
if block_given?
|
31
|
+
raise ArgumentError, 'expected either mode or block, not both'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
25
35
|
klass = MODES[mode.to_sym]
|
26
36
|
|
27
37
|
unless klass
|
28
38
|
raise ArgumentError, "invalid mode: #{mode}"
|
29
39
|
end
|
30
40
|
|
31
|
-
klass.new(*args, **opts)
|
41
|
+
klass.new(key, *args, **opts)
|
32
42
|
end
|
33
43
|
|
34
44
|
def register(mode, klass)
|
35
45
|
MODES[mode.to_sym] = klass
|
36
46
|
end
|
37
47
|
|
38
|
-
def mode=(mode)
|
39
|
-
unless MODES.include? mode.to_sym
|
40
|
-
raise ArgumentError, "invalid mode: #{mode}"
|
41
|
-
end
|
42
|
-
|
43
|
-
@mode = mode.to_sym
|
44
|
-
end
|
45
|
-
|
46
|
-
def limit(*args, **opts, &block)
|
47
|
-
mode = opts.delete(:mode) { self.mode }
|
48
|
-
new(mode, *args, **opts).limit(&block)
|
49
|
-
end
|
50
|
-
|
51
48
|
def expunge
|
52
49
|
redis.scan_each(match: "#{self.name}*") do |key|
|
53
50
|
redis.del key
|
@@ -56,7 +53,21 @@ module Berater
|
|
56
53
|
|
57
54
|
end
|
58
55
|
|
56
|
+
# convenience method
|
57
|
+
def Berater(key, mode, *args, **opts, &block)
|
58
|
+
Berater.new(key, mode, *args, **opts).limit(&block)
|
59
|
+
end
|
60
|
+
|
61
|
+
# load limiters
|
62
|
+
require 'berater/limiter'
|
63
|
+
require 'berater/concurrency_limiter'
|
64
|
+
require 'berater/inhibitor'
|
65
|
+
require 'berater/rate_limiter'
|
66
|
+
require 'berater/unlimiter'
|
67
|
+
|
59
68
|
Berater.register(:concurrency, Berater::ConcurrencyLimiter)
|
60
69
|
Berater.register(:inhibited, Berater::Inhibitor)
|
61
70
|
Berater.register(:rate, Berater::RateLimiter)
|
62
71
|
Berater.register(:unlimited, Berater::Unlimiter)
|
72
|
+
|
73
|
+
require 'berater/dsl'
|
@@ -1,18 +1,18 @@
|
|
1
1
|
module Berater
|
2
|
-
class ConcurrencyLimiter <
|
2
|
+
class ConcurrencyLimiter < Limiter
|
3
3
|
|
4
4
|
class Incapacitated < Overloaded; end
|
5
5
|
|
6
6
|
attr_reader :capacity, :timeout
|
7
7
|
|
8
|
-
def initialize(capacity, **opts)
|
9
|
-
super(**opts)
|
8
|
+
def initialize(key, capacity, **opts)
|
9
|
+
super(key, **opts)
|
10
10
|
|
11
11
|
self.capacity = capacity
|
12
12
|
self.timeout = opts[:timeout] || 0
|
13
13
|
end
|
14
14
|
|
15
|
-
def capacity=(capacity)
|
15
|
+
private def capacity=(capacity)
|
16
16
|
unless capacity.is_a? Integer
|
17
17
|
raise ArgumentError, "expected Integer, found #{capacity.class}"
|
18
18
|
end
|
@@ -22,7 +22,7 @@ module Berater
|
|
22
22
|
@capacity = capacity
|
23
23
|
end
|
24
24
|
|
25
|
-
def timeout=(timeout)
|
25
|
+
private def timeout=(timeout)
|
26
26
|
unless timeout.is_a? Integer
|
27
27
|
raise ArgumentError, "expected Integer, found #{timeout.class}"
|
28
28
|
end
|
@@ -32,119 +32,62 @@ module Berater
|
|
32
32
|
@timeout = timeout
|
33
33
|
end
|
34
34
|
|
35
|
-
class Lock
|
36
|
-
attr_reader :limiter, :id, :contention
|
37
|
-
|
38
|
-
def initialize(limiter, id, contention)
|
39
|
-
@limiter = limiter
|
40
|
-
@id = id
|
41
|
-
@contention = contention
|
42
|
-
@released = false
|
43
|
-
@locked_at = Time.now
|
44
|
-
end
|
45
|
-
|
46
|
-
def release
|
47
|
-
raise 'lock already released' if released?
|
48
|
-
raise 'lock expired' if expired?
|
49
|
-
|
50
|
-
@released = limiter.release(self)
|
51
|
-
end
|
52
|
-
|
53
|
-
def released?
|
54
|
-
@released
|
55
|
-
end
|
56
|
-
|
57
|
-
def expired?
|
58
|
-
@locked_at + limiter.timeout < Time.now
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
35
|
LUA_SCRIPT = <<~LUA.gsub(/^\s*(--.*\n)?/, '')
|
63
36
|
local key = KEYS[1]
|
37
|
+
local lock_key = KEYS[2]
|
64
38
|
local capacity = tonumber(ARGV[1])
|
65
|
-
local
|
66
|
-
|
67
|
-
local exists
|
68
|
-
local count
|
39
|
+
local ts = tonumber(ARGV[2])
|
40
|
+
local ttl = tonumber(ARGV[3])
|
69
41
|
local lock
|
70
|
-
local ts = unpack(redis.call('TIME'))
|
71
42
|
|
72
|
-
--
|
73
|
-
if ttl
|
74
|
-
|
75
|
-
else
|
76
|
-
-- and refresh TTL while we're at it
|
77
|
-
exists = redis.call('EXPIRE', key, ttl * 2)
|
43
|
+
-- purge stale hosts
|
44
|
+
if ttl > 0 then
|
45
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
78
46
|
end
|
79
47
|
|
80
|
-
|
81
|
-
|
82
|
-
if ttl > 0 then
|
83
|
-
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
84
|
-
end
|
85
|
-
|
86
|
-
-- check capacity (subtract one for next lock entry)
|
87
|
-
count = redis.call('ZCARD', key) - 1
|
88
|
-
|
89
|
-
if count < capacity then
|
90
|
-
-- yay, grab a lock
|
91
|
-
|
92
|
-
-- regenerate next lock entry, which has score inf
|
93
|
-
lock = unpack(redis.call('ZPOPMAX', key))
|
94
|
-
redis.call('ZADD', key, 'inf', (lock + 1) % 2^52)
|
95
|
-
|
96
|
-
count = count + 1
|
97
|
-
end
|
98
|
-
else
|
99
|
-
count = 1
|
100
|
-
lock = "1"
|
101
|
-
|
102
|
-
-- create structure to track locks and next id
|
103
|
-
redis.call('ZADD', key, 'inf', lock + 1)
|
48
|
+
-- check capacity
|
49
|
+
local count = redis.call('ZCARD', key)
|
104
50
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
end
|
109
|
-
|
110
|
-
if lock then
|
111
|
-
-- store lock and timestamp
|
51
|
+
if count < capacity then
|
52
|
+
-- grab a lock
|
53
|
+
lock = redis.call('INCR', lock_key)
|
112
54
|
redis.call('ZADD', key, ts, lock)
|
55
|
+
count = count + 1
|
113
56
|
end
|
114
57
|
|
115
58
|
return { count, lock }
|
116
59
|
LUA
|
117
60
|
|
118
|
-
def limit
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
).limit(&block)
|
125
|
-
end
|
126
|
-
|
127
|
-
count, lock_id = redis.eval(LUA_SCRIPT, [ key ], [ capacity, timeout ])
|
61
|
+
def limit
|
62
|
+
count, lock_id = redis.eval(
|
63
|
+
LUA_SCRIPT,
|
64
|
+
[ cache_key(key), cache_key('lock_id') ],
|
65
|
+
[ capacity, Time.now.to_i, timeout ]
|
66
|
+
)
|
128
67
|
|
129
68
|
raise Incapacitated unless lock_id
|
130
69
|
|
131
|
-
lock = Lock.new(self, lock_id, count)
|
70
|
+
lock = Lock.new(self, lock_id, count, -> { release(lock_id) })
|
132
71
|
|
133
72
|
if block_given?
|
134
73
|
begin
|
135
74
|
yield lock
|
136
75
|
ensure
|
137
|
-
release
|
76
|
+
lock.release
|
138
77
|
end
|
139
78
|
else
|
140
79
|
lock
|
141
80
|
end
|
142
81
|
end
|
143
82
|
|
144
|
-
def release(
|
145
|
-
res = redis.zrem(key,
|
83
|
+
private def release(lock_id)
|
84
|
+
res = redis.zrem(cache_key(key), lock_id)
|
146
85
|
res == true || res == 1 # depending on which version of Redis
|
147
86
|
end
|
148
87
|
|
88
|
+
def to_s
|
89
|
+
"#<#{self.class}(#{key}: #{capacity} at a time)>"
|
90
|
+
end
|
91
|
+
|
149
92
|
end
|
150
93
|
end
|
data/lib/berater/dsl.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
module Berater
|
2
|
+
module DSL
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def eval &block
|
6
|
+
@keywords ||= Class.new do
|
7
|
+
# create a class where DSL keywords are methods
|
8
|
+
KEYWORDS.each do |keyword|
|
9
|
+
define_singleton_method(keyword) { keyword }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
install
|
14
|
+
@keywords.class_eval &block
|
15
|
+
ensure
|
16
|
+
uninstall
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def each &block
|
22
|
+
Berater::MODES.map do |mode, limiter|
|
23
|
+
next unless limiter.const_defined?(:DSL, false)
|
24
|
+
limiter.const_get(:DSL)
|
25
|
+
end.compact.each(&block)
|
26
|
+
end
|
27
|
+
|
28
|
+
KEYWORDS = [
|
29
|
+
:second, :minute, :hour,
|
30
|
+
:unlimited, :inhibited,
|
31
|
+
].freeze
|
32
|
+
|
33
|
+
def install
|
34
|
+
Integer.class_eval do
|
35
|
+
def per(unit)
|
36
|
+
[ :rate, self, unit ]
|
37
|
+
end
|
38
|
+
alias every per
|
39
|
+
|
40
|
+
def at_once
|
41
|
+
[ :concurrency, self ]
|
42
|
+
end
|
43
|
+
alias concurrently at_once
|
44
|
+
alias at_a_time at_once
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def uninstall
|
49
|
+
Integer.remove_method :per
|
50
|
+
Integer.remove_method :every
|
51
|
+
|
52
|
+
Integer.remove_method :at_once
|
53
|
+
Integer.remove_method :concurrently
|
54
|
+
Integer.remove_method :at_a_time
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/berater/inhibitor.rb
CHANGED
@@ -1,19 +1,13 @@
|
|
1
1
|
module Berater
|
2
|
-
class Inhibitor <
|
2
|
+
class Inhibitor < Limiter
|
3
3
|
|
4
4
|
class Inhibited < Overloaded; end
|
5
5
|
|
6
|
-
def initialize(*args, **opts)
|
7
|
-
super(**opts)
|
6
|
+
def initialize(key = :inhibitor, *args, **opts)
|
7
|
+
super(key, **opts)
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit
|
11
|
-
unless opts.empty?
|
12
|
-
return self.class.new(
|
13
|
-
**options.merge(opts)
|
14
|
-
).limit(&block)
|
15
|
-
end
|
16
|
-
|
10
|
+
def limit
|
17
11
|
raise Inhibited
|
18
12
|
end
|
19
13
|
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Berater
|
2
|
+
class Limiter
|
3
|
+
|
4
|
+
attr_reader :key, :options
|
5
|
+
|
6
|
+
def redis
|
7
|
+
options[:redis] || Berater.redis
|
8
|
+
end
|
9
|
+
|
10
|
+
def limit
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"#<#{self.class}>"
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def initialize(key, **opts)
|
21
|
+
@key = key
|
22
|
+
@options = opts
|
23
|
+
end
|
24
|
+
|
25
|
+
def cache_key(key)
|
26
|
+
"#{self.class}:#{key}"
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
data/lib/berater/lock.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module Berater
|
2
|
+
class Lock
|
3
|
+
|
4
|
+
attr_reader :limiter, :id, :contention
|
5
|
+
|
6
|
+
def initialize(limiter, id, contention, release_fn = nil)
|
7
|
+
@limiter = limiter
|
8
|
+
@id = id
|
9
|
+
@contention = contention
|
10
|
+
@locked_at = Time.now
|
11
|
+
@release_fn = release_fn
|
12
|
+
@released_at = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def locked?
|
16
|
+
@released_at.nil? && !expired?
|
17
|
+
end
|
18
|
+
|
19
|
+
def expired?
|
20
|
+
timeout ? @locked_at + timeout < Time.now : false
|
21
|
+
end
|
22
|
+
|
23
|
+
def release
|
24
|
+
raise 'lock expired' if expired?
|
25
|
+
raise 'lock already released' unless locked?
|
26
|
+
|
27
|
+
@released_at = Time.now
|
28
|
+
@release_fn ? @release_fn.call : true
|
29
|
+
end
|
30
|
+
|
31
|
+
def timeout
|
32
|
+
limiter.respond_to?(:timeout) ? limiter.timeout : nil
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
data/lib/berater/rate_limiter.rb
CHANGED
@@ -1,18 +1,18 @@
|
|
1
1
|
module Berater
|
2
|
-
class RateLimiter <
|
2
|
+
class RateLimiter < Limiter
|
3
3
|
|
4
4
|
class Overrated < Overloaded; end
|
5
5
|
|
6
6
|
attr_accessor :count, :interval
|
7
7
|
|
8
|
-
def initialize(count, interval, **opts)
|
9
|
-
super(**opts)
|
8
|
+
def initialize(key, count, interval, **opts)
|
9
|
+
super(key, **opts)
|
10
10
|
|
11
11
|
self.count = count
|
12
12
|
self.interval = interval
|
13
13
|
end
|
14
14
|
|
15
|
-
def count=(count)
|
15
|
+
private def count=(count)
|
16
16
|
unless count.is_a? Integer
|
17
17
|
raise ArgumentError, "expected Integer, found #{count.class}"
|
18
18
|
end
|
@@ -22,12 +22,13 @@ module Berater
|
|
22
22
|
@count = count
|
23
23
|
end
|
24
24
|
|
25
|
-
def interval=(interval)
|
25
|
+
private def interval=(interval)
|
26
26
|
@interval = interval.dup
|
27
27
|
|
28
28
|
case @interval
|
29
29
|
when Integer
|
30
30
|
raise ArgumentError, "interval must be >= 0" unless @interval >= 0
|
31
|
+
@interval_sec = @interval
|
31
32
|
when String
|
32
33
|
@interval = @interval.to_sym
|
33
34
|
when Symbol
|
@@ -38,45 +39,58 @@ module Berater
|
|
38
39
|
if @interval.is_a? Symbol
|
39
40
|
case @interval
|
40
41
|
when :sec, :second, :seconds
|
41
|
-
@interval =
|
42
|
+
@interval = :second
|
43
|
+
@interval_sec = 1
|
42
44
|
when :min, :minute, :minutes
|
43
|
-
@interval =
|
45
|
+
@interval = :minute
|
46
|
+
@interval_sec = 60
|
44
47
|
when :hour, :hours
|
45
|
-
@interval =
|
48
|
+
@interval = :hour
|
49
|
+
@interval_sec = 60 * 60
|
46
50
|
else
|
47
51
|
raise ArgumentError, "unexpected interval value: #{interval}"
|
48
52
|
end
|
49
53
|
end
|
50
|
-
|
51
|
-
@interval
|
52
54
|
end
|
53
55
|
|
54
|
-
def limit
|
55
|
-
unless opts.empty?
|
56
|
-
return self.class.new(
|
57
|
-
count,
|
58
|
-
interval,
|
59
|
-
options.merge(opts)
|
60
|
-
).limit(&block)
|
61
|
-
end
|
62
|
-
|
56
|
+
def limit
|
63
57
|
ts = Time.now.to_i
|
64
58
|
|
65
59
|
# bucket into time slot
|
66
|
-
rkey = "%s:%d" % [ key, ts - ts % @
|
60
|
+
rkey = "%s:%d" % [ cache_key(key), ts - ts % @interval_sec ]
|
67
61
|
|
68
62
|
count, _ = redis.multi do
|
69
63
|
redis.incr rkey
|
70
|
-
redis.expire rkey, @
|
64
|
+
redis.expire rkey, @interval_sec * 2
|
71
65
|
end
|
72
66
|
|
73
67
|
raise Overrated if count > @count
|
74
68
|
|
69
|
+
lock = Lock.new(self, count, count)
|
70
|
+
|
75
71
|
if block_given?
|
76
|
-
|
72
|
+
begin
|
73
|
+
yield lock
|
74
|
+
ensure
|
75
|
+
lock.release
|
76
|
+
end
|
77
|
+
else
|
78
|
+
lock
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_s
|
83
|
+
msg = if @interval.is_a? Integer
|
84
|
+
if @interval == 1
|
85
|
+
"every second"
|
86
|
+
else
|
87
|
+
"every #{@interval} seconds"
|
88
|
+
end
|
77
89
|
else
|
78
|
-
|
90
|
+
"per #{@interval}"
|
79
91
|
end
|
92
|
+
|
93
|
+
"#<#{self.class}(#{key}: #{count} #{msg})>"
|
80
94
|
end
|
81
95
|
|
82
96
|
end
|