berater 0.1.3 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,27 @@
1
+ module Berater
2
+ class Lock
3
+
4
+ attr_reader :limiter, :id, :contention
5
+
6
+ def initialize(limiter, id, contention, release_fn = nil)
7
+ @limiter = limiter
8
+ @id = id
9
+ @contention = contention
10
+ @locked_at = Time.now
11
+ @release_fn = release_fn
12
+ @released_at = nil
13
+ end
14
+
15
+ def locked?
16
+ @released_at.nil?
17
+ end
18
+
19
+ def release
20
+ raise 'lock already released' unless locked?
21
+
22
+ @released_at = Time.now
23
+ @release_fn ? @release_fn.call : true
24
+ end
25
+
26
+ end
27
+ end
@@ -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,82 +1,101 @@
1
1
  module Berater
2
- class RateLimiter < BaseLimiter
2
+ class RateLimiter < Limiter
3
3
 
4
4
  class Overrated < Overloaded; end
5
5
 
6
- attr_accessor :count, :interval
6
+ attr_accessor :interval
7
7
 
8
- def initialize(count, interval, **opts)
9
- super(**opts)
10
-
11
- self.count = count
8
+ def initialize(key, capacity, interval, **opts)
12
9
  self.interval = interval
10
+ super(key, capacity, @interval_usec, **opts)
13
11
  end
14
12
 
15
- 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
13
+ private def interval=(interval)
14
+ @interval = interval
15
+ @interval_usec = Berater::Utils.to_usec(interval)
23
16
  end
24
17
 
25
- def interval=(interval)
26
- @interval = interval.dup
18
+ LUA_SCRIPT = Berater::LuaScript(<<~LUA
19
+ local key = KEYS[1]
20
+ local ts_key = KEYS[2]
21
+ local ts = tonumber(ARGV[1])
22
+ local capacity = tonumber(ARGV[2])
23
+ local interval_usec = tonumber(ARGV[3])
24
+ 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
+
35
+ -- adjust for time passing
36
+ local drips = math.floor((ts - last_ts) / usec_per_drip)
37
+ count = math.max(0, count - drips)
38
+ end
27
39
 
28
- case @interval
29
- when Integer
30
- raise ArgumentError, "interval must be >= 0" unless @interval >= 0
31
- when String
32
- @interval = @interval.to_sym
33
- when Symbol
40
+ if cost == 0 then
41
+ -- just check limit, ie. for .overlimit?
42
+ allowed = count < capacity
34
43
  else
35
- raise ArgumentError, "unexpected interval type: #{interval.class}"
36
- end
44
+ allowed = (count + cost) <= capacity
37
45
 
38
- if @interval.is_a? Symbol
39
- case @interval
40
- when :sec, :second, :seconds
41
- @interval = 1
42
- when :min, :minute, :minutes
43
- @interval = 60
44
- when :hour, :hours
45
- @interval = 60 * 60
46
- else
47
- raise ArgumentError, "unexpected interval value: #{interval}"
46
+ if allowed then
47
+ count = count + cost
48
+
49
+ -- time for bucket to empty, in milliseconds
50
+ local ttl = math.ceil((count * usec_per_drip) / 1000)
51
+
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)
48
55
  end
49
56
  end
50
57
 
51
- @interval
52
- end
58
+ return { count, allowed }
59
+ LUA
60
+ )
53
61
 
54
- def limit(**opts, &block)
55
- unless opts.empty?
56
- return self.class.new(
57
- count,
58
- interval,
59
- options.merge(opts)
60
- ).limit(&block)
61
- end
62
+ def limit(capacity: nil, cost: 1, &block)
63
+ capacity ||= @capacity
62
64
 
63
- ts = Time.now.to_i
65
+ # timestamp in microseconds
66
+ ts = (Time.now.to_f * 10**6).to_i
64
67
 
65
- # bucket into time slot
66
- rkey = "%s:%d" % [ key, ts - ts % @interval ]
68
+ count, allowed = LUA_SCRIPT.eval(
69
+ redis,
70
+ [ cache_key(key), cache_key("#{key}-ts") ],
71
+ [ ts, capacity, @interval_usec, cost ]
72
+ )
67
73
 
68
- count, _ = redis.multi do
69
- redis.incr rkey
70
- redis.expire rkey, @interval * 2
71
- end
74
+ raise Overrated unless allowed
72
75
 
73
- raise Overrated if count > @count
76
+ lock = Lock.new(self, ts, count)
77
+ yield_lock(lock, &block)
78
+ end
74
79
 
75
- if block_given?
76
- yield
80
+ def overloaded?
81
+ limit(cost: 0) { false }
82
+ rescue Overrated
83
+ true
84
+ end
85
+ alias overrated? overloaded?
86
+
87
+ def to_s
88
+ msg = if interval.is_a? Numeric
89
+ if interval == 1
90
+ "every second"
91
+ else
92
+ "every #{interval} seconds"
93
+ end
77
94
  else
