berater 0.6.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87d3bdb61a68a8b69dd7cb285dafb174a403acf19ff7586f90ed8b7cb50b7f76
4
- data.tar.gz: fff71dd4d8ae28baf7c504efdc27df71f03219b32c1a959dddfd3d035845d058
3
+ metadata.gz: c32c1de752ccc7a2a71e4628b99f3561db623374dabaf95af39aadcc3b1a2d16
4
+ data.tar.gz: 33019954de1d79522ed7105e9f0a7da3a204df74cacd25aa4b57e749a81047bb
5
5
  SHA512:
6
- metadata.gz: c9fb5c0c7ec100d2f50a3b6c6cfa449af052c1b78cff174b545e179c5fcc1324c2a5faafc90e7f7ec64ce200e47571d51b02eb56398d716c487abc7731c0cece
7
- data.tar.gz: 148c566413d6593f229143e2871116b45947eae5b101d8bb2f59ce3bdfe89ed4ee1b7aaedfcaedbc80eef8a25a14fab83ad5e540172c41f31f27d7f296161235
6
+ metadata.gz: 89460a9a81a8d384addb095c425da0eed4fdbe0e15ecd85ee97ffcf4292441deeb34c7e4cd4591adca06096a17f7c131b79c6474d9efb2b635fbe398ad68d00e
7
+ data.tar.gz: 0a331b939e4c19817c42a8aa8fc5be7fd59d63f6becd5fe6ed1c226690d1524ef040a28d8bcb625d75452ab2f5c8539ddf69093bbd30c4ba9e3353259a3e568d
data/lib/berater.rb CHANGED
@@ -19,20 +19,25 @@ module Berater
19
19
  @redis = nil
20
20
  end
21
21
 
22
- def new(key, capacity, interval = nil, **opts)
22
+ def new(key, capacity, **opts)
23
+ args = []
24
+
23
25
  case capacity
24
- when :unlimited, Float::INFINITY
26
+ when Float::INFINITY
25
27
  Berater::Unlimiter
26
- when :inhibited, 0
28
+ when 0
27
29
  Berater::Inhibitor
28
30
  else
29
- if interval
31
+ if opts[:interval]
32
+ args << opts.delete(:interval)
30
33
  Berater::RateLimiter
31
- else
34
+ elsif opts[:timeout]
32
35
  Berater::ConcurrencyLimiter
36
+ else
37
+ Berater::StaticLimiter
33
38
  end
34
39
  end.yield_self do |klass|
35
- args = [ key, capacity, interval ].compact
40
+ args = [ key, capacity, *args ].compact
36
41
  klass.new(*args, **opts)
37
42
  end
38
43
  end
@@ -46,17 +51,13 @@ module Berater
46
51
  end
47
52
 
48
53
  # 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
54
+ def Berater(*args, **opts, &block)
55
+ Berater::Utils.convenience_fn(Berater, *args, **opts, &block)
56
56
  end
57
57
 
58
58
  # load limiters
59
59
  require 'berater/concurrency_limiter'
60
60
  require 'berater/inhibitor'
61
61
  require 'berater/rate_limiter'
62
+ require 'berater/static_limiter'
62
63
  require 'berater/unlimiter'
@@ -1,20 +1,22 @@
1
1
  module Berater
2
2
  class ConcurrencyLimiter < Limiter
3
3
 
4
- class Incapacitated < Overloaded; end
5
-
6
- attr_reader :timeout
7
-
8
4
  def initialize(key, capacity, **opts)
9
5
  super(key, capacity, **opts)
10
6
 
7
+ # truncate fractional capacity
8
+ self.capacity = capacity.to_i
9
+
11
10
  self.timeout = opts[:timeout] || 0
12
11
  end
13
12
 
13
+ def timeout
14
+ options[:timeout]
15
+ end
16
+
14
17
  private def timeout=(timeout)
15
- @timeout = timeout
16
18
  timeout = 0 if timeout == Float::INFINITY
