berater 0.5.0 → 0.7.1

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: fa5d51d6eea9d4691e58e8dd7065a1d596a57abdac1227fa19bd22cd2f42c15d
4
- data.tar.gz: 55ac15bf6f2f295c525fdea57b3af9b518935a12dd4b644e3c0c9c085429ca50
3
+ metadata.gz: c12ee8f042d6ebb193f114cd093585c14a4a28b802d7a90f79ba5ce41bbd82e6
4
+ data.tar.gz: d12ebb1107ea0b24208bfb3ff663a98097cd3367c3972418a139cae9b501e4a1
5
5
  SHA512:
6
- metadata.gz: 52df5781f9c37c7f1c9dde09e579c8da1f2d62f405835b771c6087cda8af046018e85a88629f7c5d3a2be9aafd2680e348d1b2f91bc63fe456e6e51ec5d0fd02
7
- data.tar.gz: '0928cb6f2e8e07810dec96f5af302ff9f62d519b9f19ef6f28acb5621569f13edb6d784cbd7084b738df8dfc232ace26e3ce0c6c4a7f2ac3e4391c97789bafae'
6
+ metadata.gz: 491cd616903af917866a41f8b2a4bcc79a469a879f57f1872ce8f4a42c00f1309979c04c142c0083246791aef6cd3847c21197dee0b611ff326ca833cc44ae52
7
+ data.tar.gz: 7ba3fd90f8b439e4ad2fe1d5a8008c69978a90c206bc6694e9f470796019d2ac849dddaa85039b9c730e3e20edfec3411cc1275598866c63207c209e688f2a3a
data/lib/berater.rb CHANGED
@@ -19,20 +19,23 @@ 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
34
  else
32
35
  Berater::ConcurrencyLimiter
33
36
  end
34
37
  end.yield_self do |klass|
35
- args = [ key, capacity, interval ].compact
38
+ args = [ key, capacity, *args ].compact
36
39
  klass.new(*args, **opts)
37
40
  end
38
41
  end
@@ -46,8 +49,8 @@ module Berater
46
49
  end
47
50
 
48
51
  # convenience method
49
- def Berater(key, capacity, interval = nil, **opts, &block)
50
- limiter = Berater.new(key, capacity, interval, **opts)
52
+ def Berater(key, capacity, **opts, &block)
53
+ limiter = Berater.new(key, capacity, **opts)
51
54
  if block_given?
52
55
  limiter.limit(&block)
53
56
  else
@@ -1,20 +1,21 @@
1
1
  module Berater
2
2
  class ConcurrencyLimiter < Limiter
3
3
 
4
- class Incapacitated < Overloaded; end
5
-
6
4
  attr_reader :timeout
7
5
 
8
6
  def initialize(key, capacity, **opts)
9
7
  super(key, capacity, **opts)
10
8
 
9
+ # round fractional capacity
10
+ self.capacity = capacity.to_i
11
+
11
12
  self.timeout = opts[:timeout] || 0
12
13
  end
13
14
 
14
15
  private def timeout=(timeout)
15
16
  @timeout = timeout
16
17
  timeout = 0 if timeout == Float::INFINITY
17
- @timeout_usec = Berater::Utils.to_usec(timeout)
18
+ @timeout_msec = Berater::Utils.to_msec(timeout)
18
19
  end
19
20
 
20
21
  LUA_SCRIPT = Berater::LuaScript(<<~LUA
@@ -28,17 +29,15 @@ module Berater
28
29
 
29
30
  -- purge stale hosts
30
31
  if ttl > 0 then
31
- redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
32
+ redis.call('ZREMRANGEBYSCORE', key, 0, ts - ttl)
32
33
  end
33
34
 
34
35
  -- check capacity
35
36
  local count = redis.call('ZCARD', key)
36
37
 
37
38
  if cost == 0 then
38
- -- just check limit, ie. for .overlimit?
39
- if count < capacity then
40
- table.insert(lock_ids, true)
41
- end
39
+ -- just checking count
40
+ table.insert(lock_ids, true)
42
41
  elseif (count + cost) <= capacity then
43
42
  -- grab locks, one per cost
44
43
  local lock_id = redis.call('INCRBY', lock_key, cost)
@@ -63,36 +62,28 @@ module Berater
63
62
  LUA
64
63
  )
65
64
 
66
- def limit(capacity: nil, cost: 1, &block)
67
- capacity ||= @capacity
68
- # cost is Integer >= 0
65
+ protected def acquire_lock(capacity, cost)
66
+ # round fractional capacity and cost
67
+ capacity = capacity.to_i
68
+ cost = cost.ceil
69
69
 
