berater 0.1.2 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04cc88a51db5eca83f7843c1d34cf86213a0ca412fd404e3fff88b3b6994ea8d
4
- data.tar.gz: 4eb67771a03cd0f4179d8d80655efcad738cb877b2fb5813399f136dc743090c
3
+ metadata.gz: f57aeaa9319ae38bdd7d2468443fc5c9ea9ba44cecf47bc5a558f18ecd0ab274
4
+ data.tar.gz: ff14ef90856911e9e18cbfbad6f9799727c999697c5e4d96f47b5fbf665c725c
5
5
  SHA512:
6
- metadata.gz: 9715ae524f7b0fdbbd60d352546ac86a0984f4a5e8966351ae00057e976c391076c270677b29f83c813043c24ad6fa7cae571f224df6400e98fa43bdb4f06cb6
7
- data.tar.gz: 16999ff18ea843d12ce6b847b79ddf3bc2b4372cf01371a63d36213ccc27717e06e8794f6b4a519a59af7cba1afaed3cc3ca92a5baed60302e6f1fe114dd29ac
6
+ metadata.gz: c9d3abeeb9d35c608c194eaf505c5af1350a82583dd12257e70b9d4f96bd3a6cd30db802f57c3bf77d371b5deb5e856e9238eadd3990768205fd37ea3e2e3703
7
+ data.tar.gz: c98078a3c7d3a3eb4e2d523577f9d6dd7f1bbae76c9ecbd8d65961cc7aa1bab6e1a4b8fe84cfb30ee60f4d0ce11dcb58e3b94a3566cf50ee60d1f8e21bab82b1
data/lib/berater.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'berater/version'
2
+ require 'berater/lock'
2
3
 
3
4
 
4
5
  module Berater
@@ -8,41 +9,42 @@ module Berater
8
9
 
9
10
  MODES = {}
10
11
 
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)
18
+ def new(key, mode = nil, *args, **opts, &block)
19
+ if mode.nil?
20
+ unless args.empty?
21
+ raise ArgumentError, '0 arguments expected with block'
22
+ end
23
+
24
+ unless block_given?
25
+ raise ArgumentError, 'expected either mode or block'
26
+ end
27
+
28
+ mode, *args = DSL.eval(&block)
29
+ else
30
+ if block_given?
31
+ raise ArgumentError, 'expected either mode or block, not both'
32
+ end
33
+ end
34
+
20
35
  klass = MODES[mode.to_sym]
21
36
 
22
37
  unless klass
23
38
  raise ArgumentError, "invalid mode: #{mode}"
24
39
  end
25
40
 
26
- klass.new(*args, **opts)
41
+ klass.new(key, *args, **opts)
27
42
  end
28
43
 
29
44
  def register(mode, klass)
30
45
  MODES[mode.to_sym] = klass
31
46
  end
32
47
 
33
- def mode=(mode)
34
- unless MODES.include? mode.to_sym
35
- raise ArgumentError, "invalid mode: #{mode}"
36
- 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
- end
45
-
46
48
  def expunge
47
49
  redis.scan_each(match: "#{self.name}*") do |key|
48
50
  redis.del key
@@ -51,8 +53,13 @@ module Berater
51
53
 
52
54
  end
53
55
 
54
- # load and register limiters
55
- require 'berater/base_limiter'
56
+ # convenience method
57
+ def Berater(key, mode, *args, **opts, &block)
58
+ Berater.new(key, mode, *args, **opts).limit(&block)
59
+ end
60
+
61
+ # load limiters
62
+ require 'berater/limiter'
56
63
  require 'berater/concurrency_limiter'
57
64
  require 'berater/inhibitor'
58
65
  require 'berater/rate_limiter'
@@ -62,3 +69,5 @@ Berater.register(:concurrency, Berater::ConcurrencyLimiter)
62
69
  Berater.register(:inhibited, Berater::Inhibitor)
63
70
  Berater.register(:rate, Berater::RateLimiter)
64
71
  Berater.register(:unlimited, Berater::Unlimiter)
72
+
73
+ require 'berater/dsl'
@@ -1,18 +1,18 @@
1
1
  module Berater
2
- class ConcurrencyLimiter < BaseLimiter
2
+ class ConcurrencyLimiter < Limiter
3
3
 
