berater 0.6.0 → 0.8.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 +14 -13
- data/lib/berater/concurrency_limiter.rb +19 -37
- data/lib/berater/dsl.rb +8 -8
- data/lib/berater/inhibitor.rb +4 -7
- data/lib/berater/limiter.rb +49 -19
- data/lib/berater/lock.rb +2 -1
- data/lib/berater/rate_limiter.rb +40 -40
- data/lib/berater/rspec.rb +1 -2
- data/lib/berater/rspec/matchers.rb +26 -48
- data/lib/berater/static_limiter.rb +49 -0
- data/lib/berater/test_mode.rb +31 -36
- data/lib/berater/unlimiter.rb +8 -6
- data/lib/berater/utils.rb +9 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +26 -76
- data/spec/concurrency_limiter_spec.rb +73 -57
- data/spec/dsl_refinement_spec.rb +0 -12
- data/spec/dsl_spec.rb +5 -17
- data/spec/inhibitor_spec.rb +10 -5
- data/spec/limiter_spec.rb +95 -9
- data/spec/matchers_spec.rb +21 -85
- data/spec/rate_limiter_spec.rb +88 -38
- data/spec/riddle_spec.rb +6 -2
- data/spec/static_limiter_spec.rb +79 -0
- data/spec/test_mode_spec.rb +48 -106
- data/spec/unlimiter_spec.rb +11 -5
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c32c1de752ccc7a2a71e4628b99f3561db623374dabaf95af39aadcc3b1a2d16
|
4
|
+
data.tar.gz: 33019954de1d79522ed7105e9f0a7da3a204df74cacd25aa4b57e749a81047bb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 89460a9a81a8d384addb095c425da0eed4fdbe0e15ecd85ee97ffcf4292441deeb34c7e4cd4591adca06096a17f7c131b79c6474d9efb2b635fbe398ad68d00e
|
7
|
+
data.tar.gz: 0a331b939e4c19817c42a8aa8fc5be7fd59d63f6becd5fe6ed1c226690d1524ef040a28d8bcb625d75452ab2f5c8539ddf69093bbd30c4ba9e3353259a3e568d
|
data/lib/berater.rb
CHANGED
@@ -19,20 +19,25 @@ module Berater
|
|
19
19
|
@redis = nil
|
20
20
|
end
|
21
21
|
|
22
|
-
def new(key, capacity,
|
22
|
+
def new(key, capacity, **opts)
|
23
|
+
args = []
|
24
|
+
|
23
25
|
case capacity
|
24
|
-
when
|
26
|
+
when Float::INFINITY
|
25
27
|
Berater::Unlimiter
|
26
|
-
when
|
28
|
+
when 0
|
27
29
|
Berater::Inhibitor
|
28
30
|
else
|
29
|
-
if interval
|
31
|
+
if opts[:interval]
|
32
|
+
args << opts.delete(:interval)
|
30
33
|
Berater::RateLimiter
|
31
|
-
|
34
|
+
elsif opts[:timeout]
|
32
35
|
Berater::ConcurrencyLimiter
|
36
|
+
else
|
37
|
+
Berater::StaticLimiter
|
33
38
|
end
|
34
39
|
end.yield_self do |klass|
|
35
|
-
args = [ key, capacity,
|
40
|
+
args = [ key, capacity, *args ].compact
|
36
41
|
klass.new(*args, **opts)
|
37
42
|
end
|
38
43
|
end
|
@@ -46,17 +51,13 @@ module Berater
|
|
46
51
|
end
|
47
52
|
|
48
53
|
# convenience method
|
49
|
-
def Berater(
|
50
|
-
|
51
|
-
if block_given?
|
52
|
-
limiter.limit(&block)
|
53
|
-
else
|
54
|
-
limiter
|
55
|
-
end
|
54
|
+
def Berater(*args, **opts, &block)
|
55
|
+
Berater::Utils.convenience_fn(Berater, *args, **opts, &block)
|
56
56
|
end
|
57
57
|
|
58
58
|
# load limiters
|
59
59
|
require 'berater/concurrency_limiter'
|
60
60
|
require 'berater/inhibitor'
|
61
61
|
require 'berater/rate_limiter'
|
62
|
+
require 'berater/static_limiter'
|
62
63
|
require 'berater/unlimiter'
|
@@ -1,20 +1,22 @@
|
|
1
1
|
module Berater
|
2
2
|
class ConcurrencyLimiter < Limiter
|
3
3
|
|
4
|
-
class Incapacitated < Overloaded; end
|
5
|
-
|
6
|
-
attr_reader :timeout
|
7
|
-
|
8
4
|
def initialize(key, capacity, **opts)
|
9
5
|
super(key, capacity, **opts)
|
10
6
|
|
7
|
+
# truncate fractional capacity
|
8
|
+
self.capacity = capacity.to_i
|
9
|
+
|
11
10
|
self.timeout = opts[:timeout] || 0
|
12
11
|
end
|
13
12
|
|
13
|
+
def timeout
|
14
|
+
options[:timeout]
|
15
|
+
end
|
16
|
+
|
14
17
|
private def timeout=(timeout)
|
15
|
-
@timeout = timeout
|
16
18
|
timeout = 0 if timeout == Float::INFINITY
|
17
|
-
@
|
19
|
+
@timeout = Berater::Utils.to_msec(timeout)
|
18
20
|
end
|
19
21
|
|
20
22
|
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
@@ -28,17 +30,15 @@ module Berater
|
|
28
30
|
|
29
31
|
-- purge stale hosts
|
30
32
|
if ttl > 0 then
|
31
|
-
redis.call('ZREMRANGEBYSCORE', key,
|
33
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, ts - ttl)
|
32
34
|
end
|
33
35
|
|
34
36
|
-- check capacity
|
35
37
|
local count = redis.call('ZCARD', key)
|
36
38
|
|
37
39
|
if cost == 0 then
|
38
|
-
-- just
|
39
|
-
|
40
|
-
table.insert(lock_ids, true)
|
41
|
-
end
|
40
|
+
-- just checking count
|
41
|
+
table.insert(lock_ids, true)
|
42
42
|
elseif (count + cost) <= capacity then
|
43
43
|
-- grab locks, one per cost
|
44
44
|
local lock_id = redis.call('INCRBY', lock_key, cost)
|
@@ -63,45 +63,27 @@ module Berater
|
|
63
63
|
LUA
|
64
64
|
)
|
65
65
|
|
66
|
-
def
|
67
|
-
capacity
|
68
|
-
|
69
|
-
# since fractional cost is not supported, capacity behaves like int
|
66
|
+
protected def acquire_lock(capacity, cost)
|
67
|
+
# round fractional capacity and cost
|
70
68
|
capacity = capacity.to_i
|
71
|
-
|
72
|
-
unless cost.is_a?(Integer) && cost >= 0
|
73
|
-
raise ArgumentError, "invalid cost: #{cost}"
|
74
|
-
end
|
69
|
+
cost = cost.ceil
|
75
70
|
|
76
71
|
# timestamp in milliseconds
|
77
72
|
ts = (Time.now.to_f * 10**3).to_i
|
78
73
|
|
79
74
|
count, *lock_ids = LUA_SCRIPT.eval(
|
80
75
|
redis,
|
81
|
-
[ cache_key
|
82
|
-
[ capacity, ts, @
|
76
|
+
[ cache_key, self.class.cache_key('lock_id') ],
|
77
|
+
[ capacity, ts, @timeout, cost ]
|
83
78
|
)
|
84
79
|
|
85
|
-
raise
|
80
|
+
raise Overloaded if lock_ids.empty?
|
86
81
|
|
87
82
|
release_fn = if cost > 0
|
88
|
-
proc {
|
83
|
+
proc { redis.zrem(cache_key, lock_ids) }
|
89
84
|
end
|
90
|
-
lock = Lock.new(capacity, count, release_fn)
|
91
|
-
|
92
|
-
yield_lock(lock, &block)
|
93
|
-
end
|
94
|
-
|
95
|
-
def overloaded?
|
96
|
-
limit(cost: 0) { false }
|
97
|
-
rescue Overloaded
|
98
|
-
true
|
99
|
-
end
|
100
|
-
alias incapacitated? overloaded?
|
101
85
|
|
102
|
-
|
103
|
-
res = redis.zrem(cache_key(key), lock_ids)
|
104
|
-
res == true || res == lock_ids.count # depending on which version of Redis
|
86
|
+
Lock.new(capacity, count, release_fn)
|
105
87
|
end
|
106
88
|
|
107
89
|
def to_s
|
data/lib/berater/dsl.rb
CHANGED
@@ -1,20 +1,21 @@
|
|
1
1
|
module Berater
|
2
2
|
module DSL
|
3
3
|
refine Berater.singleton_class do
|
4
|
-
def new(key,
|
5
|
-
if
|
4
|
+
def new(key, capacity = nil, **opts, &block)
|
5
|
+
if capacity.nil?
|
6
6
|
unless block_given?
|
7
|
-
raise ArgumentError, 'expected either
|
7
|
+
raise ArgumentError, 'expected either capacity or block'
|
8
8
|
end
|
9
9
|
|
10
|
-
|
10
|
+
capacity, more_opts = DSL.eval(&block)
|
11
|
+
opts.merge!(more_opts) if more_opts
|
11
12
|
else
|
12
13
|
if block_given?
|
13
|
-
raise ArgumentError, 'expected either
|
14
|
+
raise ArgumentError, 'expected either capacity or block, not both'
|
14
15
|
end
|
15
16
|
end
|
16
17
|
|
17
|
-
super(key,
|
18
|
+
super(key, capacity, **opts)
|
18
19
|
end
|
19
20
|
end
|
20
21
|
|
@@ -38,13 +39,12 @@ module Berater
|
|
38
39
|
|
39
40
|
KEYWORDS = [
|
40
41
|
:second, :minute, :hour,
|
41
|
-
:unlimited, :inhibited,
|
42
42
|
].freeze
|
43
43
|
|
44
44
|
def install
|
45
45
|
Integer.class_eval do
|
46
46
|
def per(unit)
|
47
|
-
[ self, unit ]
|
47
|
+
[ self, interval: unit ]
|
48
48
|
end
|
49
49
|
alias every per
|
50
50
|
|
data/lib/berater/inhibitor.rb
CHANGED
@@ -1,20 +1,17 @@
|
|
1
1
|
module Berater
|
2
2
|
class Inhibitor < Limiter
|
3
3
|
|
4
|
-
class Inhibited < Overloaded; end
|
5
|
-
|
6
4
|
def initialize(key = :inhibitor, *args, **opts)
|
7
5
|
super(key, 0, **opts)
|
8
6
|
end
|
9
7
|
|
10
|
-
def
|
11
|
-
|
8
|
+
def to_s
|
9
|
+
"#<#{self.class}>"
|
12
10
|
end
|
13
11
|
|
14
|
-
def
|
15
|
-
|
12
|
+
protected def acquire_lock(*)
|
13
|
+
raise Overloaded
|
16
14
|
end
|
17
|
-
alias inhibited? overloaded?
|
18
15
|
|
19
16
|
end
|
20
17
|
end
|
data/lib/berater/limiter.rb
CHANGED
@@ -7,16 +7,40 @@ module Berater
|
|
7
7
|
options[:redis] || Berater.redis
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit
|
11
|
-
|
12
|
-
end
|
10
|
+
def limit(capacity: nil, cost: 1, &block)
|
11
|
+
capacity ||= @capacity
|
13
12
|
|
14
|
-
|
15
|
-
|
13
|
+
unless capacity.is_a?(Numeric) && capacity >= 0
|
14
|
+
raise ArgumentError, "invalid capacity: #{capacity}"
|
15
|
+
end
|
16
|
+
|
17
|
+
unless cost.is_a?(Numeric) && cost >= 0 && cost < Float::INFINITY
|
18
|
+
raise ArgumentError, "invalid cost: #{cost}"
|
19
|
+
end
|
20
|
+
|
21
|
+
lock = acquire_lock(capacity, cost)
|
22
|
+
|
23
|
+
if block_given?
|
24
|
+
begin
|
25
|
+
yield lock
|
26
|
+
ensure
|
27
|
+
lock.release
|
28
|
+
end
|
29
|
+
else
|
30
|
+
lock
|
31
|
+
end
|
16
32
|
end
|
17
33
|
|
18
|
-
def
|
19
|
-
|
34
|
+
def utilization
|
35
|
+
lock = limit(cost: 0)
|
36
|
+
|
37
|
+
if lock.capacity == 0
|
38
|
+
1.0
|
39
|
+
else
|
40
|
+
lock.contention.to_f / lock.capacity
|
41
|
+
end
|
42
|
+
rescue Berater::Overloaded
|
43
|
+
1.0
|
20
44
|
end
|
21
45
|
|
22
46
|
def ==(other)
|
@@ -30,7 +54,7 @@ module Berater
|
|
30
54
|
|
31
55
|
def self.new(*)
|
32
56
|
# can only call via subclass
|
33
|
-
raise
|
57
|
+
raise NoMethodError if self == Berater::Limiter
|
34
58
|
|
35
59
|
super
|
36
60
|
end
|
@@ -60,19 +84,25 @@ module Berater
|
|
60
84
|
@capacity = capacity
|
61
85
|
end
|
62
86
|
|
63
|
-
def
|
64
|
-
|
87
|
+
def acquire_lock(capacity, cost)
|
88
|
+
raise NotImplementedError
|
65
89
|
end
|
66
90
|
|
67
|
-
def
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
91
|
+
def cache_key(subkey = nil)
|
92
|
+
instance_key = subkey.nil? ? key : "#{key}:#{subkey}"
|
93
|
+
self.class.cache_key(instance_key)
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.cache_key(key)
|
97
|
+
klass = to_s.split(':')[-1]
|
98
|
+
"Berater:#{klass}:#{key}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.inherited(subclass)
|
102
|
+
# automagically create convenience method
|
103
|
+
name = subclass.to_s.split(':')[-1]
|
104
|
+
Berater.define_singleton_method(name) do |*args, **opts, &block|
|
105
|
+
Berater::Utils.convenience_fn(subclass, *args, **opts, &block)
|
76
106
|
end
|
77
107
|
end
|
78
108
|
|
data/lib/berater/lock.rb
CHANGED
data/lib/berater/rate_limiter.rb
CHANGED
@@ -1,20 +1,19 @@
|
|
1
1
|
module Berater
|
2
2
|
class RateLimiter < Limiter
|
3
3
|
|
4
|
-
class Overrated < Overloaded; end
|
5
|
-
|
6
|
-
attr_accessor :interval
|
7
|
-
|
8
4
|
def initialize(key, capacity, interval, **opts)
|
5
|
+
super(key, capacity, interval, **opts)
|
9
6
|
self.interval = interval
|
10
|
-
|
7
|
+
end
|
8
|
+
|
9
|
+
def interval
|
10
|
+
args[0]
|
11
11
|
end
|
12
12
|
|
13
13
|
private def interval=(interval)
|
14
|
-
@interval = interval
|
15
|
-
@interval_msec = Berater::Utils.to_msec(interval)
|
14
|
+
@interval = Berater::Utils.to_msec(interval)
|
16
15
|
|
17
|
-
unless @
|
16
|
+
unless @interval > 0
|
18
17
|
raise ArgumentError, 'interval must be > 0'
|
19
18
|
end
|
20
19
|
end
|
@@ -26,24 +25,32 @@ module Berater
|
|
26
25
|
local capacity = tonumber(ARGV[2])
|
27
26
|
local interval_msec = tonumber(ARGV[3])
|
28
27
|
local cost = tonumber(ARGV[4])
|
29
|
-
local count = 0
|
30
|
-
local allowed
|
31
|
-
local msec_per_drip = interval_msec / capacity
|
32
|
-
|
33
|
-
-- timestamp of last update
|
34
|
-
local last_ts = tonumber(redis.call('GET', ts_key))
|
35
|
-
|
36
|
-
if last_ts then
|
37
|
-
count = tonumber(redis.call('GET', key)) or 0
|
38
28
|
|
39
|
-
|
40
|
-
|
41
|
-
|
29
|
+
local allowed -- whether lock was acquired
|
30
|
+
local count -- capacity being utilized
|
31
|
+
local msec_per_drip = interval_msec / capacity
|
32
|
+
local state = redis.call('GET', key)
|
33
|
+
|
34
|
+
if state then
|
35
|
+
local last_ts -- timestamp of last update
|
36
|
+
count, last_ts = string.match(state, '([%d.]+);(%w+)')
|
37
|
+
count = tonumber(count)
|
38
|
+
last_ts = tonumber(last_ts, 16)
|
39
|
+
|
40
|
+
-- adjust for time passing, guarding against clock skew
|
41
|
+
if ts > last_ts then
|
42
|
+
local drips = math.floor((ts - last_ts) / msec_per_drip)
|
43
|
+
count = math.max(0, count - drips)
|
44
|
+
else
|
45
|
+
ts = last_ts
|
46
|
+
end
|
47
|
+
else
|
48
|
+
count = 0
|
42
49
|
end
|
43
50
|
|
44
51
|
if cost == 0 then
|
45
|
-
-- just
|
46
|
-
allowed =
|
52
|
+
-- just checking count
|
53
|
+
allowed = true
|
47
54
|
else
|
48
55
|
allowed = (count + cost) <= capacity
|
49
56
|
|
@@ -52,41 +59,34 @@ module Berater
|
|
52
59
|
|
53
60
|
-- time for bucket to empty, in milliseconds
|
54
61
|
local ttl = math.ceil(count * msec_per_drip)
|
62
|
+
ttl = ttl + 100 -- margin of error, for clock skew
|
55
63
|
|
56
|
-
-- update count and last_ts, with
|
57
|
-
|
58
|
-
redis.call('SET',
|
64
|
+
-- update count and last_ts, with expiration
|
65
|
+
state = string.format('%f;%X', count, ts)
|
66
|
+
redis.call('SET', key, state, 'PX', ttl)
|
59
67
|
end
|
60
68
|
end
|
61
69
|
|
62
|
-
return { count, allowed }
|
70
|
+
return { tostring(count), allowed }
|
63
71
|
LUA
|
64
72
|
)
|
65
73
|
|
66
|
-
def
|
67
|
-
capacity ||= @capacity
|
68
|
-
|
74
|
+
protected def acquire_lock(capacity, cost)
|
69
75
|
# timestamp in milliseconds
|
70
76
|
ts = (Time.now.to_f * 10**3).to_i
|
71
77
|
|
72
78
|
count, allowed = LUA_SCRIPT.eval(
|
73
79
|
redis,
|
74
|
-
[ cache_key
|
75
|
-
[ ts, capacity, @
|
80
|
+
[ cache_key ],
|
81
|
+
[ ts, capacity, @interval, cost ]
|
76
82
|
)
|
77
83
|
|
78
|
-
|
84
|
+
count = count.include?('.') ? count.to_f : count.to_i
|
79
85
|
|
80
|
-
|
81
|
-
yield_lock(lock, &block)
|
82
|
-
end
|
86
|
+
raise Overloaded unless allowed
|
83
87
|
|
84
|
-
|
85
|
-
limit(cost: 0) { false }
|
86
|
-
rescue Overrated
|
87
|
-
true
|
88
|
+
Lock.new(capacity, count)
|
88
89
|
end
|
89
|
-
alias overrated? overloaded?
|
90
90
|
|
91
91
|
def to_s
|
92
92
|
msg = if interval.is_a? Numeric
|