78
- count
95
+ "per #{interval}"
79
96
  end
97
+
98
+ "#<#{self.class}(#{key}: #{capacity} #{msg})>"
80
99
  end
81
100
 
82
101
  end
@@ -0,0 +1,14 @@
1
+ require 'berater'
2
+ require 'berater/rspec/matchers'
3
+ require 'berater/test_mode'
4
+ require 'rspec'
5
+
6
+ RSpec.configure do |config|
7
+ config.include(BeraterMatchers)
8
+
9
+ config.after do
10
+ Berater.expunge rescue nil
11
+ Berater.redis.script(:flush) rescue nil
12
+ Berater.reset
13
+ end
14
+ end
@@ -0,0 +1,62 @@
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
10
+
11
+ def matches?(obj)
12
+ begin
13
+ case obj
14
+ when Proc
15
+ # eg. expect { ... }.to be_overrated
16
+ res = obj.call
17
+
18
+ if res.is_a? Berater::Limiter
19
+ # eg. expect { Berater.new(...) }.to be_overloaded
20
+ res.overloaded?
21
+ else
22
+ # eg. expect { Berater(...) }.to be_overloaded
23
+ # eg. expect { limiter.limit }.to be_overloaded
24
+ false
25
+ end
26
+ when Berater::Limiter
27
+ # eg. expect(Berater.new(...)).to be_overloaded
28
+ obj.overloaded?
29
+ end
30
+ rescue @type
31
+ true
32
+ end
33
+ end
34
+
35
+ # def description
36
+ # it { expect { Berater.new(:inhibitor) }.not_to be_overrated }
37
+
38
+ def failure_message
39
+ "expected #{@type} to be raised"
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ "did not expect #{@type} to be raised"
44
+ end
45
+ end
46
+
47
+ def be_overloaded
48
+ Overloaded.new(Berater::Overloaded)
49
+ end
50
+
51
+ def be_overrated
52
+ Overloaded.new(Berater::RateLimiter::Overrated)
53
+ end
54
+
55
+ def be_incapacitated
56
+ Overloaded.new(Berater::ConcurrencyLimiter::Incapacitated)
57
+ end
58
+
59
+ def be_inhibited
60
+ Overloaded.new(Berater::Inhibitor::Inhibited)
61
+ end
62
+ end
@@ -0,0 +1,52 @@
1
+ require 'berater'
2
+
3
+ module Berater
4
+ extend self
5
+
6
+ attr_reader :test_mode
7
+
8
+ def test_mode=(mode)
9
+ unless [ nil, :pass, :fail ].include?(mode)
10
+ raise ArgumentError, "invalid mode: #{Berater.test_mode}"
11
+ end
12
+
13
+ @test_mode = mode
14
+
15
+ # overload class methods
16
+ unless Berater::Limiter.singleton_class.ancestors.include?(TestMode)
17
+ Berater::Limiter.singleton_class.prepend(TestMode)
18
+ end
19
+ end
20
+
21
+ module TestMode
22
+ def new(*args, **opts)
23
+ return super unless Berater.test_mode
24
+
25
+ # chose a stub class with desired behavior
26
+ stub_klass = case Berater.test_mode
27
+ when :pass
28
+ Berater::Unlimiter
29
+ when :fail
30
+ Berater::Inhibitor
31
+ end
32
+
33
+ # don't stub self
34
+ return super if self < stub_klass
35
+
36
+ # swap out limit and overloaded? methods with stub
37
+ super.tap do |instance|
38
+ stub = stub_klass.allocate
39
+ stub.send(:initialize, *args, **opts)
40
+
41
+ instance.define_singleton_method(:limit) do |**opts, &block|
42
+ stub.limit(**opts, &block)
43
+ end
44
+
45
+ instance.define_singleton_method(:overloaded?) do
46
+ stub.overloaded?
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ end
@@ -1,18 +1,16 @@
1
1
  module Berater
2
- class Unlimiter < BaseLimiter
2
+ class Unlimiter < Limiter
3
3
 
4
- def initialize(*args, **opts)
5
- super(**opts)
4
+ def initialize(key = :unlimiter, *args, **opts)
5
+ super(key, Float::INFINITY, **opts)
6
6
  end
7
7
 
8
8
  def limit(**opts, &block)
9
- unless opts.empty?
10
- return self.class.new(
11
- **options.merge(opts)
12
- ).limit(&block)
13
- end
9
+ yield_lock(Lock.new(self, Float::INFINITY, 0), &block)
10
+ end
14
11
 
15
- yield if block_given?
12
+ def overloaded?
13
+ false
16
14
  end
17
15
 
18
16
  end
@@ -0,0 +1,46 @@
1
+ module Berater
2
+ module Utils
3
+ extend self
4
+
5
+ refine Object do
6
+ def to_usec
7
+ Berater::Utils.to_usec(self)
8
+ end
9
+ end
10
+
11
+ def to_usec(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**6).to_i
43
+ end
44
+
45
+ end
46
+ end