berater 0.4.0 → 0.7.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 -37
- data/lib/berater/concurrency_limiter.rb +53 -48
- data/lib/berater/dsl.rb +21 -10
- data/lib/berater/inhibitor.rb +3 -5
- data/lib/berater/limiter.rb +74 -4
- data/lib/berater/lock.rb +4 -14
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +62 -90
- data/lib/berater/rspec.rb +3 -1
- data/lib/berater/rspec/matchers.rb +43 -42
- data/lib/berater/test_mode.rb +14 -21
- data/lib/berater/unlimiter.rb +8 -12
- data/lib/berater/utils.rb +46 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +35 -72
- data/spec/concurrency_limiter_spec.rb +168 -66
- data/spec/dsl_refinement_spec.rb +34 -0
- data/spec/dsl_spec.rb +60 -0
- data/spec/inhibitor_spec.rb +2 -4
- data/spec/limiter_spec.rb +107 -0
- data/spec/lua_script_spec.rb +97 -0
- data/spec/matchers_spec.rb +64 -60
- data/spec/rate_limiter_spec.rb +183 -96
- data/spec/riddle_spec.rb +106 -0
- data/spec/test_mode_spec.rb +83 -124
- data/spec/unlimiter_spec.rb +3 -9
- data/spec/utils_spec.rb +78 -0
- metadata +31 -3
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
module Berater
|
4
|
+
class LuaScript
|
5
|
+
|
6
|
+
attr_reader :source
|
7
|
+
|
8
|
+
def initialize(source)
|
9
|
+
@source = source
|
10
|
+
end
|
11
|
+
|
12
|
+
def sha
|
13
|
+
@sha ||= Digest::SHA1.hexdigest(minify)
|
14
|
+
end
|
15
|
+
|
16
|
+
def eval(redis, *args)
|
17
|
+
redis.evalsha(sha, *args)
|
18
|
+
rescue Redis::CommandError => e
|
19
|
+
raise unless e.message.include?('NOSCRIPT')
|
20
|
+
|
21
|
+
# fall back to regular eval, which will trigger
|
22
|
+
# script to be cached for next time
|
23
|
+
redis.eval(minify, *args)
|
24
|
+
end
|
25
|
+
|
26
|
+
def load(redis)
|
27
|
+
redis.script(:load, minify).tap do |sha|
|
28
|
+
unless sha == self.sha
|
29
|
+
raise "unexpected script SHA: expected #{self.sha}, got #{sha}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def loaded?(redis)
|
35
|
+
redis.script(:exists, sha)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
source
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def minify
|
45
|
+
# trim comments (whole line and partial)
|
46
|
+
# and whitespace (prefix and empty lines)
|
47
|
+
@minify ||= source.gsub(/^\s*--.*\n|\s*--.*|^\s*|^$\n/, '').chomp
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
def LuaScript(source)
|
53
|
+
LuaScript.new(source)
|
54
|
+
end
|
55
|
+
end
|
data/lib/berater/rate_limiter.rb
CHANGED
@@ -1,132 +1,104 @@
|
|
1
1
|
module Berater
|
2
2
|
class RateLimiter < Limiter
|
3
3
|
|
4
|
-
|
4
|
+
attr_accessor :interval
|
5
5
|
|
6
|
-
|
7
|
-
|
8
|
-
def initialize(key, count, interval, **opts)
|
9
|
-
super(key, **opts)
|
10
|
-
|
11
|
-
self.count = count
|
6
|
+
def initialize(key, capacity, interval, **opts)
|
12
7
|
self.interval = interval
|
13
|
-
|
14
|
-
|
15
|
-
private def count=(count)
|
16
|
-
unless count.is_a? Integer
|
17
|
-
raise ArgumentError, "expected Integer, found #{count.class}"
|
18
|
-
end
|
19
|
-
|
20
|
-
raise ArgumentError, "count must be >= 0" unless count >= 0
|
21
|
-
|
22
|
-
@count = count
|
8
|
+
super(key, capacity, @interval_msec, **opts)
|
23
9
|
end
|
24
10
|
|
25
11
|
private def interval=(interval)
|
26
|
-
@interval = interval
|
27
|
-
|
28
|
-
case @interval
|
29
|
-
when Integer
|
30
|
-
raise ArgumentError, "interval must be >= 0" unless @interval >= 0
|
31
|
-
@interval_sec = @interval
|
32
|
-
when String
|
33
|
-
@interval = @interval.to_sym
|
34
|
-
when Symbol
|
35
|
-
else
|
36
|
-
raise ArgumentError, "unexpected interval type: #{interval.class}"
|
37
|
-
end
|
12
|
+
@interval = interval
|
13
|
+
@interval_msec = Berater::Utils.to_msec(interval)
|
38
14
|
|
39
|
-
|
40
|
-
|
41
|
-
when :sec, :second, :seconds
|
42
|
-
@interval = :second
|
43
|
-
@interval_sec = 1
|
44
|
-
when :min, :minute, :minutes
|
45
|
-
@interval = :minute
|
46
|
-
@interval_sec = 60
|
47
|
-
when :hour, :hours
|
48
|
-
@interval = :hour
|
49
|
-
@interval_sec = 60 * 60
|
50
|
-
else
|
51
|
-
raise ArgumentError, "unexpected interval value: #{interval}"
|
52
|
-
end
|
15
|
+
unless @interval_msec > 0
|
16
|
+
raise ArgumentError, 'interval must be > 0'
|
53
17
|
end
|
54
18
|
end
|
55
19
|
|
56
|
-
LUA_SCRIPT = <<~LUA
|
20
|
+
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
57
21
|
local key = KEYS[1]
|
58
22
|
local ts_key = KEYS[2]
|
59
23
|
local ts = tonumber(ARGV[1])
|
60
24
|
local capacity = tonumber(ARGV[2])
|
61
|
-
local
|
62
|
-
local
|
63
|
-
|
64
|
-
--
|
65
|
-
local
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
--
|
71
|
-
|
72
|
-
count =
|
25
|
+
local interval_msec = tonumber(ARGV[3])
|
26
|
+
local cost = tonumber(ARGV[4])
|
27
|
+
|
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
|
73
48
|
end
|
74
49
|
|
75
|
-
|
50
|
+
if cost == 0 then
|
51
|
+
-- just checking count
|
52
|
+
allowed = true
|
53
|
+
else
|
54
|
+
allowed = (count + cost) <= capacity
|
76
55
|
|
77
|
-
|
78
|
-
|
56
|
+
if allowed then
|
57
|
+
count = count + cost
|
79
58
|
|
80
|
-
|
81
|
-
|
59
|
+
-- time for bucket to empty, in milliseconds
|
60
|
+
local ttl = math.ceil(count * msec_per_drip)
|
61
|
+
ttl = ttl + 100 -- margin of error, for clock skew
|
82
62
|
|
83
|
-
|
84
|
-
|
85
|
-
|
63
|
+
-- update count and last_ts, with expiration
|
64
|
+
state = string.format('%f;%X', count, ts)
|
65
|
+
redis.call('SET', key, state, 'PX', ttl)
|
66
|
+
end
|
86
67
|
end
|
87
68
|
|
88
|
-
return { count, allowed }
|
69
|
+
return { tostring(count), allowed }
|
89
70
|
LUA
|
71
|
+
)
|
90
72
|
|
91
|
-
def
|
92
|
-
|
93
|
-
|
94
|
-
# timestamp in microseconds
|
95
|
-
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
|
96
76
|
|
97
|
-
count, allowed =
|
98
|
-
|
99
|
-
[ cache_key(key)
|
100
|
-
[ ts, @
|
77
|
+
count, allowed = LUA_SCRIPT.eval(
|
78
|
+
redis,
|
79
|
+
[ cache_key(key) ],
|
80
|
+
[ ts, capacity, @interval_msec, cost ]
|
101
81
|
)
|
102
82
|
|
103
|
-
|
83
|
+
count = count.include?('.') ? count.to_f : count.to_i
|
104
84
|
|
105
|
-
|
85
|
+
raise Overloaded unless allowed
|
106
86
|
|
107
|
-
|
108
|
-
begin
|
109
|
-
yield lock
|
110
|
-
ensure
|
111
|
-
lock.release
|
112
|
-
end
|
113
|
-
else
|
114
|
-
lock
|
115
|
-
end
|
87
|
+
Lock.new(capacity, count)
|
116
88
|
end
|
117
89
|
|
118
90
|
def to_s
|
119
|
-
msg = if
|
120
|
-
if
|
91
|
+
msg = if interval.is_a? Numeric
|
92
|
+
if interval == 1
|
121
93
|
"every second"
|
122
94
|
else
|
123
|
-
"every #{
|
95
|
+
"every #{interval} seconds"
|
124
96
|
end
|
125
97
|
else
|
126
|
-
"per #{
|
98
|
+
"per #{interval}"
|
127
99
|
end
|
128
100
|
|
129
|
-
"#<#{self.class}(#{key}: #{
|
101
|
+
"#<#{self.class}(#{key}: #{capacity} #{msg})>"
|
130
102
|
end
|
131
103
|
|
132
104
|
end
|
data/lib/berater/rspec.rb
CHANGED
@@ -4,9 +4,11 @@ require 'berater/test_mode'
|
|
4
4
|
require 'rspec'
|
5
5
|
|
6
6
|
RSpec.configure do |config|
|
7
|
-
config.include(
|
7
|
+
config.include(Berater::Matchers)
|
8
8
|
|
9
9
|
config.after do
|
10
10
|
Berater.expunge rescue nil
|
11
|
+
Berater.redis.script(:flush) rescue nil
|
12
|
+
Berater.reset
|
11
13
|
end
|
12
14
|
end
|
@@ -1,60 +1,61 @@
|
|
1
|
-
module
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
def supports_block_expectations?
|
8
|
-
true
|
9
|
-
end
|
1
|
+
module Berater
|
2
|
+
module Matchers
|
3
|
+
class Overloaded
|
4
|
+
def supports_block_expectations?
|
5
|
+
true
|
6
|
+
end
|
10
7
|
|
11
|
-
|
12
|
-
begin
|
8
|
+
def matches?(obj)
|
13
9
|
case obj
|
14
10
|
when Proc
|
15
|
-
# eg. expect { ... }.to
|
11
|
+
# eg. expect { ... }.to be_overloaded
|
16
12
|
res = obj.call
|
17
13
|
|
18
14
|
if res.is_a? Berater::Limiter
|
19
|
-
# eg. expect { Berater.new(...) }.to
|
20
|
-
res
|
15
|
+
# eg. expect { Berater.new(...) }.to be_overloaded
|
16
|
+
@limiter = res
|
17
|
+
@limiter.utilization >= 1
|
18
|
+
else
|
19
|
+
# eg. expect { Berater(...) }.to be_overloaded
|
20
|
+
# eg. expect { limiter.limit }.to be_overloaded
|
21
|
+
false
|
21
22
|
end
|
22
23
|
when Berater::Limiter
|
23
|
-
# eg. expect(Berater.new(...)).to
|
24
|
-
obj
|
24
|
+
# eg. expect(Berater.new(...)).to be_overloaded
|
25
|
+
@limiter = obj
|
26
|
+
@limiter.utilization >= 1
|
25
27
|
end
|
26
|
-
|
27
|
-
false
|
28
|
-
rescue @type
|
28
|
+
rescue Berater::Overloaded
|
29
29
|
true
|
30
30
|
end
|
31
|
-
end
|
32
31
|
|
33
|
-
|
34
|
-
|
32
|
+
def description
|
33
|
+
if @limiter
|
34
|
+
"be overloaded"
|
35
|
+
else
|
36
|
+
"raise #{Berater::Overloaded}"
|
37
|
+
end
|
38
|
+
end
|
35
39
|
|
36
|
-
|
37
|
-
|
38
|
-
|
40
|
+
def failure_message
|
41
|
+
if @limiter
|
42
|
+
"expected to be overloaded"
|
43
|
+
else
|
44
|
+
"expected #{Berater::Overloaded} to be raised"
|
45
|
+
end
|
46
|
+
end
|
39
47
|
|
40
|
-
|
41
|
-
|
48
|
+
def failure_message_when_negated
|
49
|
+
if @limiter
|
50
|
+
"expected not to be overloaded"
|
51
|
+
else
|
52
|
+
"did not expect #{Berater::Overloaded} to be raised"
|
53
|
+
end
|
54
|
+
end
|
42
55
|
end
|
43
|
-
end
|
44
|
-
|
45
|
-
def be_overloaded
|
46
|
-
Overloaded.new(Berater::Overloaded)
|
47
|
-
end
|
48
56
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
def be_incapacitated
|
54
|
-
Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
|
55
|
-
end
|
56
|
-
|
57
|
-
def be_inhibited
|
58
|
-
Overloaded.new(Berater::Inhibitor::Inhibited)
|
57
|
+
def be_overloaded
|
58
|
+
Overloaded.new
|
59
|
+
end
|
59
60
|
end
|
60
61
|
end
|
data/lib/berater/test_mode.rb
CHANGED
@@ -13,31 +13,24 @@ module Berater
|
|
13
13
|
@test_mode = mode
|
14
14
|
end
|
15
15
|
|
16
|
-
|
17
|
-
def
|
18
|
-
|
19
|
-
|
20
|
-
# chose a stub class with desired behavior
|
21
|
-
stub_klass = case Berater.test_mode
|
16
|
+
module TestMode
|
17
|
+
def acquire_lock(*)
|
18
|
+
case Berater.test_mode
|
22
19
|
when :pass
|
23
|
-
|
20
|
+
Lock.new(Float::INFINITY, 0)
|
24
21
|
when :fail
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
# don't stub self
|
29
|
-
return super if self < stub_klass
|
30
|
-
|
31
|
-
# swap out limit method with stub
|
32
|
-
super.tap do |instance|
|
33
|
-
stub = stub_klass.allocate
|
34
|
-
stub.send(:initialize, *args, **opts)
|
35
|
-
|
36
|
-
instance.define_singleton_method(:limit) do |&block|
|
37
|
-
stub.limit(&block)
|
38
|
-
end
|
22
|
+
raise Overloaded
|
23
|
+
else
|
24
|
+
super
|
39
25
|
end
|
40
26
|
end
|
41
27
|
end
|
42
28
|
|
43
29
|
end
|
30
|
+
|
31
|
+
# stub each Limiter subclass
|
32
|
+
ObjectSpace.each_object(Class).each do |klass|
|
33
|
+
next unless klass < Berater::Limiter
|
34
|
+
|
35
|
+
klass.prepend(Berater::TestMode)
|
36
|
+
end
|
data/lib/berater/unlimiter.rb
CHANGED
@@ -2,21 +2,17 @@ module Berater
|
|
2
2
|
class Unlimiter < Limiter
|
3
3
|
|
4
4
|
def initialize(key = :unlimiter, *args, **opts)
|
5
|
-
super(key, **opts)
|
5
|
+
super(key, Float::INFINITY, **opts)
|
6
6
|
end
|
7
7
|
|
8
|
-
|
9
|
-
lock = Lock.new(self, 0, 0)
|
8
|
+
protected
|
10
9
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
else
|
18
|
-
lock
|
19
|
-
end
|
10
|
+
def capacity=(*)
|
11
|
+
@capacity = Float::INFINITY
|
12
|
+
end
|
13
|
+
|
14
|
+
def acquire_lock(*)
|
15
|
+
Lock.new(Float::INFINITY, 0)
|
20
16
|
end
|
21
17
|
|
22
18
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Berater
|
2
|
+
module Utils
|
3
|
+
extend self
|
4
|
+
|
5
|
+
refine Object do
|
6
|
+
def to_msec
|
7
|
+
Berater::Utils.to_msec(self)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_msec(val)
|
12
|
+
res = val
|
13
|
+
|
14
|
+
if val.is_a? String
|
15
|
+
# naively attempt casting, otherwise maybe it's a keyword
|
16
|
+
res = Float(val) rescue val.to_sym
|
17
|
+
end
|
18
|
+
|
19
|
+
if res.is_a? Symbol
|
20
|
+
case res
|
21
|
+
when :sec, :second, :seconds
|
22
|
+
res = 1
|
23
|
+
when :min, :minute, :minutes
|
24
|
+
res = 60
|
25
|
+
when :hour, :hours
|
26
|
+
res = 60 * 60
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
unless res.is_a? Numeric
|
31
|
+
raise ArgumentError, "unexpected value: #{val}"
|
32
|
+
end
|
33
|
+
|
34
|
+
if res < 0
|
35
|
+
raise ArgumentError, "expected value >= 0, found: #{val}"
|
36
|
+
end
|
37
|
+
|
38
|
+
if res == Float::INFINITY
|
39
|
+
raise ArgumentError, "infinite values not allowed"
|
40
|
+
end
|
41
|
+
|
42
|
+
(res * 10**3).to_i
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|