berater 0.3.0 → 0.6.2

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
@@ -3,94 +3,95 @@ module Berater
3
3
 
4
4
  class Overrated < Overloaded; end
5
5
 
6
- attr_accessor :count, :interval
6
+ attr_accessor :interval
7
7
 
8
- def initialize(key, count, interval, **opts)
9
- super(key, **opts)
10
-
11
- self.count = count
8
+ def initialize(key, capacity, interval, **opts)
12
9
  self.interval = interval
10
+ super(key, capacity, @interval_msec, **opts)
13
11
  end
14
12
 
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
13
+ private def interval=(interval)
14
+ @interval = interval
15
+ @interval_msec = Berater::Utils.to_msec(interval)
21
16
 
22
- @count = count
17
+ unless @interval_msec > 0
18
+ raise ArgumentError, 'interval must be > 0'
19
+ end
23
20
  end
24
21
 
25
- 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}"
22
+ LUA_SCRIPT = Berater::LuaScript(<<~LUA
23
+ local key = KEYS[1]
24
+ local ts_key = KEYS[2]
25
+ local ts = tonumber(ARGV[1])
26
+ local capacity = tonumber(ARGV[2])
27
+ local interval_msec = tonumber(ARGV[3])
28
+ 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
+
39
+ -- adjust for time passing
40
+ local drips = math.floor((ts - last_ts) / msec_per_drip)
41
+ count = math.max(0, count - drips)
37
42
  end
38
43
 
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
53
- end
54
- end
44
+ if cost == 0 then
45
+ -- just check limit, ie. for .overlimit?
46
+ allowed = count < capacity
47
+ else
48
+ allowed = (count + cost) <= capacity
55
49
 
56
- def limit
57
- ts = Time.now.to_i
50
+ if allowed then
51
+ count = count + cost
58
52
 
59
- # bucket into time slot
60
- rkey = "%s:%d" % [ cache_key(key), ts - ts % @interval_sec ]
53
+ -- time for bucket to empty, in milliseconds
54
+ local ttl = math.ceil(count * msec_per_drip)
61
55
 
62
- count, _ = redis.multi do
63
- redis.incr rkey
64
- redis.expire rkey, @interval_sec * 2
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)
59
+ end
65
60
  end
66
61
 
67
- raise Overrated if count > @count
62
+ return { count, allowed }
63
+ LUA
64
+ )
68
65
 
69
- lock = Lock.new(self, count, count)
66
+ protected def acquire_lock(capacity, cost)
67
+ # timestamp in milliseconds
68
+ ts = (Time.now.to_f * 10**3).to_i
70
69
 
71
- if block_given?
72
- begin
73
- yield lock
74
- ensure
75
- lock.release
76
- end
77
- else
78
- lock
79
- end
70
+ count, allowed = LUA_SCRIPT.eval(
71
+ redis,
72
+ [ cache_key(key), cache_key("#{key}-ts") ],
73
+ [ ts, capacity, @interval_msec, cost ]
74
+ )
75
+
76
+ raise Overrated unless allowed
77
+
78
+ Lock.new(capacity, count)
80
79
  end
81
80
 
81
+ alias overrated? overloaded?
82
+
82
83
  def to_s
83
- msg = if @interval.is_a? Integer
84
- if @interval == 1
84
+ msg = if interval.is_a? Numeric
85
+ if interval == 1
85
86
  "every second"
86
87
  else
87
- "every #{@interval} seconds"
88
+ "every #{interval} seconds"
88
89
  end
89
90
  else
90
- "per #{@interval}"
91
+ "per #{interval}"
91
92
  end
92
93
 
93
- "#<#{self.class}(#{key}: #{count} #{msg})>"
94
+ "#<#{self.class}(#{key}: #{capacity} #{msg})>"
94
95
  end
95
96
 
96
97
  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,81 @@
