berater 0.1.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8ca6fba7e38a014eed325df41dda7372b41ff62d9e78b61565f4d915d8b4677
4
- data.tar.gz: 1fae864b93de87ea3dc70531f3f8697e80af526aea53d8ddfe2076eac9bbd61d
3
+ metadata.gz: 87d3bdb61a68a8b69dd7cb285dafb174a403acf19ff7586f90ed8b7cb50b7f76
4
+ data.tar.gz: fff71dd4d8ae28baf7c504efdc27df71f03219b32c1a959dddfd3d035845d058
5
5
  SHA512:
6
- metadata.gz: 1b4b37134edece0657ea9be15119fe25b2208d554b9dbf56edd04f8e5acff77569022be025ec428b81ab1971171ae468e389a9568d7ad0163b0966e622a09f2e
7
- data.tar.gz: bd927303eb1475e79a26161a184205008926f95c59a06eee43f453d5e464d15c205a91bffd36228ae28bc5d27f0fa32faebc7c5870ec5d1779bd6c57ecdc0fe2
6
+ metadata.gz: c9fb5c0c7ec100d2f50a3b6c6cfa449af052c1b78cff174b545e179c5fcc1324c2a5faafc90e7f7ec64ce200e47571d51b02eb56398d716c487abc7731c0cece
7
+ data.tar.gz: 148c566413d6593f229143e2871116b45947eae5b101d8bb2f59ce3bdfe89ed4ee1b7aaedfcaedbc80eef8a25a14fab83ad5e540172c41f31f27d7f296161235
data/lib/berater.rb CHANGED
@@ -1,46 +1,40 @@
1
+ require 'berater/limiter'
2
+ require 'berater/lock'
3
+ require 'berater/lua_script'
4
+ require 'berater/utils'
1
5
  require 'berater/version'
2
6
 
3
-
4
7
  module Berater
5
8
  extend self
6
9
 
7
10
  class Overloaded < StandardError; end
8
11
 
9
- MODES = {}
10
-
11
- attr_accessor :redis, :mode
12
+ attr_accessor :redis
12
13
 
13
14
  def configure
14
- self.mode = :unlimited # default
15
-
16
15
  yield self
17
16
  end
18
17
 
19
- def new(mode, *args, **opts)
20
- klass = MODES[mode.to_sym]
21
-
22
- unless klass
23
- raise ArgumentError, "invalid mode: #{mode}"
24
- end
25
-
26
- klass.new(*args, **opts)
27
- end
28
-
29
- def register(mode, klass)
30
- MODES[mode.to_sym] = klass
18
+ def reset
19
+ @redis = nil
31
20
  end
32
21
 
33
- def mode=(mode)
34
- unless MODES.include? mode.to_sym
35
- raise ArgumentError, "invalid mode: #{mode}"
22
+ def new(key, capacity, interval = nil, **opts)
23
+ case capacity
24
+ when :unlimited, Float::INFINITY
25
+ Berater::Unlimiter
26
+ when :inhibited, 0
27
+ Berater::Inhibitor
28
+ else
29
+ if interval
30
+ Berater::RateLimiter
31
+ else
32
+ Berater::ConcurrencyLimiter
33
+ end
34
+ end.yield_self do |klass|
35
+ args = [ key, capacity, interval ].compact
36
+ klass.new(*args, **opts)
36
37
  end
37
-
38
- @mode = mode.to_sym
39
- end
40
-
41
- def limit(*args, **opts, &block)
42
- mode = opts.delete(:mode) { self.mode }
43
- new(mode, *args, **opts).limit(&block)
44
38
  end
45
39
 
46
40
  def expunge
@@ -51,14 +45,18 @@ module Berater
51
45
 
52
46
  end
53
47
 
54
- # load and register limiters
55
- require 'berater/base_limiter'
48
+ # convenience method
49
+ def Berater(key, capacity, interval = nil, **opts, &block)
50
+ limiter = Berater.new(key, capacity, interval, **opts)
51
+ if block_given?
52
+ limiter.limit(&block)
53
+ else
54
+ limiter
55
+ end
56
+ end
57
+
58
+ # load limiters
56
59
  require 'berater/concurrency_limiter'
57
60
  require 'berater/inhibitor'
