berater 0.1.4 → 0.6.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 +33 -35
- data/lib/berater/concurrency_limiter.rb +68 -110
- data/lib/berater/dsl.rb +68 -0
- data/lib/berater/inhibitor.rb +9 -10
- data/lib/berater/limiter.rb +80 -0
- data/lib/berater/lock.rb +26 -0
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +77 -54
- data/lib/berater/rspec.rb +14 -0
- data/lib/berater/rspec/matchers.rb +83 -0
- data/lib/berater/test_mode.rb +52 -0
- data/lib/berater/unlimiter.rb +11 -9
- data/lib/berater/utils.rb +46 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +43 -101
- data/spec/concurrency_limiter_spec.rb +168 -100
- data/spec/dsl_refinement_spec.rb +46 -0
- data/spec/dsl_spec.rb +72 -0
- data/spec/inhibitor_spec.rb +4 -18
- data/spec/limiter_spec.rb +71 -0
- data/spec/lua_script_spec.rb +97 -0
- data/spec/{matcher_spec.rb → matchers_spec.rb} +73 -5
- data/spec/rate_limiter_spec.rb +162 -99
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +206 -0
- data/spec/unlimiter_spec.rb +6 -37
- data/spec/utils_spec.rb +78 -0
- metadata +41 -8
- data/lib/berater/base_limiter.rb +0 -32
- data/spec/concurrency_lock_spec.rb +0 -92
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 87d3bdb61a68a8b69dd7cb285dafb174a403acf19ff7586f90ed8b7cb50b7f76
|
4
|
+
data.tar.gz: fff71dd4d8ae28baf7c504efdc27df71f03219b32c1a959dddfd3d035845d058
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c9fb5c0c7ec100d2f50a3b6c6cfa449af052c1b78cff174b545e179c5fcc1324c2a5faafc90e7f7ec64ce200e47571d51b02eb56398d716c487abc7731c0cece
|
7
|
+
data.tar.gz: 148c566413d6593f229143e2871116b45947eae5b101d8bb2f59ce3bdfe89ed4ee1b7aaedfcaedbc80eef8a25a14fab83ad5e540172c41f31f27d7f296161235
|
data/lib/berater.rb
CHANGED
@@ -1,46 +1,40 @@
|
|
1
|
+
require 'berater/limiter'
|
2
|
+
require 'berater/lock'
|
3
|
+
require 'berater/lua_script'
|
4
|
+
require 'berater/utils'
|
1
5
|
require 'berater/version'
|
2
6
|
|
3
|
-
|
4
7
|
module Berater
|
5
8
|
extend self
|
6
9
|
|
7
10
|
class Overloaded < StandardError; end
|
8
11
|
|
9
|
-
|
10
|
-
|
11
|
-
attr_accessor :redis, :mode
|
12
|
+
attr_accessor :redis
|
12
13
|
|
13
14
|
def configure
|
14
|
-
self.mode = :unlimited # default
|
15
|
-
|
16
15
|
yield self
|
17
16
|
end
|
18
17
|
|
19
|
-
def
|
20
|
-
|
21
|
-
|
22
|
-
unless klass
|
23
|
-
raise ArgumentError, "invalid mode: #{mode}"
|
24
|
-
end
|
25
|
-
|
26
|
-
klass.new(*args, **opts)
|
27
|
-
end
|
28
|
-
|
29
|
-
def register(mode, klass)
|
30
|
-
MODES[mode.to_sym] = klass
|
18
|
+
def reset
|
19
|
+
@redis = nil
|
31
20
|
end
|
32
21
|
|
33
|
-
def
|
34
|
-
|
35
|
-
|
22
|
+
def new(key, capacity, interval = nil, **opts)
|
23
|
+
case capacity
|
24
|
+
when :unlimited, Float::INFINITY
|
25
|
+
Berater::Unlimiter
|
26
|
+
when :inhibited, 0
|
27
|
+
Berater::Inhibitor
|
28
|
+
else
|
29
|
+
if interval
|
30
|
+
Berater::RateLimiter
|
31
|
+
else
|
32
|
+
Berater::ConcurrencyLimiter
|
33
|
+
end
|
34
|
+
end.yield_self do |klass|
|
35
|
+
args = [ key, capacity, interval ].compact
|
36
|
+
klass.new(*args, **opts)
|
36
37
|
end
|
37
|
-
|
38
|
-
@mode = mode.to_sym
|
39
|
-
end
|
40
|
-
|
41
|
-
def limit(*args, **opts, &block)
|
42
|
-
mode = opts.delete(:mode) { self.mode }
|
43
|
-
new(mode, *args, **opts).limit(&block)
|
44
38
|
end
|
45
39
|
|
46
40
|
def expunge
|
@@ -51,14 +45,18 @@ module Berater
|
|
51
45
|
|
52
46
|
end
|
53
47
|
|
54
|
-
#
|
55
|
-
|
48
|
+
# convenience method
|
49
|
+
def Berater(key, capacity, interval = nil, **opts, &block)
|
50
|
+
limiter = Berater.new(key, capacity, interval, **opts)
|
51
|
+
if block_given?
|
52
|
+
limiter.limit(&block)
|
53
|
+
else
|
54
|
+
limiter
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# load limiters
|
56
59
|
require 'berater/concurrency_limiter'
|
57
60
|
require 'berater/inhibitor'
|
58
61
|
require 'berater/rate_limiter'
|
59
62
|
require 'berater/unlimiter'
|
60
|
-
|
61
|
-
Berater.register(:concurrency, Berater::ConcurrencyLimiter)
|
62
|
-
Berater.register(:inhibited, Berater::Inhibitor)
|
63
|
-
Berater.register(:rate, Berater::RateLimiter)
|
64
|
-
Berater.register(:unlimited, Berater::Unlimiter)
|
@@ -1,153 +1,111 @@
|
|
1
1
|
module Berater
|
2
|
-
class ConcurrencyLimiter <
|
2
|
+
class ConcurrencyLimiter < Limiter
|
3
3
|
|
4
4
|
class Incapacitated < Overloaded; end
|
5
5
|
|
6
|
-
attr_reader :
|
6
|
+
attr_reader :timeout
|
7
7
|
|
8
|
-
def initialize(capacity, **opts)
|
9
|
-
super(**opts)
|
8
|
+
def initialize(key, capacity, **opts)
|
9
|
+
super(key, capacity, **opts)
|
10
10
|
|
11
|
-
self.capacity = capacity
|
12
11
|
self.timeout = opts[:timeout] || 0
|
13
12
|
end
|
14
13
|
|
15
|
-
def
|
16
|
-
unless capacity.is_a? Integer
|
17
|
-
raise ArgumentError, "expected Integer, found #{capacity.class}"
|
18
|
-
end
|
19
|
-
|
20
|
-
raise ArgumentError, "capacity must be >= 0" unless capacity >= 0
|
21
|
-
|
22
|
-
@capacity = capacity
|
23
|
-
end
|
24
|
-
|
25
|
-
def timeout=(timeout)
|
26
|
-
unless timeout.is_a? Integer
|
27
|
-
raise ArgumentError, "expected Integer, found #{timeout.class}"
|
28
|
-
end
|
29
|
-
|
30
|
-
raise ArgumentError, "timeout must be >= 0" unless timeout >= 0
|
31
|
-
|
14
|
+
private def timeout=(timeout)
|
32
15
|
@timeout = timeout
|
16
|
+
timeout = 0 if timeout == Float::INFINITY
|
17
|
+
@timeout_msec = Berater::Utils.to_msec(timeout)
|
33
18
|
end
|
34
19
|
|
35
|
-
|
36
|
-
attr_reader :limiter, :id, :contention
|
37
|
-
|
38
|
-
def initialize(limiter, id, contention)
|
39
|
-
@limiter = limiter
|
40
|
-
@id = id
|
41
|
-
@contention = contention
|
42
|
-
@released = false
|
43
|
-
@locked_at = Time.now
|
44
|
-
end
|
45
|
-
|
46
|
-
def release
|
47
|
-
raise 'lock already released' if released?
|
48
|
-
raise 'lock expired' if expired?
|
49
|
-
|
50
|
-
@released = limiter.release(self)
|
51
|
-
end
|
52
|
-
|
53
|
-
def released?
|
54
|
-
@released
|
55
|
-
end
|
56
|
-
|
57
|
-
def expired?
|
58
|
-
limiter.timeout > 0 && @locked_at + limiter.timeout < Time.now
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
LUA_SCRIPT = <<~LUA.gsub(/^\s*(--.*\n)?/, '')
|
20
|
+
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
63
21
|
local key = KEYS[1]
|
22
|
+
local lock_key = KEYS[2]
|
64
23
|
local capacity = tonumber(ARGV[1])
|
65
24
|
local ts = tonumber(ARGV[2])
|
66
25
|
local ttl = tonumber(ARGV[3])
|
26
|
+
local cost = tonumber(ARGV[4])
|
27
|
+
local lock_ids = {}
|
67
28
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
-- check to see if key already exists
|
73
|
-
if ttl == 0 then
|
74
|
-
exists = redis.call('EXISTS', key)
|
75
|
-
else
|
76
|
-
-- and refresh TTL while we're at it
|
77
|
-
exists = redis.call('EXPIRE', key, ttl * 2)
|
29
|
+
-- purge stale hosts
|
30
|
+
if ttl > 0 then
|
31
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
78
32
|
end
|
79
33
|
|
80
|
-
|
81
|
-
|
82
|
-
if ttl > 0 then
|
83
|
-
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
84
|
-
end
|
85
|
-
|
86
|
-
-- check capacity (subtract one for next lock entry)
|
87
|
-
count = redis.call('ZCARD', key) - 1
|
34
|
+
-- check capacity
|
35
|
+
local count = redis.call('ZCARD', key)
|
88
36
|
|
37
|
+
if cost == 0 then
|
38
|
+
-- just check limit, ie. for .overlimit?
|
89
39
|
if count < capacity then
|
90
|
-
|
40
|
+
table.insert(lock_ids, true)
|
41
|
+
end
|
42
|
+
elseif (count + cost) <= capacity then
|
43
|
+
-- grab locks, one per cost
|
44
|
+
local lock_id = redis.call('INCRBY', lock_key, cost)
|
45
|
+
local locks = {}
|
91
46
|
|
92
|
-
|
93
|
-
|
94
|
-
redis.call('ZADD', key, 'inf', (lock + 1) % 2^52)
|
47
|
+
for i = lock_id - cost + 1, lock_id do
|
48
|
+
table.insert(lock_ids, i)
|
95
49
|
|
96
|
-
|
50
|
+
table.insert(locks, ts)
|
51
|
+
table.insert(locks, i)
|
97
52
|
end
|
98
|
-
elseif capacity > 0 then
|
99
|
-
count = 1
|
100
|
-
lock = "1"
|
101
53
|
|
102
|
-
|
103
|
-
|
54
|
+
redis.call('ZADD', key, unpack(locks))
|
55
|
+
count = count + cost
|
104
56
|
|
105
57
|
if ttl > 0 then
|
106
|
-
redis.call('
|
58
|
+
redis.call('PEXPIRE', key, ttl)
|
107
59
|
end
|
108
60
|
end
|
109
61
|
|
110
|
-
|
111
|
-
-- store lock and timestamp
|
112
|
-
redis.call('ZADD', key, ts, lock)
|
113
|
-
end
|
114
|
-
|
115
|
-
return { count, lock }
|
62
|
+
return { count, unpack(lock_ids) }
|
116
63
|
LUA
|
64
|
+
)
|
65
|
+
|
66
|
+
def limit(capacity: nil, cost: 1, &block)
|
67
|
+
capacity ||= @capacity
|
117
68
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
# **options.merge(timeout: timeout).merge(opts)
|
124
|
-
).limit(&block)
|
69
|
+
# since fractional cost is not supported, capacity behaves like int
|
70
|
+
capacity = capacity.to_i
|
71
|
+
|
72
|
+
unless cost.is_a?(Integer) && cost >= 0
|
73
|
+
raise ArgumentError, "invalid cost: #{cost}"
|
125
74
|
end
|
126
75
|
|
127
|
-
|
128
|
-
|
129
|
-
[ key ],
|
130
|
-
[ capacity, Time.now.to_i, timeout ]
|
131
|
-
)
|
76
|
+
# timestamp in milliseconds
|
77
|
+
ts = (Time.now.to_f * 10**3).to_i
|
132
78
|
|
133
|
-
|
79
|
+
count, *lock_ids = LUA_SCRIPT.eval(
|
80
|
+
redis,
|
81
|
+
[ cache_key(key), cache_key('lock_id') ],
|
82
|
+
[ capacity, ts, @timeout_msec, cost ]
|
83
|
+
)
|
134
84
|
|
135
|
-
|
85
|
+
raise Incapacitated if lock_ids.empty?
|
136
86
|
|
137
|
-
if
|
138
|
-
|
139
|
-
yield lock
|
140
|
-
ensure
|
141
|
-
lock.release
|
142
|
-
end
|
143
|
-
else
|
144
|
-
lock
|
87
|
+
release_fn = if cost > 0
|
88
|
+
proc { release(lock_ids) }
|
145
89
|
end
|
90
|
+
lock = Lock.new(capacity, count, release_fn)
|
91
|
+
|
92
|
+
yield_lock(lock, &block)
|
93
|
+
end
|
94
|
+
|
95
|
+
def overloaded?
|
96
|
+
limit(cost: 0) { false }
|
97
|
+
rescue Overloaded
|
98
|
+
true
|
99
|
+
end
|
100
|
+
alias incapacitated? overloaded?
|
101
|
+
|
102
|
+
private def release(lock_ids)
|
103
|
+
res = redis.zrem(cache_key(key), lock_ids)
|
104
|
+
res == true || res == lock_ids.count # depending on which version of Redis
|
146
105
|
end
|
147
106
|
|
148
|
-
def
|
149
|
-
|
150
|
-
res == true || res == 1 # depending on which version of Redis
|
107
|
+
def to_s
|
108
|
+
"#<#{self.class}(#{key}: #{capacity} at a time)>"
|
151
109
|
end
|
152
110
|
|
153
111
|
end
|
data/lib/berater/dsl.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
module Berater
|
2
|
+
module DSL
|
3
|
+
refine Berater.singleton_class do
|
4
|
+
def new(key, mode = nil, *args, **opts, &block)
|
5
|
+
if mode.nil?
|
6
|
+
unless block_given?
|
7
|
+
raise ArgumentError, 'expected either mode or block'
|
8
|
+
end
|
9
|
+
|
10
|
+
mode, *args = DSL.eval(&block)
|
11
|
+
else
|
12
|
+
if block_given?
|
13
|
+
raise ArgumentError, 'expected either mode or block, not both'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
super(key, mode, *args, **opts)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
extend self
|
22
|
+
|
23
|
+
def eval &block
|
24
|
+
@keywords ||= Class.new do
|
25
|
+
# create a class where DSL keywords are methods
|
26
|
+
KEYWORDS.each do |keyword|
|
27
|
+
define_singleton_method(keyword) { keyword }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
install
|
32
|
+
@keywords.class_eval &block
|
33
|
+
ensure
|
34
|
+
uninstall
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
KEYWORDS = [
|
40
|
+
:second, :minute, :hour,
|
41
|
+
:unlimited, :inhibited,
|
42
|
+
].freeze
|
43
|
+
|
44
|
+
def install
|
45
|
+
Integer.class_eval do
|
46
|
+
def per(unit)
|
47
|
+
[ self, unit ]
|
48
|
+
end
|
49
|
+
alias every per
|
50
|
+
|
51
|
+
def at_once
|
52
|
+
[ self ]
|
53
|
+
end
|
54
|
+
alias concurrently at_once
|
55
|
+
alias at_a_time at_once
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def uninstall
|
60
|
+
Integer.remove_method :per
|
61
|
+
Integer.remove_method :every
|
62
|
+
|
63
|
+
Integer.remove_method :at_once
|
64
|
+
Integer.remove_method :concurrently
|
65
|
+
Integer.remove_method :at_a_time
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/berater/inhibitor.rb
CHANGED
@@ -1,21 +1,20 @@
|
|
1
1
|
module Berater
|
2
|
-
class Inhibitor <
|
2
|
+
class Inhibitor < Limiter
|
3
3
|
|
4
4
|
class Inhibited < Overloaded; end
|
5
5
|
|
6
|
-
def initialize(*args, **opts)
|
7
|
-
super(**opts)
|
6
|
+
def initialize(key = :inhibitor, *args, **opts)
|
7
|
+
super(key, 0, **opts)
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit(**opts
|
11
|
-
unless opts.empty?
|
12
|
-
return self.class.new(
|
13
|
-
**options.merge(opts)
|
14
|
-
).limit(&block)
|
15
|
-
end
|
16
|
-
|
10
|
+
def limit(**opts)
|
17
11
|
raise Inhibited
|
18
12
|
end
|
19
13
|
|
14
|
+
def overloaded?
|
15
|
+
true
|
16
|
+
end
|
17
|
+
alias inhibited? overloaded?
|
18
|
+
|
20
19
|
end
|
21
20
|
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module Berater
|
2
|
+
class Limiter
|
3
|
+
|
4
|
+
attr_reader :key, :capacity, :options
|
5
|
+
|
6
|
+
def redis
|
7
|
+
options[:redis] || Berater.redis
|
8
|
+
end
|
9
|
+
|
10
|
+
def limit
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
def overloaded?
|
15
|
+
raise NotImplementedError
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_s
|
19
|
+
"#<#{self.class}>"
|
20
|
+
end
|
21
|
+
|
22
|
+
def ==(other)
|
23
|
+
self.class == other.class &&
|
24
|
+
self.key == other.key &&
|
25
|
+
self.capacity == other.capacity &&
|
26
|
+
self.args == other.args &&
|
27
|
+
self.options == other.options &&
|
28
|
+
self.redis.connection == other.redis.connection
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.new(*)
|
32
|
+
# can only call via subclass
|
33
|
+
raise NotImplementedError if self == Berater::Limiter
|
34
|
+
|
35
|
+
super
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
|
40
|
+
attr_reader :args
|
41
|
+
|
42
|
+
def initialize(key, capacity, *args, **opts)
|
43
|
+
@key = key
|
44
|
+
self.capacity = capacity
|
45
|
+
@args = args
|
46
|
+
@options = opts
|
47
|
+
end
|
48
|
+
|
49
|
+
def capacity=(capacity)
|
50
|
+
unless capacity.is_a?(Numeric)
|
51
|
+
raise ArgumentError, "expected Numeric, found #{capacity.class}"
|
52
|
+
end
|
53
|
+
|
54
|
+
if capacity == Float::INFINITY
|
55
|
+
raise ArgumentError, 'infinite capacity not supported, use Unlimiter'
|
56
|
+
end
|
57
|
+
|
58
|
+
raise ArgumentError, 'capacity must be >= 0' unless capacity >= 0
|
59
|
+
|
60
|
+
@capacity = capacity
|
61
|
+
end
|
62
|
+
|
63
|
+
def cache_key(key)
|
64
|
+
"#{self.class}:#{key}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def yield_lock(lock, &block)
|
68
|
+
if block_given?
|
69
|
+
begin
|
70
|
+
yield lock
|
71
|
+
ensure
|
72
|
+
lock.release
|
73
|
+
end
|
74
|
+
else
|
75
|
+
lock
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|