berater 0.6.1 → 0.9.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 +29 -15
- data/lib/berater/concurrency_limiter.rb +16 -23
- data/lib/berater/dsl.rb +8 -8
- data/lib/berater/inhibitor.rb +4 -4
- data/lib/berater/limiter.rb +55 -23
- data/lib/berater/limiter_set.rb +66 -0
- data/lib/berater/lock.rb +2 -1
- data/lib/berater/rate_limiter.rb +39 -31
- data/lib/berater/rspec.rb +1 -2
- data/lib/berater/rspec/matchers.rb +11 -31
- data/lib/berater/static_limiter.rb +49 -0
- data/lib/berater/test_mode.rb +27 -23
- data/lib/berater/unlimiter.rb +4 -0
- data/lib/berater/utils.rb +9 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +38 -70
- data/spec/concurrency_limiter_spec.rb +59 -53
- 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_set_spec.rb +173 -0
- data/spec/limiter_spec.rb +125 -10
- data/spec/matchers_spec.rb +21 -85
- data/spec/middleware_spec.rb +110 -0
- 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 +32 -109
- data/spec/unlimiter_spec.rb +11 -5
- metadata +24 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 476b59e3f1e27908f5c8a5097087e6c8c2384531e1284d79b9d89a73fbbb5840
|
4
|
+
data.tar.gz: de50fdcab6ea7dc9520fcf1aefdd07b1f8775c0ce96a30e3f1d45b6ccfb7b00f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2446e64f528a8ef1d37791922d37cddcebdf702f3c5beff2ef7ea9d611ce4cc6d6c40ac42e1ea826987a2eb2ceea3ddf97e420bb7369686b8239b260a1725c55
|
7
|
+
data.tar.gz: 23dccad7e02cc425631ceeb1dfbc6960b94ac92c523d840a991917af02caaf39a7befc5887fe45631f565ddb2eddb4850e557348ab29427d3cbae1db89124c3c
|
data/lib/berater.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
require 'berater/limiter'
|
2
|
+
require 'berater/limiter_set'
|
2
3
|
require 'berater/lock'
|
3
4
|
require 'berater/lua_script'
|
4
5
|
require 'berater/utils'
|
5
6
|
require 'berater/version'
|
7
|
+
require 'meddleware'
|
6
8
|
|
7
9
|
module Berater
|
8
10
|
extend self
|
@@ -15,24 +17,35 @@ module Berater
|
|
15
17
|
yield self
|
16
18
|
end
|
17
19
|
|
18
|
-
def
|
19
|
-
@
|
20
|
+
def limiters
|
21
|
+
@limiters ||= LimiterSet.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def middleware(&block)
|
25
|
+
(@middleware ||= Meddleware.new).tap do
|
26
|
+
@middleware.instance_eval(&block) if block_given?
|
27
|
+
end
|
20
28
|
end
|
21
29
|
|
22
|
-
def new(key, capacity,
|
30
|
+
def new(key, capacity, **opts)
|
31
|
+
args = []
|
32
|
+
|
23
33
|
case capacity
|
24
|
-
when
|
34
|
+
when Float::INFINITY
|
25
35
|
Berater::Unlimiter
|
26
|
-
when
|
36
|
+
when 0
|
27
37
|
Berater::Inhibitor
|
28
38
|
else
|
29
|
-
if interval
|
39
|
+
if opts[:interval]
|
40
|
+
args << opts.delete(:interval)
|
30
41
|
Berater::RateLimiter
|
31
|
-
|
42
|
+
elsif opts[:timeout]
|
32
43
|
Berater::ConcurrencyLimiter
|
44
|
+
else
|
45
|
+
Berater::StaticLimiter
|
33
46
|
end
|
34
47
|
end.yield_self do |klass|
|
35
|
-
args = [ key, capacity,
|
48
|
+
args = [ key, capacity, *args ].compact
|
36
49
|
klass.new(*args, **opts)
|
37
50
|
end
|
38
51
|
end
|
@@ -43,20 +56,21 @@ module Berater
|
|
43
56
|
end
|
44
57
|
end
|
45
58
|
|
59
|
+
def reset
|
60
|
+
@redis = nil
|
61
|
+
limiters.clear
|
62
|
+
middleware.clear
|
63
|
+
end
|
46
64
|
end
|
47
65
|
|
48
66
|
# convenience method
|
49
|
-
def Berater(
|
50
|
-
|
51
|
-
if block_given?
|
52
|
-
limiter.limit(&block)
|
53
|
-
else
|
54
|
-
limiter
|
55
|
-
end
|
67
|
+
def Berater(*args, **opts, &block)
|
68
|
+
Berater::Utils.convenience_fn(Berater, *args, **opts, &block)
|
56
69
|
end
|
57
70
|
|
58
71
|
# load limiters
|
59
72
|
require 'berater/concurrency_limiter'
|
60
73
|
require 'berater/inhibitor'
|
61
74
|
require 'berater/rate_limiter'
|
75
|
+
require 'berater/static_limiter'
|
62
76
|
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)
|
@@ -64,7 +64,7 @@ module Berater
|
|
64
64
|
)
|
65
65
|
|
66
66
|
protected def acquire_lock(capacity, cost)
|
67
|
-
# fractional
|
67
|
+
# round fractional capacity and cost
|
68
68
|
capacity = capacity.to_i
|
69
69
|
cost = cost.ceil
|
70
70
|
|
@@ -73,26 +73,19 @@ module Berater
|
|
73
73
|
|
74
74
|
count, *lock_ids = LUA_SCRIPT.eval(
|
75
75
|
redis,
|
76
|
-
[ cache_key
|
77
|
-
[ capacity, ts, @
|
76
|
+
[ cache_key, self.class.cache_key('lock_id') ],
|
77
|
+
[ capacity, ts, @timeout, cost ]
|
78
78
|
)
|
79
79
|
|
80
|
-
raise
|
80
|
+
raise Overloaded if lock_ids.empty?
|
81
81
|
|
82
82
|
release_fn = if cost > 0
|
83
|
-
proc {
|
83
|
+
proc { redis.zrem(cache_key, lock_ids) }
|
84
84
|
end
|
85
85
|
|
86
86
|
Lock.new(capacity, count, release_fn)
|
87
87
|
end
|
88
88
|
|
89
|
-
alias incapacitated? overloaded?
|
90
|
-
|
91
|
-
private def release(lock_ids)
|
92
|
-
res = redis.zrem(cache_key(key), lock_ids)
|
93
|
-
res == true || res == lock_ids.count # depending on which version of Redis
|
94
|
-
end
|
95
|
-
|
96
89
|
def to_s
|
97
90
|
"#<#{self.class}(#{key}: #{capacity} at a time)>"
|
98
91
|
end
|
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,16 +1,16 @@
|
|
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
|
-
|
8
|
+
def to_s
|
9
|
+
"#<#{self.class}>"
|
10
|
+
end
|
11
11
|
|
12
12
|
protected def acquire_lock(*)
|
13
|
-
raise
|
13
|
+
raise Overloaded
|
14
14
|
end
|
15
15
|
|
16
16
|
end
|
data/lib/berater/limiter.rb
CHANGED
@@ -9,17 +9,12 @@ module Berater
|
|
9
9
|
|
10
10
|
def limit(capacity: nil, cost: 1, &block)
|
11
11
|
capacity ||= @capacity
|
12
|
+
lock = nil
|
12
13
|
|
13
|
-
|
14
|
-
|
15
|
-
end
|
16
|
-
|
17
|
-
unless cost.is_a?(Numeric) && cost >= 0
|
18
|
-
raise ArgumentError, "invalid cost: #{cost}"
|
14
|
+
Berater.middleware.call(self, capacity: capacity, cost: cost) do |limiter, **opts|
|
15
|
+
lock = limiter.inner_limit(**opts)
|
19
16
|
end
|
20
17
|
|
21
|
-
lock = acquire_lock(capacity, cost)
|
22
|
-
|
23
18
|
if block_given?
|
24
19
|
begin
|
25
20
|
yield lock
|
@@ -31,14 +26,33 @@ module Berater
|
|
31
26
|
end
|
32
27
|
end
|
33
28
|
|
34
|
-
def
|
35
|
-
|
36
|
-
|
37
|
-
|
29
|
+
protected def inner_limit(capacity:, cost:)
|
30
|
+
unless capacity.is_a?(Numeric) && capacity >= 0
|
31
|
+
raise ArgumentError, "invalid capacity: #{capacity}"
|
32
|
+
end
|
33
|
+
|
34
|
+
unless cost.is_a?(Numeric) && cost >= 0 && cost < Float::INFINITY
|
35
|
+
raise ArgumentError, "invalid cost: #{cost}"
|
36
|
+
end
|
37
|
+
|
38
|
+
acquire_lock(capacity, cost)
|
39
|
+
rescue NoMethodError => e
|
40
|
+
raise unless e.message.include?("undefined method `evalsha' for")
|
41
|
+
|
42
|
+
# repackage error so it's easier to understand
|
43
|
+
raise RuntimeError, "invalid redis connection: #{redis}"
|
38
44
|
end
|
39
45
|
|
40
|
-
def
|
41
|
-
|
46
|
+
def utilization
|
47
|
+
lock = limit(cost: 0)
|
48
|
+
|
49
|
+
if lock.capacity == 0
|
50
|
+
1.0
|
51
|
+
else
|
52
|
+
lock.contention.to_f / lock.capacity
|
53
|
+
end
|
54
|
+
rescue Berater::Overloaded
|
55
|
+
1.0
|
42
56
|
end
|
43
57
|
|
44
58
|
def ==(other)
|
@@ -50,13 +64,6 @@ module Berater
|
|
50
64
|
self.redis.connection == other.redis.connection
|
51
65
|
end
|
52
66
|
|
53
|
-
def self.new(*)
|
54
|
-
# can only call via subclass
|
55
|
-
raise NotImplementedError if self == Berater::Limiter
|
56
|
-
|
57
|
-
super
|
58
|
-
end
|
59
|
-
|
60
67
|
protected
|
61
68
|
|
62
69
|
attr_reader :args
|
@@ -86,8 +93,33 @@ module Berater
|
|
86
93
|
raise NotImplementedError
|
87
94
|
end
|
88
95
|
|
89
|
-
def cache_key(
|
90
|
-
"#{
|
96
|
+
def cache_key(subkey = nil)
|
97
|
+
instance_key = subkey.nil? ? key : "#{key}:#{subkey}"
|
98
|
+
self.class.cache_key(instance_key)
|
99
|
+
end
|
100
|
+
|
101
|
+
class << self
|
102
|
+
def new(*)
|
103
|
+
# can only call via subclass
|
104
|
+
raise NoMethodError if self == Berater::Limiter
|
105
|
+
|
106
|
+
super
|
107
|
+
end
|
108
|
+
|
109
|
+
def cache_key(key)
|
110
|
+
klass = to_s.split(':')[-1]
|
111
|
+
"Berater:#{klass}:#{key}"
|
112
|
+
end
|
113
|
+
|
114
|
+
protected
|
115
|
+
|
116
|
+
def inherited(subclass)
|
117
|
+
# automagically create convenience method
|
118
|
+
name = subclass.to_s.split(':')[-1]
|
119
|
+
Berater.define_singleton_method(name) do |*args, **opts, &block|
|
120
|
+
Berater::Utils.convenience_fn(subclass, *args, **opts, &block)
|
121
|
+
end
|
122
|
+
end
|
91
123
|
end
|
92
124
|
|
93
125
|
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Berater
|
2
|
+
private
|
3
|
+
|
4
|
+
class LimiterSet
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@limiters = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def each(&block)
|
12
|
+
@limiters.each_value(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
def <<(limiter)
|
16
|
+
key = limiter.key if limiter.respond_to?(:key)
|
17
|
+
send(:[]=, key, limiter)
|
18
|
+
end
|
19
|
+
|
20
|
+
def []=(key, limiter)
|
21
|
+
unless limiter.is_a? Berater::Limiter
|
22
|
+
raise ArgumentError, "expected Berater::Limiter, found: #{limiter}"
|
23
|
+
end
|
24
|
+
|
25
|
+
@limiters[key] = limiter
|
26
|
+
end
|
27
|
+
|
28
|
+
def [](key)
|
29
|
+
@limiters[key]
|
30
|
+
end
|
31
|
+
|
32
|
+
def fetch(key, val = default = true, &block)
|
33
|
+
args = default ? [ key ] : [ key, val ]
|
34
|
+
@limiters.fetch(*args, &block)
|
35
|
+
end
|
36
|
+
|
37
|
+
def include?(key)
|
38
|
+
if key.is_a? Berater::Limiter
|
39
|
+
@limiters.value?(key)
|
40
|
+
else
|
41
|
+
@limiters.key?(key)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def clear
|
46
|
+
@limiters.clear
|
47
|
+
end
|
48
|
+
|
49
|
+
def count
|
50
|
+
@limiters.count
|
51
|
+
end
|
52
|
+
|
53
|
+
def delete(key)
|
54
|
+
if key.is_a? Berater::Limiter
|
55
|
+
@limiters.delete(key.key)
|
56
|
+
else
|
57
|
+
@limiters.delete(key)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
alias remove delete
|
61
|
+
|
62
|
+
def empty?
|
63
|
+
@limiters.empty?
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
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,14 +59,15 @@ 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
|
|
@@ -69,17 +77,17 @@ module Berater
|
|
69
77
|
|
70
78
|
count, allowed = LUA_SCRIPT.eval(
|
71
79
|
redis,
|
72
|
-
[ cache_key
|
73
|
-
[ ts, capacity, @
|
80
|
+
[ cache_key ],
|
81
|
+
[ ts, capacity, @interval, cost ]
|
74
82
|
)
|
75
83
|
|
76
|
-
|
84
|
+
count = count.include?('.') ? count.to_f : count.to_i
|
85
|
+
|
86
|
+
raise Overloaded unless allowed
|
77
87
|
|
78
88
|
Lock.new(capacity, count)
|
79
89
|
end
|
80
90
|
|
81
|
-
alias overrated? overloaded?
|
82
|
-
|
83
91
|
def to_s
|
84
92
|
msg = if interval.is_a? Numeric
|
85
93
|
if interval == 1
|