58
61
  require 'berater/rate_limiter'
59
62
  require 'berater/unlimiter'
60
-
61
- Berater.register(:concurrency, Berater::ConcurrencyLimiter)
62
- Berater.register(:inhibited, Berater::Inhibitor)
63
- Berater.register(:rate, Berater::RateLimiter)
64
- Berater.register(:unlimited, Berater::Unlimiter)
@@ -1,153 +1,111 @@
1
1
  module Berater
2
- class ConcurrencyLimiter < BaseLimiter
2
+ class ConcurrencyLimiter < Limiter
3
3
 
4
4
  class Incapacitated < Overloaded; end
5
5
 
6
- attr_reader :capacity, :timeout
6
+ attr_reader :timeout
7
7
 
8
- def initialize(capacity, **opts)
9
- super(**opts)
8
+ def initialize(key, capacity, **opts)
9
+ super(key, capacity, **opts)
10
10
 
11
- self.capacity = capacity
12
11
  self.timeout = opts[:timeout] || 0
13
12
  end
14
13
 
15
- def capacity=(capacity)
16
- unless capacity.is_a? Integer
17
- raise ArgumentError, "expected Integer, found #{capacity.class}"
18
- end
19
-
20
- raise ArgumentError, "capacity must be >= 0" unless capacity >= 0
21
-
22
- @capacity = capacity
23
- end
24
-
25
- def timeout=(timeout)
26
- unless timeout.is_a? Integer
27
- raise ArgumentError, "expected Integer, found #{timeout.class}"
28
- end
29
-
30
- raise ArgumentError, "timeout must be >= 0" unless timeout >= 0
31
-
14
+ private def timeout=(timeout)
32
15
  @timeout = timeout
16
+ timeout = 0 if timeout == Float::INFINITY
17
+ @timeout_msec = Berater::Utils.to_msec(timeout)
33
18
  end
34
19
 
35
- class Lock
36
- attr_reader :limiter, :id, :contention
37
-
38
- def initialize(limiter, id, contention)
39
- @limiter = limiter
40
- @id = id
41
- @contention = contention
42
- @released = false
43
- @locked_at = Time.now
44
- end
45
-
46
- def release
47
- raise 'lock already released' if released?
48
- raise 'lock expired' if expired?
49
-
50
- @released = limiter.release(self)
51
- end
52
-
53
- def released?
54
- @released
55
- end
56
-
57
- def expired?
58
- limiter.timeout > 0 && @locked_at + limiter.timeout < Time.now
59
- end
60
- end
61
-
62
- LUA_SCRIPT = <<~LUA.gsub(/^\s*(--.*\n)?/, '')
20
+ LUA_SCRIPT = Berater::LuaScript(<<~LUA
63
21
  local key = KEYS[1]
22
+ local lock_key = KEYS[2]
64
23
  local capacity = tonumber(ARGV[1])
65
24
  local ts = tonumber(ARGV[2])
66
25
  local ttl = tonumber(ARGV[3])
26
+ local cost = tonumber(ARGV[4])
27
+ local lock_ids = {}
67
28
 
68
- local exists
69
- local count
70
- local lock
71
-
72
- -- check to see if key already exists
73
- if ttl == 0 then
74
- exists = redis.call('EXISTS', key)
75
- else
76
- -- and refresh TTL while we're at it
77
- exists = redis.call('EXPIRE', key, ttl * 2)
29
+ -- purge stale hosts
30
+ if ttl > 0 then
31
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
78
32
  end
79
33
 
80
- if exists == 1 then
81
- -- purge stale hosts
82
- if ttl > 0 then
83
- redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
84
- end
85
-
86
- -- check capacity (subtract one for next lock entry)
87
- count = redis.call('ZCARD', key) - 1
34
+ -- check capacity
35
+ local count = redis.call('ZCARD', key)
88
36
 
37
+ if cost == 0 then
38
+ -- just check limit, ie. for .overlimit?
89
39
  if count < capacity then
90
- -- yay, grab a lock
40
+ table.insert(lock_ids, true)
41
+ end
42
+ elseif (count + cost) <= capacity then
43
+ -- grab locks, one per cost
44
+ local lock_id = redis.call('INCRBY', lock_key, cost)
45
+ local locks = {}
91
46
 
92
- -- regenerate next lock entry, which has score inf
93
- lock = unpack(redis.call('ZPOPMAX', key))
94
- redis.call('ZADD', key, 'inf', (lock + 1) % 2^52)
47
+ for i = lock_id - cost + 1, lock_id do
48
+ table.insert(lock_ids, i)
95
49
 
96
- count = count + 1
50
+ table.insert(locks, ts)
51
+ table.insert(locks, i)
97
52
  end
98
- elseif capacity > 0 then
99
- count = 1
100
- lock = "1"
101
53
 
102
- -- create structure to track locks and next id
103
- redis.call('ZADD', key, 'inf', lock + 1)
54
+ redis.call('ZADD', key, unpack(locks))
55
+ count = count + cost
104
56
 
105
57
  if ttl > 0 then
106
- redis.call('EXPIRE', key, ttl * 2)
58
+ redis.call('PEXPIRE', key, ttl)
107
59
  end
108
60
  end
109
61
 
110
- if lock then
111
- -- store lock and timestamp
112
- redis.call('ZADD', key, ts, lock)
113
- end
114
-
115
- return { count, lock }
62
+ return { count, unpack(lock_ids) }
116
63
  LUA
64
+ )
65
+
66
+ def limit(capacity: nil, cost: 1, &block)
67
+ capacity ||= @capacity
117
68
 