70
- # timestamp in microseconds
71
- ts = (Time.now.to_f * 10**6).to_i
70
+ # timestamp in milliseconds
71
+ ts = (Time.now.to_f * 10**3).to_i
72
72
 
73
73
  count, *lock_ids = LUA_SCRIPT.eval(
74
74
  redis,
75
75
  [ cache_key(key), cache_key('lock_id') ],
76
- [ capacity, ts, @timeout_usec, cost ]
76
+ [ capacity, ts, @timeout_msec, cost ]
77
77
  )
78
78
 
79
- raise Incapacitated if lock_ids.empty?
79
+ raise Overloaded if lock_ids.empty?
80
80
 
81
- if cost == 0
82
- lock = Lock.new(self, nil, count)
83
- else
84
- lock = Lock.new(self, lock_ids[0], count, -> { release(lock_ids) })
81
+ release_fn = if cost > 0
82
+ proc { release(lock_ids) }
85
83
  end
86
84
 
87
- yield_lock(lock, &block)
88
- end
89
-
90
- def overloaded?
91
- limit(cost: 0) { false }
92
- rescue Overloaded
93
- true
85
+ Lock.new(capacity, count, release_fn)
94
86
  end
95
- alias incapacitated? overloaded?
96
87
 
97
88
  private def release(lock_ids)
98
89
  res = redis.zrem(cache_key(key), lock_ids)
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,13 @@
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
12
- end
13
-
14
- def overloaded?
15
- true
8
+ protected def acquire_lock(*)
9
+ raise Overloaded
16
10
  end
17
- alias inhibited? overloaded?
18
11
 
19
12
  end
20
13
  end
@@ -7,12 +7,40 @@ module Berater
7
7
  options[:redis] || Berater.redis
8
8
  end
9
9
 
10
- def limit
11
- raise NotImplementedError
10
+ def limit(capacity: nil, cost: 1, &block)
11
+ capacity ||= @capacity
12
+
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
12
32
  end
13
33
 
14
- def overloaded?
15
- raise NotImplementedError
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
16
44
  end
17
45
 
18
46
  def to_s
@@ -30,7 +58,7 @@ module Berater
30
58
 
31
59
  def self.new(*)
32
60
  # can only call via subclass
33
- raise NotImplementedError if self == Berater::Limiter
61
+ raise NoMethodError if self == Berater::Limiter
34
62
 
35
63
  super
36
64
  end
@@ -47,29 +75,25 @@ module Berater
47
75
  end
48
76
 
49
77
  def capacity=(capacity)
50
- unless capacity.is_a?(Integer) || capacity == Float::INFINITY
51
- raise ArgumentError, "expected Integer, found #{capacity.class}"
78
+ unless capacity.is_a?(Numeric)
79
+ raise ArgumentError, "expected Numeric, found #{capacity.class}"
52
80
  end
53
81
 
54
- raise ArgumentError, "capacity must be >= 0" unless capacity >= 0
82
+ if capacity == Float::INFINITY
83
+ raise ArgumentError, 'infinite capacity not supported, use Unlimiter'
84
+ end
85
+
86
+ raise ArgumentError, 'capacity must be >= 0' unless capacity >= 0
55
87
 
56
88
  @capacity = capacity
57
89
  end
58
90
 
59
- def cache_key(key)
60
- "#{self.class}:#{key}"
91
+ def acquire_lock(capacity, cost)
92
+ raise NotImplementedError
61
93
  end
62
94
 
63
- def yield_lock(lock, &block)
64
- if block_given?
65
- begin
66
- yield lock
67
- ensure
68
- lock.release
69
- end
70
- else
71
- lock
72
- end
95
+ def cache_key(key)
96
+ "#{self.class}:#{key}"
73
97
  end
74
98
 
75
99
  end
data/lib/berater/lock.rb CHANGED
@@ -1,11 +1,10 @@
1
1
  module Berater
2
2
  class Lock
3
3
 
4
- attr_reader :limiter, :id, :contention
4
+ attr_reader :capacity, :contention
5
5
 
6
- def initialize(limiter, id, contention, release_fn = nil)
7
- @limiter = limiter
8
- @id = id
6
+ def initialize(capacity, contention, release_fn = nil)
7
+ @capacity = capacity
9
8
  @contention = contention
10
9
  @locked_at = Time.now
11
10
  @release_fn = release_fn
@@ -1,18 +1,20 @@
1
1
  module Berater
2
2
  class RateLimiter < Limiter
3
3
 
4
- class Overrated < Overloaded; end
5
-
6
4
  attr_accessor :interval
7
5
 
8
6
  def initialize(key, capacity, interval, **opts)
9
7
  self.interval = interval
