berater 0.2.0 → 0.6.1
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 +30 -23
- data/lib/berater/concurrency_limiter.rb +58 -46
- data/lib/berater/dsl.rb +68 -0
- data/lib/berater/inhibitor.rb +5 -3
- data/lib/berater/limiter.rb +94 -0
- data/lib/berater/lock.rb +4 -14
- data/lib/berater/lua_script.rb +55 -0
- data/lib/berater/rate_limiter.rb +69 -52
- data/lib/berater/rspec.rb +14 -0
- data/lib/berater/rspec/matchers.rb +81 -0
- data/lib/berater/test_mode.rb +43 -0
- data/lib/berater/unlimiter.rb +9 -14
- data/lib/berater/utils.rb +46 -0
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +37 -28
- data/spec/concurrency_limiter_spec.rb +179 -73
- 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/{matcher_spec.rb → matchers_spec.rb} +71 -3
- data/spec/rate_limiter_spec.rb +156 -70
- data/spec/riddle_spec.rb +102 -0
- data/spec/test_mode_spec.rb +225 -0
- data/spec/unlimiter_spec.rb +5 -12
- data/spec/utils_spec.rb +78 -0
- metadata +40 -10
- data/lib/berater/base_limiter.rb +0 -26
- data/spec/concurrency_lock_spec.rb +0 -39
- data/spec/rate_lock_spec.rb +0 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c5cc50ba02c18bf9ef9f6b9c1dd6ccb8f8af0297df95757ac13406dc4539c40
|
4
|
+
data.tar.gz: f80341dccff30ca3f344f3bdf21de44b1ef1715bfaf4140933a91252ddb7231e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ada3fffc841e71498f652dc990eed482bccc942b86280fc5ee736d49fdf99d8ab2f921b5b52d96f3588c268c34343669c00e0be8822c462a3e6f9dcf954a4005
|
7
|
+
data.tar.gz: 0363abef69cfdad8140d89373b5088cfa8a6259f16716011c82ceb1e2e9e948cdb4d44ddd36aaf1d05b121dc7d275d0ee34571a0a3ee9b964f4286375ec356cf
|
data/lib/berater.rb
CHANGED
@@ -1,32 +1,40 @@
|
|
1
|
-
require 'berater/
|
1
|
+
require 'berater/limiter'
|
2
2
|
require 'berater/lock'
|
3
|
-
|
3
|
+
require 'berater/lua_script'
|
4
|
+
require 'berater/utils'
|
5
|
+
require 'berater/version'
|
4
6
|
|
5
7
|
module Berater
|
6
8
|
extend self
|
7
9
|
|
8
10
|
class Overloaded < StandardError; end
|
9
11
|
|
10
|
-
MODES = {}
|
11
|
-
|
12
12
|
attr_accessor :redis
|
13
13
|
|
14
14
|
def configure
|
15
15
|
yield self
|
16
16
|
end
|
17
17
|
|
18
|
-
def
|
19
|
-
|
20
|
-
|
21
|
-
unless klass
|
22
|
-
raise ArgumentError, "invalid mode: #{mode}"
|
23
|
-
end
|
24
|
-
|
25
|
-
klass.new(key, *args, **opts)
|
18
|
+
def reset
|
19
|
+
@redis = nil
|
26
20
|
end
|
27
21
|
|
28
|
-
def
|
29
|
-
|
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)
|
37
|
+
end
|
30
38
|
end
|
31
39
|
|
32
40
|
def expunge
|
@@ -38,18 +46,17 @@ module Berater
|
|
38
46
|
end
|
39
47
|
|
40
48
|
# convenience method
|
41
|
-
def Berater(key,
|
42
|
-
Berater.new(key,
|
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
|
43
56
|
end
|
44
57
|
|
45
|
-
# load
|
46
|
-
require 'berater/base_limiter'
|
58
|
+
# load limiters
|
47
59
|
require 'berater/concurrency_limiter'
|
48
60
|
require 'berater/inhibitor'
|
49
61
|
require 'berater/rate_limiter'
|
50
62
|
require 'berater/unlimiter'
|
51
|
-
|
52
|
-
Berater.register(:concurrency, Berater::ConcurrencyLimiter)
|
53
|
-
Berater.register(:inhibited, Berater::Inhibitor)
|
54
|
-
Berater.register(:rate, Berater::RateLimiter)
|
55
|
-
Berater.register(:unlimited, Berater::Unlimiter)
|
@@ -1,44 +1,30 @@
|
|
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
8
|
def initialize(key, capacity, **opts)
|
9
|
-
super(key, **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
|
-
private def capacity=(capacity)
|
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
14
|
private 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
|
-
|
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
|
-
LUA_SCRIPT = <<~LUA
|
20
|
+
LUA_SCRIPT = Berater::LuaScript(<<~LUA
|
36
21
|
local key = KEYS[1]
|
37
22
|
local lock_key = KEYS[2]
|
38
23
|
local capacity = tonumber(ARGV[1])
|
39
24
|
local ts = tonumber(ARGV[2])
|
40
25
|
local ttl = tonumber(ARGV[3])
|
41
|
-
local
|
26
|
+
local cost = tonumber(ARGV[4])
|
27
|
+
local lock_ids = {}
|
42
28
|
|
43
29
|
-- purge stale hosts
|
44
30
|
if ttl > 0 then
|
@@ -48,41 +34,67 @@ module Berater
|
|
48
34
|
-- check capacity
|
49
35
|
local count = redis.call('ZCARD', key)
|
50
36
|
|
51
|
-
if
|
52
|
-
--
|
53
|
-
|
54
|
-
|
55
|
-
|
37
|
+
if cost == 0 then
|
38
|
+
-- just check limit, ie. for .overlimit?
|
39
|
+
if count < capacity then
|
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 = {}
|
46
|
+
|
47
|
+
for i = lock_id - cost + 1, lock_id do
|
48
|
+
table.insert(lock_ids, i)
|
49
|
+
|
50
|
+
table.insert(locks, ts)
|
51
|
+
table.insert(locks, i)
|
52
|
+
end
|
53
|
+
|
54
|
+
redis.call('ZADD', key, unpack(locks))
|
55
|
+
count = count + cost
|
56
|
+
|
57
|
+
if ttl > 0 then
|
58
|
+
redis.call('PEXPIRE', key, ttl)
|
59
|
+
end
|
56
60
|
end
|
57
61
|
|
58
|
-
return { count,
|
62
|
+
return { count, unpack(lock_ids) }
|
59
63
|
LUA
|
64
|
+
)
|
60
65
|
|
61
|
-
def
|
62
|
-
|
63
|
-
|
66
|
+
protected def acquire_lock(capacity, cost)
|
67
|
+
# fractional cost is not supported, but make it work
|
68
|
+
capacity = capacity.to_i
|
69
|
+
cost = cost.ceil
|
70
|
+
|
71
|
+
# timestamp in milliseconds
|
72
|
+
ts = (Time.now.to_f * 10**3).to_i
|
73
|
+
|
74
|
+
count, *lock_ids = LUA_SCRIPT.eval(
|
75
|
+
redis,
|
64
76
|
[ cache_key(key), cache_key('lock_id') ],
|
65
|
-
[ capacity,
|
77
|
+
[ capacity, ts, @timeout_msec, cost ]
|
66
78
|
)
|
67
79
|
|
68
|
-
raise Incapacitated
|
69
|
-
|
70
|
-
lock = Lock.new(self, lock_id, count, -> { release(lock_id) })
|
80
|
+
raise Incapacitated if lock_ids.empty?
|
71
81
|
|
72
|
-
if
|
73
|
-
|
74
|
-
yield lock
|
75
|
-
ensure
|
76
|
-
lock.release
|
77
|
-
end
|
78
|
-
else
|
79
|
-
lock
|
82
|
+
release_fn = if cost > 0
|
83
|
+
proc { release(lock_ids) }
|
80
84
|
end
|
85
|
+
|
86
|
+
Lock.new(capacity, count, release_fn)
|
87
|
+
end
|
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
|
81
94
|
end
|
82
95
|
|
83
|
-
|
84
|
-
|
85
|
-
res == true || res == 1 # depending on which version of Redis
|
96
|
+
def to_s
|
97
|
+
"#<#{self.class}(#{key}: #{capacity} at a time)>"
|
86
98
|
end
|
87
99
|
|
88
100
|
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,13 +1,15 @@
|
|
1
1
|
module Berater
|
2
|
-
class Inhibitor <
|
2
|
+
class Inhibitor < Limiter
|
3
3
|
|
4
4
|
class Inhibited < Overloaded; end
|
5
5
|
|
6
6
|
def initialize(key = :inhibitor, *args, **opts)
|
7
|
-
super(key, **opts)
|
7
|
+
super(key, 0, **opts)
|
8
8
|
end
|
9
9
|
|
10
|
-
|
10
|
+
alias inhibited? overloaded?
|
11
|
+
|
12
|
+
protected def acquire_lock(*)
|
11
13
|
raise Inhibited
|
12
14
|
end
|
13
15
|
|
@@ -0,0 +1,94 @@
|
|
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(capacity: nil, cost: 1, &block)
|
11
|
+
capacity ||= @capacity
|
12
|
+
|
13
|
+
unless capacity.is_a?(Numeric)
|
14
|
+
raise ArgumentError, "invalid capacity: #{capacity}"
|
15
|
+
end
|
16
|
+
|
17
|
+
unless cost.is_a?(Numeric) && cost >= 0
|
18
|
+
raise ArgumentError, "invalid cost: #{cost}"
|
19
|
+
end
|
20
|
+
|
21
|
+
lock = acquire_lock(capacity, cost)
|
22
|
+
|
23
|
+
if block_given?
|
24
|
+
begin
|
25
|
+
yield lock
|
26
|
+
ensure
|
27
|
+
lock.release
|
28
|
+
end
|
29
|
+
else
|
30
|
+
lock
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def overloaded?
|
35
|
+
limit(cost: 0) { false }
|
36
|
+
rescue Overloaded
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_s
|
41
|
+
"#<#{self.class}>"
|
42
|
+
end
|
43
|
+
|
44
|
+
def ==(other)
|
45
|
+
self.class == other.class &&
|
46
|
+
self.key == other.key &&
|
47
|
+
self.capacity == other.capacity &&
|
48
|
+
self.args == other.args &&
|
49
|
+
self.options == other.options &&
|
50
|
+
self.redis.connection == other.redis.connection
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.new(*)
|
54
|
+
# can only call via subclass
|
55
|
+
raise NotImplementedError if self == Berater::Limiter
|
56
|
+
|
57
|
+
super
|
58
|
+
end
|
59
|
+
|
60
|
+
protected
|
61
|
+
|
62
|
+
attr_reader :args
|
63
|
+
|
64
|
+
def initialize(key, capacity, *args, **opts)
|
65
|
+
@key = key
|
66
|
+
self.capacity = capacity
|
67
|
+
@args = args
|
68
|
+
@options = opts
|
69
|
+
end
|
70
|
+
|
71
|
+
def capacity=(capacity)
|
72
|
+
unless capacity.is_a?(Numeric)
|
73
|
+
raise ArgumentError, "expected Numeric, found #{capacity.class}"
|
74
|
+
end
|
75
|
+
|
76
|
+
if capacity == Float::INFINITY
|
77
|
+
raise ArgumentError, 'infinite capacity not supported, use Unlimiter'
|
78
|
+
end
|
79
|
+
|
80
|
+
raise ArgumentError, 'capacity must be >= 0' unless capacity >= 0
|
81
|
+
|
82
|
+
@capacity = capacity
|
83
|
+
end
|
84
|
+
|
85
|
+
def acquire_lock(capacity, cost)
|
86
|
+
raise NotImplementedError
|
87
|
+
end
|
88
|
+
|
89
|
+
def cache_key(key)
|
90
|
+
"#{self.class}:#{key}"
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
end
|
data/lib/berater/lock.rb
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
module Berater
|
2
2
|
class Lock
|
3
3
|
|
4
|
-
attr_reader :
|
4
|
+
attr_reader :capacity, :contention
|
5
5
|
|
6
|
-
def initialize(
|
7
|
-
@
|
8
|
-
@id = id
|
6
|
+
def initialize(capacity, contention, release_fn = nil)
|
7
|
+
@capacity = capacity
|
9
8
|
@contention = contention
|
10
9
|
@locked_at = Time.now
|
11
10
|
@release_fn = release_fn
|
@@ -13,24 +12,15 @@ module Berater
|
|
13
12
|
end
|
14
13
|
|
15
14
|
def locked?
|
16
|
-
@released_at.nil?
|
17
|
-
end
|
18
|
-
|
19
|
-
def expired?
|
20
|
-
timeout > 0 && @locked_at + timeout < Time.now
|
15
|
+
@released_at.nil?
|
21
16
|
end
|
22
17
|
|
23
18
|
def release
|
24
|
-
raise 'lock expired' if expired?
|
25
19
|
raise 'lock already released' unless locked?
|
26
20
|
|
27
21
|
@released_at = Time.now
|
28
22
|
@release_fn ? @release_fn.call : true
|
29
23
|
end
|
30
24
|
|
31
|
-
private def timeout
|
32
|
-
limiter.respond_to?(:timeout) ? limiter.timeout : 0
|
33
|
-
end
|
34
|
-
|
35
25
|
end
|
36
26
|
end
|