4
4
  class Incapacitated < Overloaded; end
5
5
 
6
6
  attr_reader :capacity, :timeout
7
7
 
8
- def initialize(capacity, **opts)
9
- super(**opts)
8
+ def initialize(key, capacity, **opts)
9
+ super(key, **opts)
10
10
 
11
11
  self.capacity = capacity
12
12
  self.timeout = opts[:timeout] || 0
13
13
  end
14
14
 
15
- def capacity=(capacity)
15
+ private def capacity=(capacity)
16
16
  unless capacity.is_a? Integer
17
17
  raise ArgumentError, "expected Integer, found #{capacity.class}"
18
18
  end
@@ -22,7 +22,7 @@ module Berater
22
22
  @capacity = capacity
23
23
  end
24
24
 
25
- def timeout=(timeout)
25
+ private def timeout=(timeout)
26
26
  unless timeout.is_a? Integer
27
27
  raise ArgumentError, "expected Integer, found #{timeout.class}"
28
28
  end
@@ -32,124 +32,62 @@ module Berater
32
32
  @timeout = timeout
33
33
  end
34
34
 
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
- @locked_at + limiter.timeout < Time.now
59
- end
60
- end
61
-
62
- LUA_SCRIPT = <<~LUA.gsub(/^\s*(--.*\n)?/, '')
35
+ LUA_SCRIPT = <<~LUA.gsub(/^\s*|\s*--.*/, '')
63
36
  local key = KEYS[1]
37
+ local lock_key = KEYS[2]
64
38
  local capacity = tonumber(ARGV[1])
65
39
  local ts = tonumber(ARGV[2])
66
40
  local ttl = tonumber(ARGV[3])
67
-
68
- local exists
69
- local count
70
41
  local lock
71
- local ts = unpack(redis.call('TIME'))
72
42
 
73
- -- check to see if key already exists
74
- if ttl == 0 then
75
- exists = redis.call('EXISTS', key)
76
- else
77
- -- and refresh TTL while we're at it
78
- exists = redis.call('EXPIRE', key, ttl * 2)
43
+ -- purge stale hosts
44
+ if ttl > 0 then
45
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
79
46
  end
80
47
 
81
- if exists == 1 then
82
- -- purge stale hosts
83
- if ttl > 0 then
84
- redis.call('ZREMRANGEBYSCORE', key, '-inf', ts - ttl)
85
- end
86
-
87
- -- check capacity (subtract one for next lock entry)
88
- count = redis.call('ZCARD', key) - 1
89
-
90
- if count < capacity then
91
- -- yay, grab a lock
92
-
93
- -- regenerate next lock entry, which has score inf
94
- lock = unpack(redis.call('ZPOPMAX', key))
95
- redis.call('ZADD', key, 'inf', (lock + 1) % 2^52)
96
-
97
- count = count + 1
98
- end
99
- else
100
- count = 1
101
- lock = "1"
102
-
103
- -- create structure to track locks and next id
104
- redis.call('ZADD', key, 'inf', lock + 1)
48
+ -- check capacity
49
+ local count = redis.call('ZCARD', key)
105
50
 
106
- if ttl > 0 then
107
- redis.call('EXPIRE', key, ttl * 2)
108
- end
109
- end
110
-
111
- if lock then
112
- -- store lock and timestamp
51
+ if count < capacity then
52
+ -- grab a lock
53
+ lock = redis.call('INCR', lock_key)
113
54
  redis.call('ZADD', key, ts, lock)
55
+ count = count + 1
114
56
  end
115
57
 
116
58
  return { count, lock }
117
59
  LUA
118
60
 
119
- def limit(**opts, &block)
120
- unless opts.empty?
121
- return self.class.new(
122
- capacity,
123
- **options.merge(opts)
124
- # **options.merge(timeout: timeout).merge(opts)
125
- ).limit(&block)
126
- end
127
-
61
+ def limit
128
62
  count, lock_id = redis.eval(
129
63
  LUA_SCRIPT,
130
- [ key ],
64
+ [ cache_key(key), cache_key('lock_id') ],
131
65
  [ capacity, Time.now.to_i, timeout ]
132
66
  )
133
67
 
134
68
  raise Incapacitated unless lock_id
135
69
 
