berater 0.3.0 → 0.6.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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