17
- @timeout_msec = Berater::Utils.to_msec(timeout)
19
+ @timeout = Berater::Utils.to_msec(timeout)
18
20
  end
19
21
 
20
22
  LUA_SCRIPT = Berater::LuaScript(<<~LUA
@@ -28,17 +30,15 @@ module Berater
28
30
 
29
31
  -- purge stale hosts
30
32
  if ttl > 0 then
31
- redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
33
+ redis.call('ZREMRANGEBYSCORE', key, 0, ts - ttl)
32
34
  end
33
35
 
34
36
  -- check capacity
35
37
  local count = redis.call('ZCARD', key)
36
38
 
37
39
  if cost == 0 then
38
- -- just check limit, ie. for .overlimit?
39
- if count < capacity then
40
- table.insert(lock_ids, true)
41
- end
40
+ -- just checking count
41
+ table.insert(lock_ids, true)
42
42
  elseif (count + cost) <= capacity then
43
43
  -- grab locks, one per cost
44
44
  local lock_id = redis.call('INCRBY', lock_key, cost)
@@ -63,45 +63,27 @@ module Berater
63
63
  LUA
64
64
  )
65
65
 
66
- def limit(capacity: nil, cost: 1, &block)
67
- capacity ||= @capacity
68
-
69
- # since fractional cost is not supported, capacity behaves like int
66
+ protected def acquire_lock(capacity, cost)
67
+ # round fractional capacity and cost
70
68
  capacity = capacity.to_i
71
-
72
- unless cost.is_a?(Integer) && cost >= 0
73
- raise ArgumentError, "invalid cost: #{cost}"
74
- end
69
+ cost = cost.ceil
75
70
 
76
71
  # timestamp in milliseconds
77
72
  ts = (Time.now.to_f * 10**3).to_i
78
73
 
79
74
  count, *lock_ids = LUA_SCRIPT.eval(
80
75
  redis,
81
- [ cache_key(key), cache_key('lock_id') ],
82
- [ capacity, ts, @timeout_msec, cost ]
76
+ [ cache_key, self.class.cache_key('lock_id') ],
77
+ [ capacity, ts, @timeout, cost ]
83
78
  )
84
79
 
85
- raise Incapacitated if lock_ids.empty?
80
+ raise Overloaded if lock_ids.empty?
86
81
 
87
82
  release_fn = if cost > 0
88
- proc { release(lock_ids) }
83
+ proc { redis.zrem(cache_key, lock_ids) }
89
84
  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
85
 
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
86
+ Lock.new(capacity, count, release_fn)
105
87
  end
106
88
 
107
89
  def to_s
data/lib/berater/dsl.rb CHANGED
@@ -1,20 +1,21 @@
1
1
  module Berater
2
2
  module DSL
3
3
  refine Berater.singleton_class do
4
- def new(key, mode = nil, *args, **opts, &block)
5
- if mode.nil?
4
+ def new(key, capacity = nil, **opts, &block)
5
+ if capacity.nil?
6
6
  unless block_given?
7
- raise ArgumentError, 'expected either mode or block'
7
+ raise ArgumentError, 'expected either capacity or block'
8
8
  end
9
9
 
10
- mode, *args = DSL.eval(&block)
10
+ capacity, more_opts = DSL.eval(&block)
11
+ opts.merge!(more_opts) if more_opts
11
12
  else
12
13
  if block_given?
13
- raise ArgumentError, 'expected either mode or block, not both'
14
+ raise ArgumentError, 'expected either capacity or block, not both'
14
15
  end
15
16
  end
16
17
 
17
- super(key, mode, *args, **opts)
18
+ super(key, capacity, **opts)
18
19
  end
19
20
  end
20
21
 
@@ -38,13 +39,12 @@ module Berater
38
39
 
39
40
  KEYWORDS = [
40
41
  :second, :minute, :hour,
41
- :unlimited, :inhibited,
42
42
  ].freeze
