berater 0.6.1 → 0.9.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: 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