berater 0.6.1 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c5cc50ba02c18bf9ef9f6b9c1dd6ccb8f8af0297df95757ac13406dc4539c40
4
- data.tar.gz: f80341dccff30ca3f344f3bdf21de44b1ef1715bfaf4140933a91252ddb7231e
3
+ metadata.gz: 476b59e3f1e27908f5c8a5097087e6c8c2384531e1284d79b9d89a73fbbb5840
4
+ data.tar.gz: de50fdcab6ea7dc9520fcf1aefdd07b1f8775c0ce96a30e3f1d45b6ccfb7b00f
5
5
  SHA512:
6
- metadata.gz: ada3fffc841e71498f652dc990eed482bccc942b86280fc5ee736d49fdf99d8ab2f921b5b52d96f3588c268c34343669c00e0be8822c462a3e6f9dcf954a4005
7
- data.tar.gz: 0363abef69cfdad8140d89373b5088cfa8a6259f16716011c82ceb1e2e9e948cdb4d44ddd36aaf1d05b121dc7d275d0ee34571a0a3ee9b964f4286375ec356cf
6
+ metadata.gz: 2446e64f528a8ef1d37791922d37cddcebdf702f3c5beff2ef7ea9d611ce4cc6d6c40ac42e1ea826987a2eb2ceea3ddf97e420bb7369686b8239b260a1725c55
7
+ data.tar.gz: 23dccad7e02cc425631ceeb1dfbc6960b94ac92c523d840a991917af02caaf39a7befc5887fe45631f565ddb2eddb4850e557348ab29427d3cbae1db89124c3c
data/lib/berater.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  require 'berater/limiter'
2
+ require 'berater/limiter_set'
2
3
  require 'berater/lock'
3
4
  require 'berater/lua_script'
4
5
  require 'berater/utils'
5
6
  require 'berater/version'
7
+ require 'meddleware'
6
8
 
7
9
  module Berater
8
10
  extend self
@@ -15,24 +17,35 @@ module Berater
15
17
  yield self
16
18
  end
17
19
 
18
- def reset
19
- @redis = nil
20
+ def limiters
21
+ @limiters ||= LimiterSet.new
22
+ end
23
+
24
+ def middleware(&block)
25
+ (@middleware ||= Meddleware.new).tap do
26
+ @middleware.instance_eval(&block) if block_given?
27
+ end
20
28
  end
21
29
 
22
- def new(key, capacity, interval = nil, **opts)
30
+ def new(key, capacity, **opts)
31
+ args = []
32
+
23
33
  case capacity
24
- when :unlimited, Float::INFINITY
34
+ when Float::INFINITY
25
35
  Berater::Unlimiter
26
- when :inhibited, 0
36
+ when 0
27
37
  Berater::Inhibitor
28
38
  else
29
- if interval
39
+ if opts[:interval]
40
+ args << opts.delete(:interval)
30
41
  Berater::RateLimiter
31
- else
42
+ elsif opts[:timeout]
32
43
  Berater::ConcurrencyLimiter
44
+ else
45
+ Berater::StaticLimiter
33
46
  end
34
47
  end.yield_self do |klass|
35
- args = [ key, capacity, interval ].compact
48
+ args = [ key, capacity, *args ].compact
36
49
  klass.new(*args, **opts)
37
50
  end
38
51
  end
@@ -43,20 +56,21 @@ module Berater
43
56
  end
44
57
  end
45
58
 
59
+ def reset
60
+ @redis = nil
61
+ limiters.clear
62
+ middleware.clear
63
+ end
46
64
  end
47
65
 
48
66
  # 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
67
+ def Berater(*args, **opts, &block)
68
+ Berater::Utils.convenience_fn(Berater, *args, **opts, &block)
56
69
  end
57
70
 
58
71
  # load limiters
59
72
  require 'berater/concurrency_limiter'
60
73
  require 'berater/inhibitor'
61
74
  require 'berater/rate_limiter'
75
+ require 'berater/static_limiter'
62
76
  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)
@@ -64,7 +64,7 @@ module Berater
64
64
  )
65
65
 
66
66
  protected def acquire_lock(capacity, cost)
67
- # fractional cost is not supported, but make it work
67
+ # round fractional capacity and cost
68
68
  capacity = capacity.to_i
69
69
  cost = cost.ceil
70
70
 
@@ -73,26 +73,19 @@ module Berater
73
73
 
74
74
  count, *lock_ids = LUA_SCRIPT.eval(
75
75
  redis,
76
- [ cache_key(key), cache_key('lock_id') ],
77
- [ capacity, ts, @timeout_msec, cost ]
76
+ [ cache_key, self.class.cache_key('lock_id') ],
77
+ [ capacity, ts, @timeout, cost ]
78
78
  )
79
79
 
80
- raise Incapacitated if lock_ids.empty?
80
+ raise Overloaded if lock_ids.empty?
81
81
 
82
82
  release_fn = if cost > 0
83
- proc { release(lock_ids) }
83
+ proc { redis.zrem(cache_key, lock_ids) }
84
84
  end
85
85
 
86
86
  Lock.new(capacity, count, release_fn)
87
87
  end
88
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
94
- end
95
-
96
89
  def to_s
97
90
  "#<#{self.class}(#{key}: #{capacity} at a time)>"
98
91
  end
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,16 +1,16 @@
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
- alias inhibited? overloaded?
8
+ def to_s
9
+ "#<#{self.class}>"
10
+ end
11
11
 
12
12
  protected def acquire_lock(*)
13
- raise Inhibited
13
+ raise Overloaded
14
14
  end
15
15
 
16
16
  end
@@ -9,17 +9,12 @@ module Berater
9
9
 
10
10
  def limit(capacity: nil, cost: 1, &block)
11
11
  capacity ||= @capacity