43
43
 
44
44
  def install
45
45
  Integer.class_eval do
46
46
  def per(unit)
47
- [ self, unit ]
47
+ [ self, interval: unit ]
48
48
  end
49
49
  alias every per
50
50
 
@@ -1,20 +1,17 @@
1
1
  module Berater
2
2
  class Inhibitor < Limiter
3
3
 
4
- class Inhibited < Overloaded; end
5
-
6
4
  def initialize(key = :inhibitor, *args, **opts)
7
5
  super(key, 0, **opts)
8
6
  end
9
7
 
10
- def limit(**opts)
11
- raise Inhibited
8
+ def to_s
9
+ "#<#{self.class}>"
12
10
  end
13
11
 
14
- def overloaded?
15
- true
12
+ protected def acquire_lock(*)
13
+ raise Overloaded
16
14
  end
17
- alias inhibited? overloaded?
18
15
 
19
16
  end
20
17
  end
@@ -7,16 +7,40 @@ module Berater
7
7
  options[:redis] || Berater.redis
8
8
  end
9
9
 
10
- def limit
11
- raise NotImplementedError
12
- end
10
+ def limit(capacity: nil, cost: 1, &block)
11
+ capacity ||= @capacity
13
12
 
14
- def overloaded?
15
- raise NotImplementedError
13
+ unless capacity.is_a?(Numeric) && capacity >= 0
14
+ raise ArgumentError, "invalid capacity: #{capacity}"
15
+ end
16
+
17
+ unless cost.is_a?(Numeric) && cost >= 0 && cost < Float::INFINITY
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
16
32
  end
17
33
 
18
- def to_s
19
- "#<#{self.class}>"
34
+ def utilization
35
+ lock = limit(cost: 0)
36
+
37
+ if lock.capacity == 0
38
+ 1.0
39
+ else
40
+ lock.contention.to_f / lock.capacity
41
+ end
42
+ rescue Berater::Overloaded
43
+ 1.0
20
44
  end
21
45
 
22
46
  def ==(other)
@@ -30,7 +54,7 @@ module Berater
30
54
 
31
55
  def self.new(*)
32
56
  # can only call via subclass
33
- raise NotImplementedError if self == Berater::Limiter
57
+ raise NoMethodError if self == Berater::Limiter
34
58
 
35
59
  super
36
60
  end
@@ -60,19 +84,25 @@ module Berater
60
84
  @capacity = capacity
61
85
  end
62
86
 
63
- def cache_key(key)
64
- "#{self.class}:#{key}"
87
+ def acquire_lock(capacity, cost)
88
+ raise NotImplementedError
65
89
  end
66
90
 
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
91
+ def cache_key(subkey = nil)
92
+ instance_key = subkey.nil? ? key : "#{key}:#{subkey}"
93
+ self.class.cache_key(instance_key)
94
+ end
95
+
96
+ def self.cache_key(key)
97
+ klass = to_s.split(':')[-1]
98
+ "Berater:#{klass}:#{key}"
99
+ end
100
+
101
+ def self.inherited(subclass)
102
+ # automagically create convenience method
103
+ name = subclass.to_s.split(':')[-1]
104
+ Berater.define_singleton_method(name) do |*args, **opts, &block|
105
+ Berater::Utils.convenience_fn(subclass, *args, **opts, &block)
76
106
  end
77
107
  end
78
108
 
data/lib/berater/lock.rb CHANGED
@@ -19,7 +19,8 @@ module Berater
19
19
  raise 'lock already released' unless locked?
20
20
 
21
21
  @released_at = Time.now
22
- @release_fn ? @release_fn.call : true
22
+ @release_fn&.call
23
+ nil
23
24
  end
24
25
 
25
26
  end
@@ -1,20 +1,19 @@
1
1
  module Berater
2
2
  class RateLimiter < Limiter
3
3
 
4
- class Overrated < Overloaded; end
5
-
6
- attr_accessor :interval
7
-
8
4
  def initialize(key, capacity, interval, **opts)
