berater 0.5.0 → 0.7.1

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: 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