berater 0.1.3 → 0.5.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,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