berater 0.6.0 → 0.8.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: 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