118
- def limit(**opts, &block)
119
- unless opts.empty?
120
- return self.class.new(
121
- capacity,
122
- **options.merge(opts)
123
- # **options.merge(timeout: timeout).merge(opts)
124
- ).limit(&block)
69
+ # since fractional cost is not supported, capacity behaves like int
70
+ capacity = capacity.to_i
71
+
72
+ unless cost.is_a?(Integer) && cost >= 0
73
+ raise ArgumentError, "invalid cost: #{cost}"
125
74
  end
126
75
 
127
- count, lock_id = redis.eval(
128
- LUA_SCRIPT,
129
- [ key ],
130
- [ capacity, Time.now.to_i, timeout ]
131
- )
76
+ # timestamp in milliseconds
77
+ ts = (Time.now.to_f * 10**3).to_i
132
78
 
133
- raise Incapacitated unless lock_id
79
+ count, *lock_ids = LUA_SCRIPT.eval(
80
+ redis,
81
+ [ cache_key(key), cache_key('lock_id') ],
82
+ [ capacity, ts, @timeout_msec, cost ]
83
+ )
134
84
 
135
- lock = Lock.new(self, lock_id, count)
85
+ raise Incapacitated if lock_ids.empty?
136
86
 
137
- if block_given?
138
- begin
139
- yield lock
140
- ensure
141
- lock.release
142
- end
143
- else
144
- lock
87
+ release_fn = if cost > 0
88
+ proc { release(lock_ids) }
145
89
  end
90
+ lock = Lock.new(capacity, count, release_fn)
91
+
92
+ yield_lock(lock, &block)
93
+ end
94
+
95
+ def overloaded?
96
+ limit(cost: 0) { false }
97
+ rescue Overloaded
98
+ true
99
+ end
100
+ alias incapacitated? overloaded?
101
+
102
+ private def release(lock_ids)
103
+ res = redis.zrem(cache_key(key), lock_ids)
104
+ res == true || res == lock_ids.count # depending on which version of Redis
146
105
  end
147
106
 
148
- def release(lock)
149
- res = redis.zrem(key, lock.id)
150
- res == true || res == 1 # depending on which version of Redis
107
+ def to_s
108
+ "#<#{self.class}(#{key}: #{capacity} at a time)>"
151
109
  end
152
110
 
153
111
  end