136
- lock = Lock.new(self, lock_id, count)
70
+ lock = Lock.new(self, lock_id, count, -> { release(lock_id) })
137
71
 
138
72
  if block_given?
139
73
  begin
140
74
  yield lock
141
75
  ensure
142
- release(lock)
76
+ lock.release
143
77
  end
144
78
  else
145
79
  lock
146
80
  end
147
81
  end
148
82
 
149
- def release(lock)
150
- res = redis.zrem(key, lock.id)
83
+ private def release(lock_id)
84
+ res = redis.zrem(cache_key(key), lock_id)
151
85
  res == true || res == 1 # depending on which version of Redis
152
86
  end
153
87
 
88
+ def to_s
89
+ "#<#{self.class}(#{key}: #{capacity} at a time)>"
90
+ end
91
+
154
92
  end
155
93
  end
@@ -0,0 +1,57 @@
1
+ module Berater
2
+ module DSL
3
+ extend self
4
+
5
+ def eval &block
6
+ @keywords ||= Class.new do
7
+ # create a class where DSL keywords are methods
8
+ KEYWORDS.each do |keyword|
9
+ define_singleton_method(keyword) { keyword }
10
+ end
11
+ end
12
+
13
+ install
14
+ @keywords.class_eval &block
15
+ ensure
16
+ uninstall
17
+ end
18
+
19
+ private
20
+
21
+ def each &block
22
+ Berater::MODES.map do |mode, limiter|
23
+ next unless limiter.const_defined?(:DSL, false)
24
+ limiter.const_get(:DSL)
25
+ end.compact.each(&block)
26
+ end
27
+
28
+ KEYWORDS = [
29
+ :second, :minute, :hour,
30
+ :unlimited, :inhibited,
31
+ ].freeze
32
+
33
+ def install
34
+ Integer.class_eval do
35
+ def per(unit)
36
+ [ :rate, self, unit ]
37
+ end
38
+ alias every per
39
+
40
+ def at_once
41
+ [ :concurrency, self ]
42
+ end
43
+ alias concurrently at_once
44
+ alias at_a_time at_once
45
+ end
46
+ end
47
+
48
+ def uninstall
49
+ Integer.remove_method :per
50
+ Integer.remove_method :every
51
+
52
+ Integer.remove_method :at_once
53
+ Integer.remove_method :concurrently
54
+ Integer.remove_method :at_a_time
55
+ end
56
+ end
57
+ end
@@ -1,19 +1,13 @@
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, **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
17
11
  raise Inhibited
18
12
  end
19
13
 
@@ -0,0 +1,30 @@
1
+ module Berater
2
+ class Limiter
3
+
4
+ attr_reader :key, :options
5
+
6
+ def redis
7
+ options[:redis] || Berater.redis
8
+ end
9
+
10
+ def limit
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def to_s
15
+ "#<#{self.class}>"
16
+ end
17
+
18
+ protected
19
+
20
+ def initialize(key, **opts)
21
+ @key = key
22
+ @options = opts
23
+ end
24
+
25
+ def cache_key(key)
26
+ "#{self.class}:#{key}"
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,36 @@
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? && !expired?
17
+ end
18
+
19
+ def expired?
20
+ timeout ? @locked_at + timeout < Time.now : false
21
+ end
22
+
23
+ def release
24
+ raise 'lock expired' if expired?
25
+ raise 'lock already released' unless locked?
26
+
27
+ @released_at = Time.now
28
+ @release_fn ? @release_fn.call : true
29
+ end
30
+
31
+ def timeout
32
+ limiter.respond_to?(:timeout) ? limiter.timeout : nil
33
+ end
34
+
35
+ end
36
+ end
@@ -1,18 +1,18 @@
1
1
  module Berater
2
- class RateLimiter < BaseLimiter
2
+ class RateLimiter < Limiter
3
3
 
4
4
  class Overrated < Overloaded; end
5
5
 
6
6
  attr_accessor :count, :interval
7
7
 
8
- def initialize(count, interval, **opts)
9
- super(**opts)
8
+ def initialize(key, count, interval, **opts)
9
+ super(key, **opts)
10
10
 
11
11
  self.count = count
12
12
  self.interval = interval
13
13
  end
14
14
 
15
- def count=(count)
15
+ private def count=(count)
16
16
  unless count.is_a? Integer
