berater 0.1.2 → 0.4.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 -20
- data/lib/berater/concurrency_limiter.rb +26 -88
- data/lib/berater/dsl.rb +57 -0
- data/lib/berater/inhibitor.rb +4 -10
- data/lib/berater/limiter.rb +30 -0
- data/lib/berater/lock.rb +36 -0
- data/lib/berater/rate_limiter.rb +76 -26
- data/lib/berater/rspec.rb +12 -0
- data/lib/berater/rspec/matchers.rb +60 -0
- data/lib/berater/test_mode.rb +43 -0
- data/lib/berater/unlimiter.rb +14 -10
- data/lib/berater/version.rb +1 -1
- data/spec/berater_spec.rb +69 -90
- data/spec/concurrency_limiter_spec.rb +63 -74
- data/spec/inhibitor_spec.rb +3 -15
- data/spec/{matcher_spec.rb → matchers_spec.rb} +4 -4
- data/spec/rate_limiter_spec.rb +83 -58
- data/spec/test_mode_spec.rb +183 -0
- data/spec/unlimiter_spec.rb +6 -31
- metadata +12 -7
- data/lib/berater/base_limiter.rb +0 -32
- data/spec/concurrency_lock_spec.rb +0 -50
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f57aeaa9319ae38bdd7d2468443fc5c9ea9ba44cecf47bc5a558f18ecd0ab274
|
4
|
+
data.tar.gz: ff14ef90856911e9e18cbfbad6f9799727c999697c5e4d96f47b5fbf665c725c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c9d3abeeb9d35c608c194eaf505c5af1350a82583dd12257e70b9d4f96bd3a6cd30db802f57c3bf77d371b5deb5e856e9238eadd3990768205fd37ea3e2e3703
|
7
|
+
data.tar.gz: c98078a3c7d3a3eb4e2d523577f9d6dd7f1bbae76c9ecbd8d65961cc7aa1bab6e1a4b8fe84cfb30ee60f4d0ce11dcb58e3b94a3566cf50ee60d1f8e21bab82b1
|
data/lib/berater.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'berater/version'
|
2
|
+
require 'berater/lock'
|
2
3
|
|
3
4
|
|
4
5
|
module Berater
|
@@ -8,41 +9,42 @@ module Berater
|
|
8
9
|
|
9
10
|
MODES = {}
|
10
11
|
|
11
|
-
attr_accessor :redis
|
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 new(mode, *args, **opts)
|
18
|
+
def new(key, mode = nil, *args, **opts, &block)
|
19
|
+
if mode.nil?
|
20
|
+
unless args.empty?
|
21
|
+
raise ArgumentError, '0 arguments expected with block'
|
22
|
+
end
|
23
|
+
|
24
|
+
unless block_given?
|
25
|
+
raise ArgumentError, 'expected either mode or block'
|
26
|
+
end
|
27
|
+
|
28
|
+
mode, *args = DSL.eval(&block)
|
29
|
+
else
|
30
|
+
if block_given?
|
31
|
+
raise ArgumentError, 'expected either mode or block, not both'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
20
35
|
klass = MODES[mode.to_sym]
|
21
36
|
|
22
37
|
unless klass
|
23
38
|
raise ArgumentError, "invalid mode: #{mode}"
|
24
39
|
end
|
25
40
|
|
26
|
-
klass.new(*args, **opts)
|
41
|
+
klass.new(key, *args, **opts)
|
27
42
|
end
|
28
43
|
|
29
44
|
def register(mode, klass)
|
30
45
|
MODES[mode.to_sym] = klass
|
31
46
|
end
|
32
47
|
|
33
|
-
def mode=(mode)
|
34
|
-
unless MODES.include? mode.to_sym
|
35
|
-
raise ArgumentError, "invalid mode: #{mode}"
|
36
|
-
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
|
-
end
|
45
|
-
|
46
48
|
def expunge
|
47
49
|
redis.scan_each(match: "#{self.name}*") do |key|
|
48
50
|
redis.del key
|
@@ -51,8 +53,13 @@ module Berater
|
|
51
53
|
|
52
54
|
end
|
53
55
|
|
54
|
-
#
|
55
|
-
|
56
|
+
# convenience method
|
57
|
+
def Berater(key, mode, *args, **opts, &block)
|
58
|
+
Berater.new(key, mode, *args, **opts).limit(&block)
|
59
|
+
end
|
60
|
+
|
61
|
+
# load limiters
|
62
|
+
require 'berater/limiter'
|
56
63
|
require 'berater/concurrency_limiter'
|
57
64
|
require 'berater/inhibitor'
|
58
65
|
require 'berater/rate_limiter'
|
@@ -62,3 +69,5 @@ Berater.register(:concurrency, Berater::ConcurrencyLimiter)
|
|
62
69
|
Berater.register(:inhibited, Berater::Inhibitor)
|
63
70
|
Berater.register(:rate, Berater::RateLimiter)
|
64
71
|
Berater.register(:unlimited, Berater::Unlimiter)
|
72
|
+
|
73
|
+
require 'berater/dsl'
|
@@ -1,18 +1,18 @@
|
|
1
1
|
module Berater
|
2
|
-
class ConcurrencyLimiter <
|
2
|
+
class ConcurrencyLimiter < Limiter
|
3
3
|
|
4
4
|
class Incapacitated < Overloaded; end
|
5
5
|
|
6
6
|
attr_reader :capacity, :timeout
|
7
7
|
|
8
|
-
def initialize(capacity, **opts)
|
9
|
-
super(**opts)
|
8
|
+
def initialize(key, capacity, **opts)
|
9
|
+
super(key, **opts)
|
10
10
|
|
11
11
|
self.capacity = capacity
|
12
12
|
self.timeout = opts[:timeout] || 0
|
13
13
|
end
|
14
14
|
|
15
|
-
def capacity=(capacity)
|
15
|
+
private def capacity=(capacity)
|
16
16
|
unless capacity.is_a? Integer
|
17
17
|
raise ArgumentError, "expected Integer, found #{capacity.class}"
|
18
18
|
end
|
@@ -22,7 +22,7 @@ module Berater
|
|
22
22
|
@capacity = capacity
|
23
23
|
end
|
24
24
|
|
25
|
-
def timeout=(timeout)
|
25
|
+
private def timeout=(timeout)
|
26
26
|
unless timeout.is_a? Integer
|
27
27
|
raise ArgumentError, "expected Integer, found #{timeout.class}"
|
28
28
|
end
|
@@ -32,124 +32,62 @@ module Berater
|
|
32
32
|
@timeout = timeout
|
33
33
|
end
|
34
34
|
|
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
|
-
@locked_at + limiter.timeout < Time.now
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
LUA_SCRIPT = <<~LUA.gsub(/^\s*(--.*\n)?/, '')
|
35
|
+
LUA_SCRIPT = <<~LUA.gsub(/^\s*|\s*--.*/, '')
|
63
36
|
local key = KEYS[1]
|
37
|
+
local lock_key = KEYS[2]
|
64
38
|
local capacity = tonumber(ARGV[1])
|
65
39
|
local ts = tonumber(ARGV[2])
|
66
40
|
local ttl = tonumber(ARGV[3])
|
67
|
-
|
68
|
-
local exists
|
69
|
-
local count
|
70
41
|
local lock
|
71
|
-
local ts = unpack(redis.call('TIME'))
|
72
42
|
|
73
|
-
--
|
74
|
-
if ttl
|
75
|
-
|
76
|
-
else
|
77
|
-
-- and refresh TTL while we're at it
|
78
|
-
exists = redis.call('EXPIRE', key, ttl * 2)
|
43
|
+
-- purge stale hosts
|
44
|
+
if ttl > 0 then
|
45
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
79
46
|
end
|
80
47
|
|
81
|
-
|
82
|
-
|
83
|
-
if ttl > 0 then
|
84
|
-
redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
|
85
|
-
end
|
86
|
-
|
87
|
-
-- check capacity (subtract one for next lock entry)
|
88
|
-
count = redis.call('ZCARD', key) - 1
|
89
|
-
|
90
|
-
if count < capacity then
|
91
|
-
-- yay, grab a lock
|
92
|
-
|
93
|
-
-- regenerate next lock entry, which has score inf
|
94
|
-
lock = unpack(redis.call('ZPOPMAX', key))
|
95
|
-
redis.call('ZADD', key, 'inf', (lock + 1) % 2^52)
|
96
|
-
|
97
|
-
count = count + 1
|
98
|
-
end
|
99
|
-
else
|
100
|
-
count = 1
|
101
|
-
lock = "1"
|
102
|
-
|
103
|
-
-- create structure to track locks and next id
|
104
|
-
redis.call('ZADD', key, 'inf', lock + 1)
|
48
|
+
-- check capacity
|
49
|
+
local count = redis.call('ZCARD', key)
|
105
50
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
end
|
110
|
-
|
111
|
-
if lock then
|
112
|
-
-- store lock and timestamp
|
51
|
+
if count < capacity then
|
52
|
+
-- grab a lock
|
53
|
+
lock = redis.call('INCR', lock_key)
|
113
54
|
redis.call('ZADD', key, ts, lock)
|
55
|
+
count = count + 1
|
114
56
|
end
|
115
57
|
|
116
58
|
return { count, lock }
|
117
59
|
LUA
|
118
60
|
|
119
|
-
def limit
|
120
|
-
unless opts.empty?
|
121
|
-
return self.class.new(
|
122
|
-
capacity,
|
123
|
-
**options.merge(opts)
|
124
|
-
# **options.merge(timeout: timeout).merge(opts)
|
125
|
-
).limit(&block)
|
126
|
-
end
|
127
|
-
|
61
|
+
def limit
|
128
62
|
count, lock_id = redis.eval(
|
129
63
|
LUA_SCRIPT,
|
130
|
-
[ key ],
|
64
|
+
[ cache_key(key), cache_key('lock_id') ],
|
131
65
|
[ capacity, Time.now.to_i, timeout ]
|
132
66
|
)
|
133
67
|
|
134
68
|
raise Incapacitated unless lock_id
|
135
69
|
|
136
|
-
lock = Lock.new(self, lock_id, count)
|
70
|
+
lock = Lock.new(self, lock_id, count, -> { release(lock_id) })
|
137
71
|
|
138
72
|
if block_given?
|
139
73
|
begin
|
140
74
|
yield lock
|
141
75
|
ensure
|
142
|
-
release
|
76
|
+
lock.release
|
143
77
|
end
|
144
78
|
else
|
145
79
|
lock
|
146
80
|
end
|
147
81
|
end
|
148
82
|
|
149
|
-
def release(
|
150
|
-
res = redis.zrem(key,
|
83
|
+
private def release(lock_id)
|
84
|
+
res = redis.zrem(cache_key(key), lock_id)
|
151
85
|
res == true || res == 1 # depending on which version of Redis
|
152
86
|
end
|
153
87
|
|
88
|
+
def to_s
|
89
|
+
"#<#{self.class}(#{key}: #{capacity} at a time)>"
|
90
|
+
end
|
91
|
+
|
154
92
|
end
|
155
93
|
end
|
data/lib/berater/dsl.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
module Berater
|
2
|
+
module DSL
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def eval &block
|
6
|
+
@keywords ||= Class.new do
|
7
|
+
# create a class where DSL keywords are methods
|
8
|
+
KEYWORDS.each do |keyword|
|
9
|
+
define_singleton_method(keyword) { keyword }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
install
|
14
|
+
@keywords.class_eval &block
|
15
|
+
ensure
|
16
|
+
uninstall
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def each &block
|
22
|
+
Berater::MODES.map do |mode, limiter|
|
23
|
+
next unless limiter.const_defined?(:DSL, false)
|
24
|
+
limiter.const_get(:DSL)
|
25
|
+
end.compact.each(&block)
|
26
|
+
end
|
27
|
+
|
28
|
+
KEYWORDS = [
|
29
|
+
:second, :minute, :hour,
|
30
|
+
:unlimited, :inhibited,
|
31
|
+
].freeze
|
32
|
+
|
33
|
+
def install
|
34
|
+
Integer.class_eval do
|
35
|
+
def per(unit)
|
36
|
+
[ :rate, self, unit ]
|
37
|
+
end
|
38
|
+
alias every per
|
39
|
+
|
40
|
+
def at_once
|
41
|
+
[ :concurrency, self ]
|
42
|
+
end
|
43
|
+
alias concurrently at_once
|
44
|
+
alias at_a_time at_once
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def uninstall
|
49
|
+
Integer.remove_method :per
|
50
|
+
Integer.remove_method :every
|
51
|
+
|
52
|
+
Integer.remove_method :at_once
|
53
|
+
Integer.remove_method :concurrently
|
54
|
+
Integer.remove_method :at_a_time
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/berater/inhibitor.rb
CHANGED
@@ -1,19 +1,13 @@
|
|
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, **opts)
|
8
8
|
end
|
9
9
|
|
10
|
-
def limit
|
11
|
-
unless opts.empty?
|
12
|
-
return self.class.new(
|
13
|
-
**options.merge(opts)
|
14
|
-
).limit(&block)
|
15
|
-
end
|
16
|
-
|
10
|
+
def limit
|
17
11
|
raise Inhibited
|
18
12
|
end
|
19
13
|
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Berater
|
2
|
+
class Limiter
|
3
|
+
|
4
|
+
attr_reader :key, :options
|
5
|
+
|
6
|
+
def redis
|
7
|
+
options[:redis] || Berater.redis
|
8
|
+
end
|
9
|
+
|
10
|
+
def limit
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"#<#{self.class}>"
|
16
|
+
end
|
17
|
+
|
18
|
+
protected
|
19
|
+
|
20
|
+
def initialize(key, **opts)
|
21
|
+
@key = key
|
22
|
+
@options = opts
|
23
|
+
end
|
24
|
+
|
25
|
+
def cache_key(key)
|
26
|
+
"#{self.class}:#{key}"
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
data/lib/berater/lock.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module Berater
|
2
|
+
class Lock
|
3
|
+
|
4
|
+
attr_reader :limiter, :id, :contention
|
5
|
+
|
6
|
+
def initialize(limiter, id, contention, release_fn = nil)
|
7
|
+
@limiter = limiter
|
8
|
+
@id = id
|
9
|
+
@contention = contention
|
10
|
+
@locked_at = Time.now
|
11
|
+
@release_fn = release_fn
|
12
|
+
@released_at = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def locked?
|
16
|
+
@released_at.nil? && !expired?
|
17
|
+
end
|
18
|
+
|
19
|
+
def expired?
|
20
|
+
timeout ? @locked_at + timeout < Time.now : false
|
21
|
+
end
|
22
|
+
|
23
|
+
def release
|
24
|
+
raise 'lock expired' if expired?
|
25
|
+
raise 'lock already released' unless locked?
|
26
|
+
|
27
|
+
@released_at = Time.now
|
28
|
+
@release_fn ? @release_fn.call : true
|
29
|
+
end
|
30
|
+
|
31
|
+
def timeout
|
32
|
+
limiter.respond_to?(:timeout) ? limiter.timeout : nil
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
data/lib/berater/rate_limiter.rb
CHANGED
@@ -1,18 +1,18 @@
|
|
1
1
|
module Berater
|
2
|
-
class RateLimiter <
|
2
|
+
class RateLimiter < Limiter
|
3
3
|
|
4
4
|
class Overrated < Overloaded; end
|
5
5
|
|
6
6
|
attr_accessor :count, :interval
|
7
7
|
|
8
|
-
def initialize(count, interval, **opts)
|
9
|
-
super(**opts)
|
8
|
+
def initialize(key, count, interval, **opts)
|
9
|
+
super(key, **opts)
|
10
10
|
|
11
11
|
self.count = count
|
12
12
|
self.interval = interval
|
13
13
|
end
|
14
14
|
|
15
|
-
def count=(count)
|
15
|
+
private def count=(count)
|
16
16
|
unless count.is_a? Integer
|
17
17
|
raise ArgumentError, "expected Integer, found #{count.class}"
|
18
18
|
end
|
@@ -22,12 +22,13 @@ module Berater
|
|
22
22
|
@count = count
|
23
23
|
end
|
24
24
|
|
25
|
-
def interval=(interval)
|
25
|
+
private def interval=(interval)
|
26
26
|
@interval = interval.dup
|
27
27
|
|
28
28
|
case @interval
|
29
29
|
when Integer
|
30
30
|
raise ArgumentError, "interval must be >= 0" unless @interval >= 0
|
31
|
+
@interval_sec = @interval
|
31
32
|
when String
|
32
33
|
@interval = @interval.to_sym
|
33
34
|
when Symbol
|
@@ -38,47 +39,96 @@ module Berater
|
|
38
39
|
if @interval.is_a? Symbol
|
39
40
|
case @interval
|
40
41
|
when :sec, :second, :seconds
|
41
|
-
@interval =
|
42
|
+
@interval = :second
|
43
|
+
@interval_sec = 1
|
42
44
|
when :min, :minute, :minutes
|
43
|
-
@interval =
|
45
|
+
@interval = :minute
|
46
|
+
@interval_sec = 60
|
44
47
|
when :hour, :hours
|
45
|
-
@interval =
|
48
|
+
@interval = :hour
|
49
|
+
@interval_sec = 60 * 60
|
46
50
|
else
|
47
51
|
raise ArgumentError, "unexpected interval value: #{interval}"
|
48
52
|
end
|
49
53
|
end
|
50
|
-
|
51
|
-
@interval
|
52
54
|
end
|
53
55
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
56
|
+
LUA_SCRIPT = <<~LUA.gsub(/^\s*|\s*--.*/, '')
|
57
|
+
local key = KEYS[1]
|
58
|
+
local ts_key = KEYS[2]
|
59
|
+
local ts = tonumber(ARGV[1])
|
60
|
+
local capacity = tonumber(ARGV[2])
|
61
|
+
local usec_per_drip = tonumber(ARGV[3])
|
62
|
+
local count = 0
|
63
|
+
|
64
|
+
-- timestamp of last update
|
65
|
+
local last_ts = tonumber(redis.call('GET', ts_key))
|
66
|
+
|
67
|
+
if last_ts then
|
68
|
+
count = tonumber(redis.call('GET', key)) or 0
|
69
|
+
|
70
|
+
-- adjust for time passing
|
71
|
+
local drips = math.floor((ts - last_ts) / usec_per_drip)
|
72
|
+
count = math.max(0, count - drips)
|
61
73
|
end
|
62
74
|
|
63
|
-
|
75
|
+
local allowed = count + 1 <= capacity
|
76
|
+
|
77
|
+
if allowed then
|
78
|
+
count = count + 1
|
64
79
|
|
65
|
-
|
66
|
-
|
80
|
+
-- time for bucket to empty, in milliseconds
|
81
|
+
local ttl = math.ceil((count * usec_per_drip) / 1000)
|
67
82
|
|
68
|
-
|
69
|
-
redis.
|
70
|
-
redis.
|
83
|
+
-- update count and last_ts, with expirations
|
84
|
+
redis.call('SET', key, count, 'PX', ttl)
|
85
|
+
redis.call('SET', ts_key, ts, 'PX', ttl)
|
71
86
|
end
|
72
87
|
|
73
|
-
|
88
|
+
return { count, allowed }
|
89
|
+
LUA
|
90
|
+
|
91
|
+
def limit
|
92
|
+
usec_per_drip = (@interval_sec * 10**6) / @count
|
93
|
+
|
94
|
+
# timestamp in microseconds
|
95
|
+
ts = (Time.now.to_f * 10**6).to_i
|
96
|
+
|
97
|
+
count, allowed = redis.eval(
|
98
|
+
LUA_SCRIPT,
|
99
|
+
[ cache_key(key), cache_key("#{key}-ts") ],
|
100
|
+
[ ts, @count, usec_per_drip ]
|
101
|
+
)
|
102
|
+
|
103
|
+
raise Overrated unless allowed
|
104
|
+
|
105
|
+
lock = Lock.new(self, "#{ts}-#{count}", count)
|
74
106
|
|
75
107
|
if block_given?
|
76
|
-
|
108
|
+
begin
|
109
|
+
yield lock
|
110
|
+
ensure
|
111
|
+
lock.release
|
112
|
+
end
|
77
113
|
else
|
78
|
-
|
114
|
+
lock
|
79
115
|
end
|
80
116
|
end
|
81
117
|
|
118
|
+
def to_s
|
119
|
+
msg = if @interval.is_a? Integer
|
120
|
+
if @interval == 1
|
121
|
+
"every second"
|
122
|
+
else
|
123
|
+
"every #{@interval} seconds"
|
124
|
+
end
|
125
|
+
else
|
126
|
+
"per #{@interval}"
|
127
|
+
end
|
128
|
+
|
129
|
+
"#<#{self.class}(#{key}: #{count} #{msg})>"
|
130
|
+
end
|
131
|
+
|
82
132
|
end
|
83
133
|
end
|
84
134
|
|