berater 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/lib/berater.rb +10 -7
- data/lib/berater/concurrency_limiter.rb +18 -27
- data/lib/berater/dsl.rb +8 -8
- data/lib/berater/inhibitor.rb +2 -9
- data/lib/berater/limiter.rb +44 -20
- data/lib/berater/lock.rb +3 -4
- data/lib/berater/rate_limiter.rb +43 -40
- data/lib/berater/rspec.rb +2 -2
- data/lib/berater/rspec/matchers.rb +37 -38
- data/lib/berater/test_mode.rb +13 -29
- data/lib/berater/unlimiter.rb +6 -4
- data/lib/berater/utils.rb +4 -4
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +9 -9
- data/spec/concurrency_limiter_spec.rb +82 -55
- data/spec/dsl_refinement_spec.rb +0 -12
- data/spec/dsl_spec.rb +5 -17
- data/spec/limiter_spec.rb +39 -3
- data/spec/matchers_spec.rb +64 -72
- data/spec/rate_limiter_spec.rb +134 -44
- data/spec/riddle_spec.rb +6 -2
- data/spec/test_mode_spec.rb +40 -111
- data/spec/utils_spec.rb +30 -30
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c12ee8f042d6ebb193f114cd093585c14a4a28b802d7a90f79ba5ce41bbd82e6
|
4
|
+
data.tar.gz: d12ebb1107ea0b24208bfb3ff663a98097cd3367c3972418a139cae9b501e4a1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 491cd616903af917866a41f8b2a4bcc79a469a879f57f1872ce8f4a42c00f1309979c04c142c0083246791aef6cd3847c21197dee0b611ff326ca833cc44ae52
|
7
|
+
data.tar.gz: 7ba3fd90f8b439e4ad2fe1d5a8008c69978a90c206bc6694e9f470796019d2ac849dddaa85039b9c730e3e20edfec3411cc1275598866c63207c209e688f2a3a
|
data/lib/berater.rb
CHANGED
@@ -19,20 +19,23 @@ 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
|
else
|
32
35
|
Berater::ConcurrencyLimiter
|
33
36
|
end
|
34
37
|
end.yield_self do |klass|
|
35
|
-
args = [ key, capacity,
|
38
|
+
args = [ key, capacity, *args ].compact
|
36
39
|
klass.new(*args, **opts)
|
37
40
|
end
|
38
41
|
end
|
@@ -46,8 +49,8 @@ module Berater
|
|
46
49
|
end
|
47
50
|
|
48
51
|
# convenience method
|
49
|
-
def Berater(key, capacity,
|
50
|
-
limiter = Berater.new(key, capacity,
|
52
|
+
def Berater(key, capacity, **opts, &block)
|
53
|
+
limiter = Berater.new(key, capacity, **opts)
|
51
54
|
if block_given?
|
52
55
|
limiter.limit(&block)
|
53
56
|
else
|
@@ -1,20 +1,21 @@
|
|
1
1
|
module Berater
|
2
2
|
class ConcurrencyLimiter < Limiter
|
3
3
|
|
4
|
-
class Incapacitated < Overloaded; end
|
5
|
-
|
6
4
|
attr_reader :timeout
|
7
5
|
|
8
6
|
def initialize(key, capacity, **opts)
|
9
7
|
super(key, capacity, **opts)
|
10
8
|
|
9
|
+
# round fractional capacity
|
10
|
+
self.capacity = capacity.to_i
|
11
|
+
|
11
12
|
self.timeout = opts[:timeout] || 0
|
12
13
|
end
|
13
14
|
|
14
15
|
private def timeout=(timeout)
|
15
16
|
@timeout = timeout
|
16
17
|
timeout = 0 if timeout == Float::INFINITY
|
17
|
-
@
|
18
|
+
@timeout_msec = Berater::Utils.to_msec(timeout)
|
18
19
|
end
|
19
20
|
|
20
21
|
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
@@ -28,17 +29,15 @@ module Berater
|
|
28
29
|
|
29
30
|
-- purge stale hosts
|
30
31
|
if ttl > 0 then
|
31
|
-
redis.call('ZREMRANGEBYSCORE', key,
|
32
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, ts - ttl)
|
32
33
|
end
|
33
34
|
|
34
35
|
-- check capacity
|
35
36
|
local count = redis.call('ZCARD', key)
|
36
37
|
|
37
38
|
if cost == 0 then
|
38
|
-
-- just
|
39
|
-
|
40
|
-
table.insert(lock_ids, true)
|
41
|
-
end
|
39
|
+
-- just checking count
|
40
|
+
table.insert(lock_ids, true)
|
42
41
|
elseif (count + cost) <= capacity then
|
43
42
|
-- grab locks, one per cost
|
44
43
|
local lock_id = redis.call('INCRBY', lock_key, cost)
|
@@ -63,36 +62,28 @@ module Berater
|
|
63
62
|
LUA
|
64
63
|
)
|
65
64
|
|
66
|
-
def
|
67
|
-
capacity
|
68
|
-
|
65
|
+
protected def acquire_lock(capacity, cost)
|
66
|
+
# round fractional capacity and cost
|
67
|
+
capacity = capacity.to_i
|
68
|
+
cost = cost.ceil
|
69
69
|
|
70
|
-
# timestamp in
|
71
|
-
ts = (Time.now.to_f * 10**
|
70
|
+
# timestamp in milliseconds
|
71
|
+
ts = (Time.now.to_f * 10**3).to_i
|
72
72
|
|
73
73
|
count, *lock_ids = LUA_SCRIPT.eval(
|
74
74
|
redis,
|
75
75
|
[ cache_key(key), cache_key('lock_id') ],
|
76
|
-
[ capacity, ts, @
|
76
|
+
[ capacity, ts, @timeout_msec, cost ]
|
77
77
|
)
|
78
78
|
|
79
|
-
raise
|
79
|
+
raise Overloaded if lock_ids.empty?
|
80
80
|
|
81
|
-
if cost
|
82
|
-
|
83
|
-
else
|
84
|
-
lock = Lock.new(self, lock_ids[0], count, -> { release(lock_ids) })
|
81
|
+
release_fn = if cost > 0
|
82
|
+
proc { release(lock_ids) }
|
85
83
|
end
|
86
84
|
|
87
|
-
|
88
|
-
end
|
89
|
-
|
90
|
-
def overloaded?
|
91
|
-
limit(cost: 0) { false }
|
92
|
-
rescue Overloaded
|
93
|
-
true
|
85
|
+
Lock.new(capacity, count, release_fn)
|
94
86
|
end
|
95
|
-
alias incapacitated? overloaded?
|
96
87
|
|
97
88
|
private def release(lock_ids)
|
98
89
|
res = redis.zrem(cache_key(key), lock_ids)
|
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,13 @@
|
|
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
|
-
raise
|
12
|
-
end
|
13
|
-
|
14
|
-
def overloaded?
|
15
|
-
true
|
8
|
+
protected def acquire_lock(*)
|
9
|
+
raise Overloaded
|
16
10
|
end
|
17
|
-
alias inhibited? overloaded?
|
18
11
|
|
19
12
|
end
|
20
13
|
end
|
data/lib/berater/limiter.rb
CHANGED
@@ -7,12 +7,40 @@ module Berater
|
|
7
7
|
options[:redis] || Berater.redis
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit
|
11
|
-
|
10
|
+
def limit(capacity: nil, cost: 1, &block)
|
11
|
+
capacity ||= @capacity
|
12
|
+
|
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
|
12
32
|
end
|
13
33
|
|
14
|
-
def
|
15
|
-
|
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
|
16
44
|
end
|
17
45
|
|
18
46
|
def to_s
|
@@ -30,7 +58,7 @@ module Berater
|
|
30
58
|
|
31
59
|
def self.new(*)
|
32
60
|
# can only call via subclass
|
33
|
-
raise
|
61
|
+
raise NoMethodError if self == Berater::Limiter
|
34
62
|
|
35
63
|
super
|
36
64
|
end
|
@@ -47,29 +75,25 @@ module Berater
|
|
47
75
|
end
|
48
76
|
|
49
77
|
def capacity=(capacity)
|
50
|
-
unless capacity.is_a?(
|
51
|
-
raise ArgumentError, "expected
|
78
|
+
unless capacity.is_a?(Numeric)
|
79
|
+
raise ArgumentError, "expected Numeric, found #{capacity.class}"
|
52
80
|
end
|
53
81
|
|
54
|
-
|
82
|
+
if capacity == Float::INFINITY
|
83
|
+
raise ArgumentError, 'infinite capacity not supported, use Unlimiter'
|
84
|
+
end
|
85
|
+
|
86
|
+
raise ArgumentError, 'capacity must be >= 0' unless capacity >= 0
|
55
87
|
|
56
88
|
@capacity = capacity
|
57
89
|
end
|
58
90
|
|
59
|
-
def
|
60
|
-
|
91
|
+
def acquire_lock(capacity, cost)
|
92
|
+
raise NotImplementedError
|
61
93
|
end
|
62
94
|
|
63
|
-
def
|
64
|
-
|
65
|
-
begin
|
66
|
-
yield lock
|
67
|
-
ensure
|
68
|
-
lock.release
|
69
|
-
end
|
70
|
-
else
|
71
|
-
lock
|
72
|
-
end
|
95
|
+
def cache_key(key)
|
96
|
+
"#{self.class}:#{key}"
|
73
97
|
end
|
74
98
|
|
75
99
|
end
|
data/lib/berater/lock.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
module Berater
|
2
2
|
class Lock
|
3
3
|
|
4
|
-
attr_reader :
|
4
|
+
attr_reader :capacity, :contention
|
5
5
|
|
6
|
-
def initialize(
|
7
|
-
@
|
8
|
-
@id = id
|
6
|
+
def initialize(capacity, contention, release_fn = nil)
|
7
|
+
@capacity = capacity
|
9
8
|
@contention = contention
|
10
9
|
@locked_at = Time.now
|
11
10
|
@release_fn = release_fn
|
data/lib/berater/rate_limiter.rb
CHANGED
@@ -1,18 +1,20 @@
|
|
1
1
|
module Berater
|
2
2
|
class RateLimiter < Limiter
|
3
3
|
|
4
|
-
class Overrated < Overloaded; end
|
5
|
-
|
6
4
|
attr_accessor :interval
|
7
5
|
|
8
6
|
def initialize(key, capacity, interval, **opts)
|
9
7
|
self.interval = interval
|
10
|
-
super(key, capacity, @
|
8
|
+
super(key, capacity, @interval_msec, **opts)
|
11
9
|
end
|
12
10
|
|
13
11
|
private def interval=(interval)
|
14
12
|
@interval = interval
|
15
|
-
@
|
13
|
+
@interval_msec = Berater::Utils.to_msec(interval)
|
14
|
+
|
15
|
+
unless @interval_msec > 0
|
16
|
+
raise ArgumentError, 'interval must be > 0'
|
17
|
+
end
|
16
18
|
end
|
17
19
|
|
18
20
|
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
@@ -20,26 +22,34 @@ module Berater
|
|
20
22
|
local ts_key = KEYS[2]
|
21
23
|
local ts = tonumber(ARGV[1])
|
22
24
|
local capacity = tonumber(ARGV[2])
|
23
|
-
local
|
25
|
+
local interval_msec = tonumber(ARGV[3])
|
24
26
|
local cost = tonumber(ARGV[4])
|
25
|
-
local count = 0
|
26
|
-
local allowed
|
27
|
-
local usec_per_drip = interval_usec / capacity
|
28
|
-
|
29
|
-
-- timestamp of last update
|
30
|
-
local last_ts = tonumber(redis.call('GET', ts_key))
|
31
|
-
|
32
|
-
if last_ts then
|
33
|
-
count = tonumber(redis.call('GET', key)) or 0
|
34
27
|
|
35
|
-
|
36
|
-
|
37
|
-
|
28
|
+
local allowed -- whether lock was acquired
|
29
|
+
local count -- capacity being utilized
|
30
|
+
local msec_per_drip = interval_msec / capacity
|
31
|
+
local state = redis.call('GET', key)
|
32
|
+
|
33
|
+
if state then
|
34
|
+
local last_ts -- timestamp of last update
|
35
|
+
count, last_ts = string.match(state, '([%d.]+);(%w+)')
|
36
|
+
count = tonumber(count)
|
37
|
+
last_ts = tonumber(last_ts, 16)
|
38
|
+
|
39
|
+
-- adjust for time passing, guarding against clock skew
|
40
|
+
if ts > last_ts then
|
41
|
+
local drips = math.floor((ts - last_ts) / msec_per_drip)
|
42
|
+
count = math.max(0, count - drips)
|
43
|
+
else
|
44
|
+
ts = last_ts
|
45
|
+
end
|
46
|
+
else
|
47
|
+
count = 0
|
38
48
|
end
|
39
49
|
|
40
50
|
if cost == 0 then
|
41
|
-
-- just
|
42
|
-
allowed =
|
51
|
+
-- just checking count
|
52
|
+
allowed = true
|
43
53
|
else
|
44
54
|
allowed = (count + cost) <= capacity
|
45
55
|
|
@@ -47,42 +57,35 @@ module Berater
|
|
47
57
|
count = count + cost
|
48
58
|
|
49
59
|
-- time for bucket to empty, in milliseconds
|
50
|
-
local ttl = math.ceil(
|
60
|
+
local ttl = math.ceil(count * msec_per_drip)
|
61
|
+
ttl = ttl + 100 -- margin of error, for clock skew
|
51
62
|
|
52
|
-
-- update count and last_ts, with
|
53
|
-
|
54
|
-
redis.call('SET',
|
63
|
+
-- update count and last_ts, with expiration
|
64
|
+
state = string.format('%f;%X', count, ts)
|
65
|
+
redis.call('SET', key, state, 'PX', ttl)
|
55
66
|
end
|
56
67
|
end
|
57
68
|
|
58
|
-
return { count, allowed }
|
69
|
+
return { tostring(count), allowed }
|
59
70
|
LUA
|
60
71
|
)
|
61
72
|
|
62
|
-
def
|
63
|
-
|
64
|
-
|
65
|
-
# timestamp in microseconds
|
66
|
-
ts = (Time.now.to_f * 10**6).to_i
|
73
|
+
protected def acquire_lock(capacity, cost)
|
74
|
+
# timestamp in milliseconds
|
75
|
+
ts = (Time.now.to_f * 10**3).to_i
|
67
76
|
|
68
77
|
count, allowed = LUA_SCRIPT.eval(
|
69
78
|
redis,
|
70
|
-
[ cache_key(key)
|
71
|
-
[ ts, capacity, @
|
79
|
+
[ cache_key(key) ],
|
80
|
+
[ ts, capacity, @interval_msec, cost ]
|
72
81
|
)
|
73
82
|
|
74
|
-
|
83
|
+
count = count.include?('.') ? count.to_f : count.to_i
|
75
84
|
|
76
|
-
|
77
|
-
yield_lock(lock, &block)
|
78
|
-
end
|
85
|
+
raise Overloaded unless allowed
|
79
86
|
|
80
|
-
|
81
|
-
limit(cost: 0) { false }
|
82
|
-
rescue Overrated
|
83
|
-
true
|
87
|
+
Lock.new(capacity, count)
|
84
88
|
end
|
85
|
-
alias overrated? overloaded?
|
86
89
|
|
87
90
|
def to_s
|
88
91
|
msg = if interval.is_a? Numeric
|