5
+ super(key, capacity, interval, **opts)
9
6
  self.interval = interval
10
- super(key, capacity, @interval_msec, **opts)
7
+ end
8
+
9
+ def interval
10
+ args[0]
11
11
  end
12
12
 
13
13
  private def interval=(interval)
14
- @interval = interval
15
- @interval_msec = Berater::Utils.to_msec(interval)
14
+ @interval = Berater::Utils.to_msec(interval)
16
15
 
17
- unless @interval_msec > 0
16
+ unless @interval > 0
18
17
  raise ArgumentError, 'interval must be > 0'
19
18
  end
20
19
  end
@@ -26,24 +25,32 @@ module Berater
26
25
  local capacity = tonumber(ARGV[2])
27
26
  local interval_msec = tonumber(ARGV[3])
28
27
  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
28
 
39
- -- adjust for time passing
40
- local drips = math.floor((ts - last_ts) / msec_per_drip)
41
- count = math.max(0, count - drips)
29
+ local allowed -- whether lock was acquired
30
+ local count -- capacity being utilized
31
+ local msec_per_drip = interval_msec / capacity
32
+ local state = redis.call('GET', key)
33
+
34
+ if state then
35
+ local last_ts -- timestamp of last update
36
+ count, last_ts = string.match(state, '([%d.]+);(%w+)')
37
+ count = tonumber(count)
38
+ last_ts = tonumber(last_ts, 16)
39
+
40
+ -- adjust for time passing, guarding against clock skew
41
+ if ts > last_ts then
42
+ local drips = math.floor((ts - last_ts) / msec_per_drip)
43
+ count = math.max(0, count - drips)
44
+ else
45
+ ts = last_ts
46
+ end
47
+ else
48
+ count = 0
42
49
  end
43
50
 
44
51
  if cost == 0 then
45
- -- just check limit, ie. for .overlimit?
46
- allowed = count < capacity
52
+ -- just checking count
53
+ allowed = true
47
54
  else
48
55
  allowed = (count + cost) <= capacity
49
56
 
@@ -52,41 +59,34 @@ module Berater
52
59
 
53
60
  -- time for bucket to empty, in milliseconds
54
61
  local ttl = math.ceil(count * msec_per_drip)
62
+ ttl = ttl + 100 -- margin of error, for clock skew
55
63
 
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)
64
+ -- update count and last_ts, with expiration
65
+ state = string.format('%f;%X', count, ts)
66
+ redis.call('SET', key, state, 'PX', ttl)
59
67
  end
60
68
  end
61
69
 
62
- return { count, allowed }
70
+ return { tostring(count), allowed }
63
71
  LUA
64
72
  )
65
73
 
66
- def limit(capacity: nil, cost: 1, &block)
67
- capacity ||= @capacity
68
-
74
+ protected def acquire_lock(capacity, cost)
69
75
  # timestamp in milliseconds
70
76
  ts = (Time.now.to_f * 10**3).to_i
71
77
 
72
78
  count, allowed = LUA_SCRIPT.eval(
73
79
  redis,
74
- [ cache_key(key), cache_key("#{key}-ts") ],
75
- [ ts, capacity, @interval_msec, cost ]
80
+ [ cache_key ],
81
+ [ ts, capacity, @interval, cost ]
76
82
  )
77
83
 
78
- raise Overrated unless allowed
84
+ count = count.include?('.') ? count.to_f : count.to_i
79
85
 
80
- lock = Lock.new(capacity, count)
81
- yield_lock(lock, &block)
82
- end
86
+ raise Overloaded unless allowed
83
87
 
84
- def overloaded?
85
- limit(cost: 0) { false }
86
- rescue Overrated
87
- true
88
+ Lock.new(capacity, count)
88
89
  end
89
- alias overrated? overloaded?
90
90
 
91
91
  def to_s
92
92
  msg = if interval.is_a? Numeric