10
- super(key, capacity, @interval_usec, **opts)
8
+ super(key, capacity, @interval_msec, **opts)
11
9
  end
12
10
 
13
11
  private def interval=(interval)
14
12
  @interval = interval
15
- @interval_usec = Berater::Utils.to_usec(interval)
13
+ @interval_msec = Berater::Utils.to_msec(interval)
14
+
15
+ unless @interval_msec > 0
16
+ raise ArgumentError, 'interval must be > 0'
17
+ end
16
18
  end
17
19
 
18
20
  LUA_SCRIPT = Berater::LuaScript(<<~LUA
@@ -20,26 +22,34 @@ module Berater
20
22
  local ts_key = KEYS[2]
21
23
  local ts = tonumber(ARGV[1])
22
24
  local capacity = tonumber(ARGV[2])
23
- local interval_usec = tonumber(ARGV[3])
25
+ local interval_msec = tonumber(ARGV[3])
24
26
  local cost = tonumber(ARGV[4])
25
- local count = 0
26
- local allowed
27
- local usec_per_drip = interval_usec / capacity
28
-
29
- -- timestamp of last update
30
- local last_ts = tonumber(redis.call('GET', ts_key))
31
-
32
- if last_ts then
33
- count = tonumber(redis.call('GET', key)) or 0
34
27
 
35
- -- adjust for time passing
36
- local drips = math.floor((ts - last_ts) / usec_per_drip)
37
- count = math.max(0, count - drips)
28
+ local allowed -- whether lock was acquired
29
+ local count -- capacity being utilized
30
+ local msec_per_drip = interval_msec / capacity
31
+ local state = redis.call('GET', key)
32
+
33
+ if state then
34
+ local last_ts -- timestamp of last update
35
+ count, last_ts = string.match(state, '([%d.]+);(%w+)')
36
+ count = tonumber(count)
37
+ last_ts = tonumber(last_ts, 16)
38
+
39
+ -- adjust for time passing, guarding against clock skew
40
+ if ts > last_ts then
41
+ local drips = math.floor((ts - last_ts) / msec_per_drip)
42
+ count = math.max(0, count - drips)
43
+ else
44
+ ts = last_ts
45
+ end
46
+ else
47
+ count = 0
38
48
  end
39
49
 
40
50
  if cost == 0 then
41
- -- just check limit, ie. for .overlimit?
42
- allowed = count < capacity
51
+ -- just checking count
52
+ allowed = true
43
53
  else
44
54
  allowed = (count + cost) <= capacity
45
55
 
@@ -47,42 +57,35 @@ module Berater
47
57
  count = count + cost
48
58
 
49
59
  -- time for bucket to empty, in milliseconds
50
- local ttl = math.ceil((count * usec_per_drip) / 1000)
60
+ local ttl = math.ceil(count * msec_per_drip)
61
+ ttl = ttl + 100 -- margin of error, for clock skew
51
62
 
52
- -- update count and last_ts, with expirations
53
- redis.call('SET', key, count, 'PX', ttl)
54
- redis.call('SET', ts_key, ts, 'PX', ttl)
63
+ -- update count and last_ts, with expiration
64
+ state = string.format('%f;%X', count, ts)
65
+ redis.call('SET', key, state, 'PX', ttl)
55
66
  end
56
67
  end
57
68
 
58
- return { count, allowed }
69
+ return { tostring(count), allowed }
59
70
  LUA
60
71
  )
61
72
 
62
- def limit(capacity: nil, cost: 1, &block)
63
- capacity ||= @capacity
64
-
65
- # timestamp in microseconds
66
- ts = (Time.now.to_f * 10**6).to_i
73
+ protected def acquire_lock(capacity, cost)
74
+ # timestamp in milliseconds
75
+ ts = (Time.now.to_f * 10**3).to_i
67
76
 
68
77
  count, allowed = LUA_SCRIPT.eval(
69
78
  redis,
70
- [ cache_key(key), cache_key("#{key}-ts") ],
71
- [ ts, capacity, @interval_usec, cost ]
79
+ [ cache_key(key) ],
80
+ [ ts, capacity, @interval_msec, cost ]
72
81
  )
73
82
 
74
- raise Overrated unless allowed
83
+ count = count.include?('.') ? count.to_f : count.to_i
75
84
 
76
- lock = Lock.new(self, ts, count)
77
- yield_lock(lock, &block)
78
- end
85
+ raise Overloaded unless allowed
79
86
 
80
- def overloaded?
81
- limit(cost: 0) { false }
82
- rescue Overrated
83
- true
87
+ Lock.new(capacity, count)
84
88
  end
85
- alias overrated? overloaded?
86
89
 
87
90
  def to_s
88
91
  msg = if interval.is_a? Numeric