@@ -0,0 +1,68 @@
1
+ module Berater
2
+ module DSL
3
+ refine Berater.singleton_class do
4
+ def new(key, mode = nil, *args, **opts, &block)
5
+ if mode.nil?
6
+ unless block_given?
7
+ raise ArgumentError, 'expected either mode or block'
8
+ end
9
+
10
+ mode, *args = DSL.eval(&block)
11
+ else
12
+ if block_given?
13
+ raise ArgumentError, 'expected either mode or block, not both'
14
+ end
15
+ end
16
+
17
+ super(key, mode, *args, **opts)
18
+ end
19
+ end
20
+
21
+ extend self
22
+
23
+ def eval &block
24
+ @keywords ||= Class.new do
25
+ # create a class where DSL keywords are methods
26
+ KEYWORDS.each do |keyword|
27
+ define_singleton_method(keyword) { keyword }
28
+ end
29
+ end
30
+
31
+ install
32
+ @keywords.class_eval &block
33
+ ensure
34
+ uninstall
35
+ end
36
+
37
+ private
38
+
39
+ KEYWORDS = [
40
+ :second, :minute, :hour,
41
+ :unlimited, :inhibited,
42
+ ].freeze
43
+
44
+ def install
45
+ Integer.class_eval do
46
+ def per(unit)
47
+ [ self, unit ]
48
+ end
49
+ alias every per
50
+
51
+ def at_once
52
+ [ self ]
53
+ end
54
+ alias concurrently at_once
55
+ alias at_a_time at_once
56
+ end
57
+ end
58
+
59
+ def uninstall
60
+ Integer.remove_method :per
61
+ Integer.remove_method :every
62
+
63
+ Integer.remove_method :at_once
64
+ Integer.remove_method :concurrently
65
+ Integer.remove_method :at_a_time
66
+ end
67
+ end
68
+ end
@@ -1,21 +1,20 @@
1
1
  module Berater
2
- class Inhibitor < BaseLimiter
2
+ class Inhibitor < Limiter
3
3
 
4
4
  class Inhibited < Overloaded; end
5
5
 
6
- def initialize(*args, **opts)
7
- super(**opts)
6
+ def initialize(key = :inhibitor, *args, **opts)
7
+ super(key, 0, **opts)
8
8
  end
9
9
 
10
- def limit(**opts, &block)
11
- unless opts.empty?
12
- return self.class.new(
13
- **options.merge(opts)
14
- ).limit(&block)
15
- end
16
-
10
+ def limit(**opts)
17
11
  raise Inhibited
18
12
  end
19
13
 
14
+ def overloaded?
15
+ true
16
+ end
17
+ alias inhibited? overloaded?
18
+
20
19
  end
21
20
  end
@@ -0,0 +1,80 @@
1
+ module Berater
2
+ class Limiter
3
+
4
+ attr_reader :key, :capacity, :options
5
+
6
+ def redis
7
+ options[:redis] || Berater.redis
8
+ end
9
+
10
+ def limit
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def overloaded?
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def to_s
19
+ "#<#{self.class}>"
20
+ end
21
+
22
+ def ==(other)
23
+ self.class == other.class &&
24
+ self.key == other.key &&
25
+ self.capacity == other.capacity &&
26
+ self.args == other.args &&
27
+ self.options == other.options &&
28
+ self.redis.connection == other.redis.connection
29
+ end
30
+
31
+ def self.new(*)
32
+ # can only call via subclass
33
+ raise NotImplementedError if self == Berater::Limiter
34
+
35
+ super
36
+ end
37
+
38
+ protected
39
+
40
+ attr_reader :args
41
+
42
+ def initialize(key, capacity, *args, **opts)
43
+ @key = key
44
+ self.capacity = capacity
45
+ @args = args
46
+ @options = opts
47
+ end
48
+
49
+ def capacity=(capacity)
50
+ unless capacity.is_a?(Numeric)
51
+ raise ArgumentError, "expected Numeric, found #{capacity.class}"
52
+ end
53
+
54
+ if capacity == Float::INFINITY
55
+ raise ArgumentError, 'infinite capacity not supported, use Unlimiter'
56
+ end
57
+
58
+ raise ArgumentError, 'capacity must be >= 0' unless capacity >= 0
59
+
60
+ @capacity = capacity
61
+ end
62
+
63
+ def cache_key(key)
64
+ "#{self.class}:#{key}"
65
+ end
66
+
67
+ def yield_lock(lock, &block)
68
+ if block_given?
69
+ begin
70
+ yield lock
71
+ ensure
72
+ lock.release
73
+ end
74
+ else
75
+ lock
76
+ end
77
+ end
78
+
79
+ end
80
+ end