berater 0.4.0 → 0.5.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 +27 -38
- data/lib/berater/concurrency_limiter.rb +58 -44
- data/lib/berater/dsl.rb +20 -9
- data/lib/berater/inhibitor.rb +7 -2
- data/lib/berater/limiter.rb +48 -2
- data/lib/berater/lock.rb +1 -10
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +42 -73
- data/lib/berater/rspec.rb +2 -0
- data/lib/berater/rspec/matchers.rb +8 -6
- data/lib/berater/test_mode.rb +14 -5
- data/lib/berater/unlimiter.rb +6 -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 +138 -63
- 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 +71 -0
- data/spec/lua_script_spec.rb +97 -0
- data/spec/matchers_spec.rb +14 -2
- data/spec/rate_limiter_spec.rb +94 -97
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +108 -78
- data/spec/unlimiter_spec.rb +3 -9
- data/spec/utils_spec.rb +78 -0
- metadata +31 -3
data/lib/berater/rate_limiter.rb
CHANGED
@@ -3,63 +3,28 @@ 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
|
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
|
10
|
+
super(key, capacity, @interval_usec, **opts)
|
23
11
|
end
|
24
12
|
|
25
13
|
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
|
38
|
-
|
39
|
-
if @interval.is_a? Symbol
|
40
|
-
case @interval
|
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
|
53
|
-
end
|
14
|
+
@interval = interval
|
15
|
+
@interval_usec = Berater::Utils.to_usec(interval)
|
54
16
|
end
|
55
17
|
|
56
|
-
LUA_SCRIPT = <<~LUA
|
18
|
+
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
57
19
|
local key = KEYS[1]
|
58
20
|
local ts_key = KEYS[2]
|
59
21
|
local ts = tonumber(ARGV[1])
|
60
22
|
local capacity = tonumber(ARGV[2])
|
61
|
-
local
|
23
|
+
local interval_usec = tonumber(ARGV[3])
|
24
|
+
local cost = tonumber(ARGV[4])
|
62
25
|
local count = 0
|
26
|
+
local allowed
|
27
|
+
local usec_per_drip = interval_usec / capacity
|
63
28
|
|
64
29
|
-- timestamp of last update
|
65
30
|
local last_ts = tonumber(redis.call('GET', ts_key))
|
@@ -72,61 +37,65 @@ module Berater
|
|
72
37
|
count = math.max(0, count - drips)
|
73
38
|
end
|
74
39
|
|
75
|
-
|
40
|
+
if cost == 0 then
|
41
|
+
-- just check limit, ie. for .overlimit?
|
42
|
+
allowed = count < capacity
|
43
|
+
else
|
44
|
+
allowed = (count + cost) <= capacity
|
76
45
|
|
77
|
-
|
78
|
-
|
46
|
+
if allowed then
|
47
|
+
count = count + cost
|
79
48
|
|
80
|
-
|
81
|
-
|
49
|
+
-- time for bucket to empty, in milliseconds
|
50
|
+
local ttl = math.ceil((count * usec_per_drip) / 1000)
|
82
51
|
|
83
|
-
|
84
|
-
|
85
|
-
|
52
|
+
-- update count and last_ts, with expirations
|
53
|
+
redis.call('SET', key, count, 'PX', ttl)
|
54
|
+
redis.call('SET', ts_key, ts, 'PX', ttl)
|
55
|
+
end
|
86
56
|
end
|
87
57
|
|
88
58
|
return { count, allowed }
|
89
59
|
LUA
|
60
|
+
)
|
90
61
|
|
91
|
-
def limit
|
92
|
-
|
62
|
+
def limit(capacity: nil, cost: 1, &block)
|
63
|
+
capacity ||= @capacity
|
93
64
|
|
94
65
|
# timestamp in microseconds
|
95
66
|
ts = (Time.now.to_f * 10**6).to_i
|
96
67
|
|
97
|
-
count, allowed =
|
98
|
-
|
68
|
+
count, allowed = LUA_SCRIPT.eval(
|
69
|
+
redis,
|
99
70
|
[ cache_key(key), cache_key("#{key}-ts") ],
|
100
|
-
[ ts, @
|
71
|
+
[ ts, capacity, @interval_usec, cost ]
|
101
72
|
)
|
102
73
|
|
103
74
|
raise Overrated unless allowed
|
104
75
|
|
105
|
-
lock = Lock.new(self,
|
76
|
+
lock = Lock.new(self, ts, count)
|
77
|
+
yield_lock(lock, &block)
|
78
|
+
end
|
106
79
|
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
lock.release
|
112
|
-
end
|
113
|
-
else
|
114
|
-
lock
|
115
|
-
end
|
80
|
+
def overloaded?
|
81
|
+
limit(cost: 0) { false }
|
82
|
+
rescue Overrated
|
83
|
+
true
|
116
84
|
end
|
85
|
+
alias overrated? overloaded?
|
117
86
|
|
118
87
|
def to_s
|
119
|
-
msg = if
|
120
|
-
if
|
88
|
+
msg = if interval.is_a? Numeric
|
89
|
+
if interval == 1
|
121
90
|
"every second"
|
122
91
|
else
|
123
|
-
"every #{
|
92
|
+
"every #{interval} seconds"
|
124
93
|
end
|
125
94
|
else
|
126
|
-
"per #{
|
95
|
+
"per #{interval}"
|
127
96
|
end
|
128
97
|
|
129
|
-
"#<#{self.class}(#{key}: #{
|
98
|
+
"#<#{self.class}(#{key}: #{capacity} #{msg})>"
|
130
99
|
end
|
131
100
|
|
132
101
|
end
|
data/lib/berater/rspec.rb
CHANGED
@@ -16,15 +16,17 @@ module BeraterMatchers
|
|
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
|
+
res.overloaded?
|
21
|
+
else
|
22
|
+
# eg. expect { Berater(...) }.to be_overloaded
|
23
|
+
# eg. expect { limiter.limit }.to be_overloaded
|
24
|
+
false
|
21
25
|
end
|
22
26
|
when Berater::Limiter
|
23
|
-
# eg. expect(Berater.new(...)).to
|
24
|
-
obj.
|
27
|
+
# eg. expect(Berater.new(...)).to be_overloaded
|
28
|
+
obj.overloaded?
|
25
29
|
end
|
26
|
-
|
27
|
-
false
|
28
30
|
rescue @type
|
29
31
|
true
|
30
32
|
end
|
data/lib/berater/test_mode.rb
CHANGED
@@ -11,10 +11,15 @@ module Berater
|
|
11
11
|
end
|
12
12
|
|
13
13
|
@test_mode = mode
|
14
|
+
|
15
|
+
# overload class methods
|
16
|
+
unless Berater::Limiter.singleton_class.ancestors.include?(TestMode)
|
17
|
+
Berater::Limiter.singleton_class.prepend(TestMode)
|
18
|
+
end
|
14
19
|
end
|
15
20
|
|
16
|
-
|
17
|
-
def
|
21
|
+
module TestMode
|
22
|
+
def new(*args, **opts)
|
18
23
|
return super unless Berater.test_mode
|
19
24
|
|
20
25
|
# chose a stub class with desired behavior
|
@@ -28,13 +33,17 @@ module Berater
|
|
28
33
|
# don't stub self
|
29
34
|
return super if self < stub_klass
|
30
35
|
|
31
|
-
# swap out limit
|
36
|
+
# swap out limit and overloaded? methods with stub
|
32
37
|
super.tap do |instance|
|
33
38
|
stub = stub_klass.allocate
|
34
39
|
stub.send(:initialize, *args, **opts)
|
35
40
|
|
36
|
-
instance.define_singleton_method(:limit) do
|
37
|
-
stub.limit(&block)
|
41
|
+
instance.define_singleton_method(:limit) do |**opts, &block|
|
42
|
+
stub.limit(**opts, &block)
|
43
|
+
end
|
44
|
+
|
45
|
+
instance.define_singleton_method(:overloaded?) do
|
46
|
+
stub.overloaded?
|
38
47
|
end
|
39
48
|
end
|
40
49
|
end
|
data/lib/berater/unlimiter.rb
CHANGED
@@ -2,21 +2,15 @@ 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
|
-
def limit
|
9
|
-
|
8
|
+
def limit(**opts, &block)
|
9
|
+
yield_lock(Lock.new(self, Float::INFINITY, 0), &block)
|
10
|
+
end
|
10
11
|
|
11
|
-
|
12
|
-
|
13
|
-
yield lock
|
14
|
-
ensure
|
15
|
-
lock.release
|
16
|
-
end
|
17
|
-
else
|
18
|
-
lock
|
19
|
-
end
|
12
|
+
def overloaded?
|
13
|
+
false
|
20
14
|
end
|
21
15
|
|
22
16
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Berater
|
2
|
+
module Utils
|
3
|
+
extend self
|
4
|
+
|
5
|
+
refine Object do
|
6
|
+
def to_usec
|
7
|
+
Berater::Utils.to_usec(self)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_usec(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**6).to_i
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
data/lib/berater/version.rb
CHANGED
data/spec/berater_spec.rb
CHANGED
@@ -7,12 +7,12 @@ describe Berater do
|
|
7
7
|
it { is_expected.to respond_to :configure }
|
8
8
|
|
9
9
|
describe '.configure' do
|
10
|
-
it '
|
10
|
+
it 'is used with a block' do
|
11
11
|
Berater.configure do |c|
|
12
12
|
c.redis = :redis
|
13
13
|
end
|
14
14
|
|
15
|
-
expect(Berater.redis).to
|
15
|
+
expect(Berater.redis).to be :redis
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
@@ -26,7 +26,7 @@ describe Berater do
|
|
26
26
|
|
27
27
|
describe '.new' do
|
28
28
|
context 'unlimited mode' do
|
29
|
-
let(:limiter) { Berater.new(:key,
|
29
|
+
let(:limiter) { Berater.new(:key, Float::INFINITY) }
|
30
30
|
|
31
31
|
it 'instantiates an Unlimiter' do
|
32
32
|
expect(limiter).to be_a Berater::Unlimiter
|
@@ -39,18 +39,13 @@ describe Berater do
|
|
39
39
|
|
40
40
|
it 'accepts options' do
|
41
41
|
redis = double('Redis')
|
42
|
-
limiter = Berater.new(:key,
|
42
|
+
limiter = Berater.new(:key, Float::INFINITY, redis: redis)
|
43
43
|
expect(limiter.redis).to be redis
|
44
44
|
end
|
45
|
-
|
46
|
-
it 'works with convinience' do
|
47
|
-
expect(Berater).to receive(:new).and_return(limiter)
|
48
|
-
expect {|b| Berater(:key, :unlimited, &b) }.to yield_control
|
49
|
-
end
|
50
45
|
end
|
51
46
|
|
52
47
|
context 'inhibited mode' do
|
53
|
-
let(:limiter) { Berater.new(:key,
|
48
|
+
let(:limiter) { Berater.new(:key, 0) }
|
54
49
|
|
55
50
|
it 'instantiates an Inhibitor' do
|
56
51
|
expect(limiter).to be_a Berater::Inhibitor
|
@@ -63,18 +58,13 @@ describe Berater do
|
|
63
58
|
|
64
59
|
it 'accepts options' do
|
65
60
|
redis = double('Redis')
|
66
|
-
limiter = Berater.new(:key,
|
61
|
+
limiter = Berater.new(:key, 0, redis: redis)
|
67
62
|
expect(limiter.redis).to be redis
|
68
63
|
end
|
69
|
-
|
70
|
-
it 'works with convinience' do
|
71
|
-
expect(Berater).to receive(:new).and_return(limiter)
|
72
|
-
expect { Berater(:key, :inhibited) }.to be_inhibited
|
73
|
-
end
|
74
64
|
end
|
75
65
|
|
76
66
|
context 'rate mode' do
|
77
|
-
let(:limiter) { Berater.new(:key,
|
67
|
+
let(:limiter) { Berater.new(:key, 1, :second) }
|
78
68
|
|
79
69
|
it 'instantiates a RateLimiter' do
|
80
70
|
expect(limiter).to be_a Berater::RateLimiter
|
@@ -87,18 +77,13 @@ describe Berater do
|
|
87
77
|
|
88
78
|
it 'accepts options' do
|
89
79
|
redis = double('Redis')
|
90
|
-
limiter = Berater.new(:key,
|
80
|
+
limiter = Berater.new(:key, 1, :second, redis: redis)
|
91
81
|
expect(limiter.redis).to be redis
|
92
82
|
end
|
93
|
-
|
94
|
-
it 'works with convinience' do
|
95
|
-
expect(Berater).to receive(:new).and_return(limiter)
|
96
|
-
expect {|b| Berater(:key, :rate, 1, :second, &b) }.to yield_control
|
97
|
-
end
|
98
83
|
end
|
99
84
|
|
100
85
|
context 'concurrency mode' do
|
101
|
-
let(:limiter) { Berater.new(:key,
|
86
|
+
let(:limiter) { Berater.new(:key, 1) }
|
102
87
|
|
103
88
|
it 'instantiates a ConcurrencyLimiter' do
|
104
89
|
expect(limiter).to be_a Berater::ConcurrencyLimiter
|
@@ -111,61 +96,39 @@ describe Berater do
|
|
111
96
|
|
112
97
|
it 'accepts options' do
|
113
98
|
redis = double('Redis')
|
114
|
-
limiter = Berater.new(:key,
|
99
|
+
limiter = Berater.new(:key, 1, redis: redis)
|
115
100
|
expect(limiter.redis).to be redis
|
116
101
|
end
|
117
|
-
|
118
|
-
it 'works with convinience' do
|
119
|
-
expect(Berater).to receive(:new).and_return(limiter)
|
120
|
-
expect {|b| Berater(:key, :concurrency, 1, &b) }.to yield_control
|
121
|
-
end
|
122
102
|
end
|
103
|
+
end
|
123
104
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
expect(limiter
|
129
|
-
end
|
130
|
-
|
131
|
-
it 'instatiates an Inhibiter' do
|
132
|
-
limiter = Berater.new(:key) { inhibited }
|
133
|
-
expect(limiter).to be_a Berater::Inhibitor
|
134
|
-
expect(limiter.key).to be :key
|
135
|
-
end
|
136
|
-
|
137
|
-
it 'instatiates a RateLimiter' do
|
138
|
-
limiter = Berater.new(:key) { 1.per second }
|
139
|
-
expect(limiter).to be_a Berater::RateLimiter
|
140
|
-
expect(limiter.key).to be :key
|
141
|
-
expect(limiter.count).to be 1
|
142
|
-
expect(limiter.interval).to be :second
|
143
|
-
end
|
144
|
-
|
145
|
-
it 'instatiates a ConcurrencyLimiter' do
|
146
|
-
limiter = Berater.new(:key, timeout: 2) { 1.at_once }
|
147
|
-
expect(limiter).to be_a Berater::ConcurrencyLimiter
|
148
|
-
expect(limiter.key).to be :key
|
149
|
-
expect(limiter.capacity).to be 1
|
150
|
-
expect(limiter.timeout).to be 2
|
105
|
+
describe 'Berater() - convenience method' do
|
106
|
+
RSpec.shared_examples 'test convenience' do |klass, *args|
|
107
|
+
it 'creates a limiter' do
|
108
|
+
limiter = Berater(:key, *args)
|
109
|
+
expect(limiter).to be_a klass
|
151
110
|
end
|
152
111
|
|
153
|
-
|
154
|
-
|
155
|
-
Berater
|
156
|
-
|
112
|
+
context 'with a block' do
|
113
|
+
it 'creates a limiter and calls limit' do
|
114
|
+
limiter = Berater(:key, *args)
|
115
|
+
expect(klass).to receive(:new).and_return(limiter)
|
116
|
+
expect(limiter).to receive(:limit).and_call_original
|
157
117
|
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
Berater.new(:key)
|
166
|
-
}.to raise_error(ArgumentError)
|
118
|
+
begin
|
119
|
+
res = Berater(:key, *args) { true }
|
120
|
+
expect(res).to be true
|
121
|
+
rescue Berater::Overloaded
|
122
|
+
expect(klass).to be Berater::Inhibitor
|
123
|
+
end
|
124
|
+
end
|
167
125
|
end
|
168
126
|
end
|
127
|
+
|
128
|
+
include_examples 'test convenience', Berater::Unlimiter, Float::INFINITY
|
129
|
+
include_examples 'test convenience', Berater::Inhibitor, 0
|
130
|
+
include_examples 'test convenience', Berater::RateLimiter, 1, :second
|
131
|
+
include_examples 'test convenience', Berater::ConcurrencyLimiter, 1
|
169
132
|
end
|
170
133
|
|
171
134
|
end
|