berater 0.4.0 → 0.7.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.
@@ -0,0 +1,55 @@
1
+ require 'digest'
2
+
3
+ module Berater
4
+ class LuaScript
5
+
6
+ attr_reader :source
7
+
8
+ def initialize(source)
9
+ @source = source
10
+ end
11
+
12
+ def sha
13
+ @sha ||= Digest::SHA1.hexdigest(minify)
14
+ end
15
+
16
+ def eval(redis, *args)
17
+ redis.evalsha(sha, *args)
18
+ rescue Redis::CommandError => e
19
+ raise unless e.message.include?('NOSCRIPT')
20
+
21
+ # fall back to regular eval, which will trigger
22
+ # script to be cached for next time
23
+ redis.eval(minify, *args)
24
+ end
25
+
26
+ def load(redis)
27
+ redis.script(:load, minify).tap do |sha|
28
+ unless sha == self.sha
29
+ raise "unexpected script SHA: expected #{self.sha}, got #{sha}"
30
+ end
31
+ end
32
+ end
33
+
34
+ def loaded?(redis)
35
+ redis.script(:exists, sha)
36
+ end
37
+
38
+ def to_s
39
+ source
40
+ end
41
+
42
+ private
43
+
44
+ def minify
45
+ # trim comments (whole line and partial)
46
+ # and whitespace (prefix and empty lines)
47
+ @minify ||= source.gsub(/^\s*--.*\n|\s*--.*|^\s*|^$\n/, '').chomp
48
+ end
49
+
50
+ end
51
+
52
+ def LuaScript(source)
53
+ LuaScript.new(source)
54
+ end
55
+ end
@@ -1,132 +1,104 @@
1
1
  module Berater
2
2
  class RateLimiter < Limiter
3
3
 
4
- class Overrated < Overloaded; end
4
+ attr_accessor :interval
5
5
 
6
- attr_accessor :count, :interval
7
-
8
- def initialize(key, count, interval, **opts)
9
- super(key, **opts)
10
-
11
- self.count = count
6
+ def initialize(key, capacity, interval, **opts)
12
7
  self.interval = interval
13
- end
14
-
15
- private def count=(count)
16
- unless count.is_a? Integer
17
- raise ArgumentError, "expected Integer, found #{count.class}"
18
- end
19
-
20
- raise ArgumentError, "count must be >= 0" unless count >= 0
21
-
22
- @count = count
8
+ super(key, capacity, @interval_msec, **opts)
23
9
  end
24
10
 
25
11
  private def interval=(interval)
26
- @interval = interval.dup
27
-
28
- case @interval
29
- when Integer
30
- raise ArgumentError, "interval must be >= 0" unless @interval >= 0
31
- @interval_sec = @interval
32
- when String
33
- @interval = @interval.to_sym
34
- when Symbol
35
- else
36
- raise ArgumentError, "unexpected interval type: #{interval.class}"
37
- end
12
+ @interval = interval
13
+ @interval_msec = Berater::Utils.to_msec(interval)
38
14
 
39
- if @interval.is_a? Symbol
40
- case @interval
41
- when :sec, :second, :seconds
42
- @interval = :second
43
- @interval_sec = 1
44
- when :min, :minute, :minutes
45
- @interval = :minute
46
- @interval_sec = 60
47
- when :hour, :hours
48
- @interval = :hour
49
- @interval_sec = 60 * 60
50
- else
51
- raise ArgumentError, "unexpected interval value: #{interval}"
52
- end
15
+ unless @interval_msec > 0
16
+ raise ArgumentError, 'interval must be > 0'
53
17
  end
54
18
  end
55
19
 
56
- LUA_SCRIPT = <<~LUA.gsub(/^\s*|\s*--.*/, '')
20
+ LUA_SCRIPT = Berater::LuaScript(<<~LUA
57
21
  local key = KEYS[1]
58
22
  local ts_key = KEYS[2]
59
23
  local ts = tonumber(ARGV[1])
60
24
  local capacity = tonumber(ARGV[2])
61
- local usec_per_drip = tonumber(ARGV[3])
62
- local count = 0
63
-
64
- -- timestamp of last update
65
- local last_ts = tonumber(redis.call('GET', ts_key))
66
-
67
- if last_ts then
68
- count = tonumber(redis.call('GET', key)) or 0
69
-
70
- -- adjust for time passing
71
- local drips = math.floor((ts - last_ts) / usec_per_drip)
72
- count = math.max(0, count - drips)
25
+ local interval_msec = tonumber(ARGV[3])
26
+ local cost = tonumber(ARGV[4])
27
+
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
73
48
  end
74
49
 
75
- local allowed = count + 1 <= capacity
50
+ if cost == 0 then
51
+ -- just checking count
52
+ allowed = true
53
+ else
54
+ allowed = (count + cost) <= capacity
76
55
 
77
- if allowed then
78
- count = count + 1
56
+ if allowed then
57
+ count = count + cost
79
58
 
80
- -- time for bucket to empty, in milliseconds
81
- local ttl = math.ceil((count * usec_per_drip) / 1000)
59
+ -- time for bucket to empty, in milliseconds
60
+ local ttl = math.ceil(count * msec_per_drip)
61
+ ttl = ttl + 100 -- margin of error, for clock skew
82
62
 
83
- -- update count and last_ts, with expirations
84
- redis.call('SET', key, count, 'PX', ttl)
85
- 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)
66
+ end
86
67
  end