12
+ lock = nil
12
13
 
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}"
14
+ Berater.middleware.call(self, capacity: capacity, cost: cost) do |limiter, **opts|
15
+ lock = limiter.inner_limit(**opts)
19
16
  end
20
17
 
21
- lock = acquire_lock(capacity, cost)
22
-
23
18
  if block_given?
24
19
  begin
25
20
  yield lock
@@ -31,14 +26,33 @@ module Berater
31
26
  end
32
27
  end
33
28
 
34
- def overloaded?
35
- limit(cost: 0) { false }
36
- rescue Overloaded
37
- true
29
+ protected def inner_limit(capacity:, cost:)
30
+ unless capacity.is_a?(Numeric) && capacity >= 0
31
+ raise ArgumentError, "invalid capacity: #{capacity}"
32
+ end
33
+
34
+ unless cost.is_a?(Numeric) && cost >= 0 && cost < Float::INFINITY
35
+ raise ArgumentError, "invalid cost: #{cost}"
36
+ end
37
+
38
+ acquire_lock(capacity, cost)
39
+ rescue NoMethodError => e
40
+ raise unless e.message.include?("undefined method `evalsha' for")
41
+
42
+ # repackage error so it's easier to understand
43
+ raise RuntimeError, "invalid redis connection: #{redis}"
38
44
  end
39
45
 
40
- def to_s
41
- "#<#{self.class}>"
46
+ def utilization
47
+ lock = limit(cost: 0)
48
+
49
+ if lock.capacity == 0
50
+ 1.0
51
+ else
52
+ lock.contention.to_f / lock.capacity
53
+ end
54
+ rescue Berater::Overloaded
55
+ 1.0
42
56
  end
43
57
 
44
58
  def ==(other)
@@ -50,13 +64,6 @@ module Berater
50
64
  self.redis.connection == other.redis.connection
51
65
  end
52
66
 
53
- def self.new(*)
54
- # can only call via subclass
55
- raise NotImplementedError if self == Berater::Limiter
56
-
57
- super
58
- end
59
-
60
67
  protected
61
68
 
62
69
  attr_reader :args
@@ -86,8 +93,33 @@ module Berater
86
93
  raise NotImplementedError
87
94
  end
88
95
 
89
- def cache_key(key)
90
- "#{self.class}:#{key}"
96
+ def cache_key(subkey = nil)
97
+ instance_key = subkey.nil? ? key : "#{key}:#{subkey}"
98
+ self.class.cache_key(instance_key)
99
+ end
100
+
101
+ class << self
102
+ def new(*)
103
+ # can only call via subclass
104
+ raise NoMethodError if self == Berater::Limiter
105
+
106
+ super
107
+ end
108
+
109
+ def cache_key(key)
110
+ klass = to_s.split(':')[-1]
111
+ "Berater:#{klass}:#{key}"
112
+ end
113
+
114
+ protected
115
+
116
+ def inherited(subclass)
117
+ # automagically create convenience method
118
+ name = subclass.to_s.split(':')[-1]
119
+ Berater.define_singleton_method(name) do |*args, **opts, &block|
120
+ Berater::Utils.convenience_fn(subclass, *args, **opts, &block)
121
+ end
122
+ end
91
123
  end
92
124
 
93
125
  end
@@ -0,0 +1,66 @@
1
+ module Berater
2
+ private
3
+
4
+ class LimiterSet
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @limiters = {}
9
+ end
10
+
11
+ def each(&block)
12
+ @limiters.each_value(&block)
13
+ end
14
+
15
+ def <<(limiter)
16
+ key = limiter.key if limiter.respond_to?(:key)
17
+ send(:[]=, key, limiter)
18
+ end
19
+
20
+ def []=(key, limiter)
21
+ unless limiter.is_a? Berater::Limiter
22
+ raise ArgumentError, "expected Berater::Limiter, found: #{limiter}"
23
+ end
24
+
25
+ @limiters[key] = limiter
26
+ end
27
+
28
+ def [](key)
29
+ @limiters[key]
30
+ end
31
+
32
+ def fetch(key, val = default = true, &block)
33
+ args = default ? [ key ] : [ key, val ]
34
+ @limiters.fetch(*args, &block)
35
+ end
36
+
37
+ def include?(key)
38
+ if key.is_a? Berater::Limiter
39
+ @limiters.value?(key)
40
+ else
41
+ @limiters.key?(key)
42
+ end
43
+ end
44
+
45
+ def clear
46
+ @limiters.clear
47
+ end
48
+
49
+ def count
50
+ @limiters.count
51
+ end
52
+
53
+ def delete(key)
54
+ if key.is_a? Berater::Limiter
55
+ @limiters.delete(key.key)
56
+ else
57
+ @limiters.delete(key)
58
+ end
59
+ end
60
+ alias remove delete
61
+
62
+ def empty?
63
+ @limiters.empty?
64
+ end
65
+ end
66
+ end
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,14 +59,15 @@ 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
 
@@ -69,17 +77,17 @@ module Berater
69
77
 
70
78
  count, allowed = LUA_SCRIPT.eval(
71
79
  redis,
72
- [ cache_key(key), cache_key("#{key}-ts") ],
73
- [ ts, capacity, @interval_msec, cost ]
80
+ [ cache_key ],
81
+ [ ts, capacity, @interval, cost ]
74
82
  )
75
83
 
76
- raise Overrated unless allowed
84
+ count = count.include?('.') ? count.to_f : count.to_i
85
+
86
+ raise Overloaded unless allowed
77
87
 
78
88
  Lock.new(capacity, count)
79
89
  end
80
90
 
81
- alias overrated? overloaded?
82
-
83
91
  def to_s
84
92
  msg = if interval.is_a? Numeric
85
93
  if interval == 1