17
17
  raise ArgumentError, "expected Integer, found #{count.class}"
18
18
  end
@@ -22,12 +22,13 @@ module Berater
22
22
  @count = count
23
23
  end
24
24
 
25
- def interval=(interval)
25
+ private def interval=(interval)
26
26
  @interval = interval.dup
27
27
 
28
28
  case @interval
29
29
  when Integer
30
30
  raise ArgumentError, "interval must be >= 0" unless @interval >= 0
31
+ @interval_sec = @interval
31
32
  when String
32
33
  @interval = @interval.to_sym
33
34
  when Symbol
@@ -38,47 +39,96 @@ module Berater
38
39
  if @interval.is_a? Symbol
39
40
  case @interval
40
41
  when :sec, :second, :seconds
41
- @interval = 1
42
+ @interval = :second
43
+ @interval_sec = 1
42
44
  when :min, :minute, :minutes
43
- @interval = 60
45
+ @interval = :minute
46
+ @interval_sec = 60
44
47
  when :hour, :hours
45
- @interval = 60 * 60
48
+ @interval = :hour
49
+ @interval_sec = 60 * 60
46
50
  else
47
51
  raise ArgumentError, "unexpected interval value: #{interval}"
48
52
  end
49
53
  end
50
-
51
- @interval
52
54
  end
53
55
 
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)
56
+ LUA_SCRIPT = <<~LUA.gsub(/^\s*|\s*--.*/, '')
57
+ local key = KEYS[1]
58
+ local ts_key = KEYS[2]
59
+ local ts = tonumber(ARGV[1])
60
+ local capacity = tonumber(ARGV[2])
61
+ local usec_per_drip = tonumber(ARGV[3])
62
+ local count = 0
63
+
64
+ -- timestamp of last update
65
+ local last_ts = tonumber(redis.call('GET', ts_key))
66
+
67
+ if last_ts then
68
+ count = tonumber(redis.call('GET', key)) or 0
69
+
70
+ -- adjust for time passing
71
+ local drips = math.floor((ts - last_ts) / usec_per_drip)
72
+ count = math.max(0, count - drips)
61
73
  end
62
74
 
63
- ts = Time.now.to_i
75
+ local allowed = count + 1 <= capacity
76
+
77
+ if allowed then
78
+ count = count + 1
64
79
 
65
- # bucket into time slot
66
- rkey = "%s:%d" % [ key, ts - ts % @interval ]
80
+ -- time for bucket to empty, in milliseconds
81
+ local ttl = math.ceil((count * usec_per_drip) / 1000)
67
82
 
68
- count, _ = redis.multi do
69
- redis.incr rkey
70
- redis.expire rkey, @interval * 2
83
+ -- update count and last_ts, with expirations
84
+ redis.call('SET', key, count, 'PX', ttl)
85
+ redis.call('SET', ts_key, ts, 'PX', ttl)
71
86
  end
72
87
 
73
- raise Overrated if count > @count
88
+ return { count, allowed }
89
+ LUA
90
+
91
+ def limit
92
+ usec_per_drip = (@interval_sec * 10**6) / @count
93
+
94
+ # timestamp in microseconds
95
+ ts = (Time.now.to_f * 10**6).to_i
96
+
97
+ count, allowed = redis.eval(
98
+ LUA_SCRIPT,
99
+ [ cache_key(key), cache_key("#{key}-ts") ],
100
+ [ ts, @count, usec_per_drip ]
101
+ )
102
+
103
+ raise Overrated unless allowed
104
+
105
+ lock = Lock.new(self, "#{ts}-#{count}", count)
74
106
 
75
107
  if block_given?
76
- yield
108
+ begin
109
+ yield lock
110
+ ensure
111
+ lock.release
112
+ end
77
113
  else
78
- count
114
+ lock
79
115
  end
80
116
  end
81
117
 
118
+ def to_s
119
+ msg = if @interval.is_a? Integer
120
+ if @interval == 1
121
+ "every second"
122
+ else
123
+ "every #{@interval} seconds"
124
+ end
125
+ else
126
+ "per #{@interval}"
127
+ end
128
+
129
+ "#<#{self.class}(#{key}: #{count} #{msg})>"
130
+ end
131
+
82
132
  end
83
133
  end
84
134