87
68
 
88
- return { count, allowed }
69
+ return { tostring(count), allowed }
89
70
  LUA
71
+ )
90
72
 
91
- def limit
92
- usec_per_drip = (@interval_sec * 10**6) / @count
93
-
94
- # timestamp in microseconds
95
- 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
96
76
 
97
- count, allowed = redis.eval(
98
- LUA_SCRIPT,
99
- [ cache_key(key), cache_key("#{key}-ts") ],
100
- [ ts, @count, usec_per_drip ]
77
+ count, allowed = LUA_SCRIPT.eval(
78
+ redis,
79
+ [ cache_key(key) ],
80
+ [ ts, capacity, @interval_msec, cost ]
101
81
  )
102
82
 
103
- raise Overrated unless allowed
83
+ count = count.include?('.') ? count.to_f : count.to_i
104
84
 
105
- lock = Lock.new(self, "#{ts}-#{count}", count)
85
+ raise Overloaded unless allowed
106
86
 
107
- if block_given?
108
- begin
109
- yield lock
110
- ensure
111
- lock.release
112
- end
113
- else
114
- lock
115
- end
87
+ Lock.new(capacity, count)
116
88
  end
117
89
 
118
90
  def to_s
119
- msg = if @interval.is_a? Integer
120
- if @interval == 1
91
+ msg = if interval.is_a? Numeric
92
+ if interval == 1
121
93
  "every second"
122
94
  else
123
- "every #{@interval} seconds"
95
+ "every #{interval} seconds"
124
96
  end
125
97
  else
126
- "per #{@interval}"
98
+ "per #{interval}"
127
99
  end
128
100
 
129
- "#<#{self.class}(#{key}: #{count} #{msg})>"
101
+ "#<#{self.class}(#{key}: #{capacity} #{msg})>"
130
102
  end
131
103
 
132
104
  end
data/lib/berater/rspec.rb CHANGED
@@ -4,9 +4,11 @@ require 'berater/test_mode'
4
4
  require 'rspec'
5
5
 
6
6
  RSpec.configure do |config|
7
- config.include(BeraterMatchers)
7
+ config.include(Berater::Matchers)
8
8
 
9
9
  config.after do
10
10
  Berater.expunge rescue nil
11
+ Berater.redis.script(:flush) rescue nil
12
+ Berater.reset
11
13
  end
12
14
  end
@@ -1,60 +1,61 @@
1
- module BeraterMatchers
2
- class Overloaded
3
- def initialize(type)
4
- @type = type
5
- end
6
-
7
- def supports_block_expectations?
8
- true
9
- end
1
+ module Berater
2
+ module Matchers
3
+ class Overloaded
4
+ def supports_block_expectations?
5
+ true
6
+ end
10
7
 
11
- def matches?(obj)
12
- begin
8
+ def matches?(obj)
13
9
  case obj
14
10
  when Proc
15
- # eg. expect { ... }.to be_overrated
11
+ # eg. expect { ... }.to be_overloaded
16
12
  res = obj.call
17
13
 
18
14
  if res.is_a? Berater::Limiter
19
- # eg. expect { Berater.new(...) }.to be_overrated
20
- res.limit {}
15
+ # eg. expect { Berater.new(...) }.to be_overloaded
16
+ @limiter = res
17
+ @limiter.utilization >= 1
18
+ else
19
+ # eg. expect { Berater(...) }.to be_overloaded
20
+ # eg. expect { limiter.limit }.to be_overloaded
21
+ false
21
22
  end
22
23
  when Berater::Limiter
23
- # eg. expect(Berater.new(...)).to be_overrated
24
- obj.limit {}
24
+ # eg. expect(Berater.new(...)).to be_overloaded
25
+ @limiter = obj
26
+ @limiter.utilization >= 1
25
27
  end
26
-
27
- false
28
- rescue @type
28
+ rescue Berater::Overloaded
29
29
  true
30
30
  end
31
- end
32
31
 
33
- # def description
34
- # it { expect { Berater.new(:inhibitor) }.not_to be_overrated }
32
+ def description
33
+ if @limiter
34
+ "be overloaded"
35
+ else
36
+ "raise #{Berater::Overloaded}"
37
+ end
38
+ end
35
39
 
36
- def failure_message
37
- "expected #{@type} to be raised"
38
- end
40
+ def failure_message
41
+ if @limiter
42
+ "expected to be overloaded"
43
+ else
44
+ "expected #{Berater::Overloaded} to be raised"
45
+ end
46
+ end
39
47
 
40
- def failure_message_when_negated
41
- "did not expect #{@type} to be raised"
48
+ def failure_message_when_negated
49
+ if @limiter
50
+ "expected not to be overloaded"
51
+ else
52
+ "did not expect #{Berater::Overloaded} to be raised"
53
+ end
54
+ end
42
55
  end
43
- end
44
-
45
- def be_overloaded
46
- Overloaded.new(Berater::Overloaded)
47
- end
48
56
 
49
- def be_overrated
50
- Overloaded.new(Berater::RateLimiter::Overrated)
51
- end
52
-
53
- def be_incapacitated
54
- Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
55
- end
56
-
57
- def be_inhibited
58
- Overloaded.new(Berater::Inhibitor::Inhibited)
57
+ def be_overloaded
58
+ Overloaded.new
59
+ end
59
60
  end
60
61
  end
@@ -13,31 +13,24 @@ module Berater
13
13
  @test_mode = mode
14
14
  end
15
15
 
16
- class Limiter
17
- def self.new(*args, **opts)
18
- return super unless Berater.test_mode
19
-
20
- # chose a stub class with desired behavior
21
- stub_klass = case Berater.test_mode
16
+ module TestMode
17
+ def acquire_lock(*)
18
+ case Berater.test_mode
22
19
  when :pass
23
- Berater::Unlimiter
20
+ Lock.new(Float::INFINITY, 0)
24
21
  when :fail
25
- Berater::Inhibitor
26
- end
27
-
28
- # don't stub self
29
- return super if self < stub_klass
30
-
31
- # swap out limit method with stub
32
- super.tap do |instance|
33
- stub = stub_klass.allocate
34
- stub.send(:initialize, *args, **opts)
35
-
36
- instance.define_singleton_method(:limit) do |&block|
37
- stub.limit(&block)
38
- end
22
+ raise Overloaded
23
+ else
24
+ super
39
25
  end
40
26
  end
41
27
  end
42
28
 
43
29
  end
30
+
31
+ # stub each Limiter subclass
32
+ ObjectSpace.each_object(Class).each do |klass|
33
+ next unless klass < Berater::Limiter
34
+
35
+ klass.prepend(Berater::TestMode)
36
+ end
@@ -2,21 +2,17 @@ module Berater
2
2
  class Unlimiter < Limiter
3
3
 
4
4
  def initialize(key = :unlimiter, *args, **opts)
5
- super(key, **opts)
5
+ super(key, Float::INFINITY, **opts)
6
6
  end
7
7
 
8
- def limit
9
- lock = Lock.new(self, 0, 0)
8
+ protected
10
9
 
11
- if block_given?
12
- begin
13
- yield lock
14
- ensure
15
- lock.release
16
- end
17
- else
18
- lock
19
- end
10
+ def capacity=(*)
11
+ @capacity = Float::INFINITY
12
+ end
13
+
14
+ def acquire_lock(*)
15
+ Lock.new(Float::INFINITY, 0)
20
16
  end
21
17
 
22
18
  end
@@ -0,0 +1,46 @@
1
+ module Berater
2
+ module Utils
3
+ extend self
4
+
5
+ refine Object do
6
+ def to_msec
7
+ Berater::Utils.to_msec(self)
8
+ end
9
+ end
10
+
11
+ def to_msec(val)
12
+ res = val
13
+
14
+ if val.is_a? String
15
+ # naively attempt casting, otherwise maybe it's a keyword
16
+ res = Float(val) rescue val.to_sym
17
+ end
18
+
19
+ if res.is_a? Symbol
20
+ case res
21
+ when :sec, :second, :seconds
22
+ res = 1
23
+ when :min, :minute, :minutes
24
+ res = 60
25
+ when :hour, :hours
26
+ res = 60 * 60
27
+ end
28
+ end
29
+
30
+ unless res.is_a? Numeric
31
+ raise ArgumentError, "unexpected value: #{val}"
32
+ end
33
+
34
+ if res < 0
35
+ raise ArgumentError, "expected value >= 0, found: #{val}"
36
+ end
37
+
38
+ if res == Float::INFINITY
39
+ raise ArgumentError, "infinite values not allowed"
40
+ end
41
+
42
+ (res * 10**3).to_i
43
+ end
44
+
45
+ end
46
+ end