1
- module BeraterMatchers
2
- class Overloaded
3
- def initialize(type)
4
- @type = type
5
- end
1
+ module Berater
2
+ module Matchers
3
+ class Overloaded
4
+ def initialize(type)
5
+ @type = type
6
+ end
6
7
 
7
- def supports_block_expectations?
8
- true
9
- end
8
+ def supports_block_expectations?
9
+ true
10
+ end
10
11
 
11
- def matches?(obj)
12
- begin
12
+ def matches?(obj)
13
13
  case obj
14
14
  when Proc
15
15
  # eg. expect { ... }.to be_overrated
16
16
  res = obj.call
17
17
 
18
18
  if res.is_a? Berater::Limiter
19
- # eg. expect { Berater.new(...) }.to be_overrated
20
- res.limit {}
19
+ # eg. expect { Berater.new(...) }.to be_overloaded
20
+ @limiter = res
21
+ res.overloaded?
22
+ else
23
+ # eg. expect { Berater(...) }.to be_overloaded
24
+ # eg. expect { limiter.limit }.to be_overloaded
25
+ false
21
26
  end
22
27
  when Berater::Limiter
23
- # eg. expect(Berater.new(...)).to be_overrated
24
- obj.limit {}
28
+ # eg. expect(Berater.new(...)).to be_overloaded
29
+ @limiter = obj
30
+ obj.overloaded?
25
31
  end
26
-
27
- false
28
32
  rescue @type
29
33
  true
30
34
  end
31
- end
32
35
 
33
- # def description
34
- # it { expect { Berater.new(:inhibitor) }.not_to be_overrated }
36
+ def description
37
+ if @limiter
38
+ "be #{verb}"
39
+ else
40
+ "raise #{@type}"
41
+ end
42
+ end
35
43
 
36
- def failure_message
37
- "expected #{@type} to be raised"
38
- end
44
+ def failure_message
45
+ if @limiter
46
+ "expected to be #{verb}"
47
+ else
48
+ "expected #{@type} to be raised"
49
+ end
50
+ end
39
51
 
40
- def failure_message_when_negated
41
- "did not expect #{@type} to be raised"
52
+ def failure_message_when_negated
53
+ if @limiter
54
+ "expected not to be #{verb}"
55
+ else
56
+ "did not expect #{@type} to be raised"
57
+ end
58
+ end
59
+
60
+ private def verb
61
+ @type.to_s.split('::')[-1].downcase
62
+ end
42
63
  end
43
- end
44
64
 
45
- def be_overloaded
46
- Overloaded.new(Berater::Overloaded)
47
- end
65
+ def be_overloaded
66
+ Overloaded.new(Berater::Overloaded)
67
+ end
48
68
 
49
- def be_overrated
50
- Overloaded.new(Berater::RateLimiter::Overrated)
51
- end
69
+ def be_overrated
70
+ Overloaded.new(Berater::RateLimiter::Overrated)
71
+ end
52
72
 
53
- def be_incapacitated
54
- Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
55
- end
73
+ def be_incapacitated
74
+ Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
75
+ end
56
76
 
57
- def be_inhibited
58
- Overloaded.new(Berater::Inhibitor::Inhibited)
77
+ def be_inhibited
78
+ Overloaded.new(Berater::Inhibitor::Inhibited)
79
+ end
59
80
  end
60
81
  end
@@ -13,31 +13,31 @@ 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
+ # find class specific Overloaded error
23
+ e = self.class.constants.map do |name|
24
+ self.class.const_get(name)
25
+ end.find do |const|
26
+ const.is_a?(Class) && const < Berater::Overloaded
27
+ end || Berater::Overloaded
28
+
29
+ raise e
30
+ else
31
+ super
39
32
  end
40
33
  end
41
34
  end
42
35
 
43
36
  end
37
+
38
+ # stub each Limiter subclass
39
+ ObjectSpace.each_object(Class).each do |klass|
40
+ next unless klass < Berater::Limiter
41
+
42
+ klass.prepend(Berater::TestMode)
43
+ 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