berater 0.3.0 → 0.6.2
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 +27 -38
- data/lib/berater/concurrency_limiter.rb +53 -45
- data/lib/berater/dsl.rb +20 -9
- data/lib/berater/inhibitor.rb +4 -2
- data/lib/berater/limiter.rb +68 -4
- data/lib/berater/lock.rb +4 -14
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +64 -63
- data/lib/berater/rspec.rb +3 -1
- data/lib/berater/rspec/matchers.rb +57 -36
- data/lib/berater/test_mode.rb +21 -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 +33 -70
- data/spec/concurrency_limiter_spec.rb +166 -64
- data/spec/dsl_refinement_spec.rb +46 -0
- data/spec/dsl_spec.rb +72 -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 +71 -3
- data/spec/rate_limiter_spec.rb +132 -94
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +123 -81
- 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
@@ -3,94 +3,95 @@ module Berater
|
|
3
3
|
|
4
4
|
class Overrated < Overloaded; end
|
5
5
|
|
6
|
-
attr_accessor :
|
6
|
+
attr_accessor :interval
|
7
7
|
|
8
|
-
def initialize(key,
|
9
|
-
super(key, **opts)
|
10
|
-
|
11
|
-
self.count = count
|
8
|
+
def initialize(key, capacity, interval, **opts)
|
12
9
|
self.interval = interval
|
10
|
+
super(key, capacity, @interval_msec, **opts)
|
13
11
|
end
|
14
12
|
|
15
|
-
private def
|
16
|
-
|
17
|
-
|
18
|
-
end
|
19
|
-
|
20
|
-
raise ArgumentError, "count must be >= 0" unless count >= 0
|
13
|
+
private def interval=(interval)
|
14
|
+
@interval = interval
|
15
|
+
@interval_msec = Berater::Utils.to_msec(interval)
|
21
16
|
|
22
|
-
@
|
17
|
+
unless @interval_msec > 0
|
18
|
+
raise ArgumentError, 'interval must be > 0'
|
19
|
+
end
|
23
20
|
end
|
24
21
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
22
|
+
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
23
|
+
local key = KEYS[1]
|
24
|
+
local ts_key = KEYS[2]
|
25
|
+
local ts = tonumber(ARGV[1])
|
26
|
+
local capacity = tonumber(ARGV[2])
|
27
|
+
local interval_msec = tonumber(ARGV[3])
|
28
|
+
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
|
+
|
39
|
+
-- adjust for time passing
|
40
|
+
local drips = math.floor((ts - last_ts) / msec_per_drip)
|
41
|
+
count = math.max(0, count - drips)
|
37
42
|
end
|
38
43
|
|
39
|
-
if
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
53
|
-
end
|
54
|
-
end
|
44
|
+
if cost == 0 then
|
45
|
+
-- just check limit, ie. for .overlimit?
|
46
|
+
allowed = count < capacity
|
47
|
+
else
|
48
|
+
allowed = (count + cost) <= capacity
|
55
49
|
|
56
|
-
|
57
|
-
|
50
|
+
if allowed then
|
51
|
+
count = count + cost
|
58
52
|
|
59
|
-
|
60
|
-
|
53
|
+
-- time for bucket to empty, in milliseconds
|
54
|
+
local ttl = math.ceil(count * msec_per_drip)
|
61
55
|
|
62
|
-
|
63
|
-
|
64
|
-
|
56
|
+
-- update count and last_ts, with expirations
|
57
|
+
redis.call('SET', key, count, 'PX', ttl)
|
58
|
+
redis.call('SET', ts_key, ts, 'PX', ttl)
|
59
|
+
end
|
65
60
|
end
|
66
61
|
|
67
|
-
|
62
|
+
return { count, allowed }
|
63
|
+
LUA
|
64
|
+
)
|
68
65
|
|
69
|
-
|
66
|
+
protected def acquire_lock(capacity, cost)
|
67
|
+
# timestamp in milliseconds
|
68
|
+
ts = (Time.now.to_f * 10**3).to_i
|
70
69
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
70
|
+
count, allowed = LUA_SCRIPT.eval(
|
71
|
+
redis,
|
72
|
+
[ cache_key(key), cache_key("#{key}-ts") ],
|
73
|
+
[ ts, capacity, @interval_msec, cost ]
|
74
|
+
)
|
75
|
+
|
76
|
+
raise Overrated unless allowed
|
77
|
+
|
78
|
+
Lock.new(capacity, count)
|
80
79
|
end
|
81
80
|
|
81
|
+
alias overrated? overloaded?
|
82
|
+
|
82
83
|
def to_s
|
83
|
-
msg = if
|
84
|
-
if
|
84
|
+
msg = if interval.is_a? Numeric
|
85
|
+
if interval == 1
|
85
86
|
"every second"
|
86
87
|
else
|
87
|
-
"every #{
|
88
|
+
"every #{interval} seconds"
|
88
89
|
end
|
89
90
|
else
|
90
|
-
"per #{
|
91
|
+
"per #{interval}"
|
91
92
|
end
|
92
93
|
|
93
|
-
"#<#{self.class}(#{key}: #{
|
94
|
+
"#<#{self.class}(#{key}: #{capacity} #{msg})>"
|
94
95
|
end
|
95
96
|
|
96
97
|
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,81 @@
|
|
1
|
-
module
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
1
|
+
module Berater
|
2
|
+
module Matchers
|
3
|
+
class Overloaded
|
4
|
+
def initialize(type)
|
5
|
+
@type = type
|
6
|
+
end
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
def supports_block_expectations?
|
9
|
+
true
|
10
|
+
end
|
10
11
|
|
11
|
-
|
12
|
-
begin
|
12
|
+
def matches?(obj)
|
13
13
|
case obj
|
14
14
|
when Proc
|
15
15
|
# eg. expect { ... }.to be_overrated
|
16
16
|
res = obj.call
|
17
17
|
|
18
18
|
if res.is_a? Berater::Limiter
|
19
|
-
# eg. expect { Berater.new(...) }.to
|
20
|
-
res
|
19
|
+
# eg. expect { Berater.new(...) }.to be_overloaded
|
20
|
+
@limiter = res
|
21
|
+
res.overloaded?
|
22
|
+
else
|
23
|
+
# eg. expect { Berater(...) }.to be_overloaded
|
24
|
+
# eg. expect { limiter.limit }.to be_overloaded
|
25
|
+
false
|
21
26
|
end
|
22
27
|
when Berater::Limiter
|
23
|
-
# eg. expect(Berater.new(...)).to
|
24
|
-
obj
|
28
|
+
# eg. expect(Berater.new(...)).to be_overloaded
|
29
|
+
@limiter = obj
|
30
|
+
obj.overloaded?
|
25
31
|
end
|
26
|
-
|
27
|
-
false
|
28
32
|
rescue @type
|
29
33
|
true
|
30
34
|
end
|
31
|
-
end
|
32
35
|
|
33
|
-
|
34
|
-
|
36
|
+
def description
|
37
|
+
if @limiter
|
38
|
+
"be #{verb}"
|
39
|
+
else
|
40
|
+
"raise #{@type}"
|
41
|
+
end
|
42
|
+
end
|
35
43
|
|
36
|
-
|
37
|
-
|
38
|
-
|
44
|
+
def failure_message
|
45
|
+
if @limiter
|
46
|
+
"expected to be #{verb}"
|
47
|
+
else
|
48
|
+
"expected #{@type} to be raised"
|
49
|
+
end
|
50
|
+
end
|
39
51
|
|
40
|
-
|
41
|
-
|
52
|
+
def failure_message_when_negated
|
53
|
+
if @limiter
|
54
|
+
"expected not to be #{verb}"
|
55
|
+
else
|
56
|
+
"did not expect #{@type} to be raised"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private def verb
|
61
|
+
@type.to_s.split('::')[-1].downcase
|
62
|
+
end
|
42
63
|
end
|
43
|
-
end
|
44
64
|
|
45
|
-
|
46
|
-
|
47
|
-
|
65
|
+
def be_overloaded
|
66
|
+
Overloaded.new(Berater::Overloaded)
|
67
|
+
end
|
48
68
|
|
49
|
-
|
50
|
-
|
51
|
-
|
69
|
+
def be_overrated
|
70
|
+
Overloaded.new(Berater::RateLimiter::Overrated)
|
71
|
+
end
|
52
72
|
|
53
|
-
|
54
|
-
|
55
|
-
|
73
|
+
def be_incapacitated
|
74
|
+
Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
|
75
|
+
end
|
56
76
|
|
57
|
-
|
58
|
-
|
77
|
+
def be_inhibited
|
78
|
+
Overloaded.new(Berater::Inhibitor::Inhibited)
|
79
|
+
end
|
59
80
|
end
|
60
81
|
end
|
data/lib/berater/test_mode.rb
CHANGED
@@ -13,31 +13,31 @@ 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
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
instance.define_singleton_method(:limit) do |&block|
|
37
|
-
stub.limit(&block)
|
38
|
-
end
|
22
|
+
# find class specific Overloaded error
|
23
|
+
e = self.class.constants.map do |name|
|
24
|
+
self.class.const_get(name)
|
25
|
+
end.find do |const|
|
26
|
+
const.is_a?(Class) && const < Berater::Overloaded
|
27
|
+
end || Berater::Overloaded
|
28
|
+
|
29
|
+
raise e
|
30
|
+
else
|
31
|
+
super
|
39
32
|
end
|
40
33
|
end
|
41
34
|
end
|
42
35
|
|
43
36
|
end
|
37
|
+
|
38
|
+
# stub each Limiter subclass
|
39
|
+
ObjectSpace.each_object(Class).each do |klass|
|
40
|
+
next unless klass < Berater::Limiter
|
41
|
+
|
42
|
+
klass.prepend(Berater::TestMode)
|
43
|
+
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
|