redis-throttle 0.0.1 → 1.0.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: d6578fcf4ed5b682873d8f6c31a629bf8ab04fa3f747a6e85e67d552fa22ad40
4
- data.tar.gz: c3d2ea3307187912af4d4f93ecee4f8a2fc2e386922511b63e3a9ad93e547c49
3
+ metadata.gz: 94cbed31445714a0f2883bfbe8e317dff6813db80d93c73b7a16a32dfb45524a
4
+ data.tar.gz: 5aa8ecb1c5d5301f314f844bbdbdd720d26d881b6820966cf42682302bd00178
5
5
  SHA512:
6
- metadata.gz: 861efde66ca39700db8a6922add9e7539ecd13b9ecea1a79fe14575df1ca75772ac49608a298bbdb92fce7ec7681cb40eeaf3768089e7481395c57eb6ad23418
7
- data.tar.gz: a1299fb9d99b21c5cca90823812e77feda7fccc7ed4e69778afcd2bb87a0c74aa94f48746047f3fd8b02ccf2076d4344c4047ca499b69090bfffd823cdc6ae87
6
+ metadata.gz: 89d197efa9dadad39539510da10ec183c2df3f00c1b12063591827ab7ffd02b368ab92a9643a20d83dc40751bbfa2cdf33ae42f2d41717d314d40f496628af53
7
+ data.tar.gz: d07140bc01e01b051dcabd266b0a2d5410ce9289d74b5550c44da903c8d3b0f5e574a8cef3ae5b777b26fb1d5557e2fb294e9b0a7394b75e77c0c31e047def75
@@ -1,12 +1,288 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "throttle/errors"
4
- require_relative "throttle/version"
5
- require_relative "throttle/concurrency"
6
- require_relative "throttle/threshold"
3
+ require "redis"
4
+ require "set"
5
+ require "securerandom"
6
+
7
+ require_relative "./throttle/api"
8
+ require_relative "./throttle/errors"
9
+ require_relative "./throttle/class_methods"
10
+ require_relative "./throttle/concurrency"
11
+ require_relative "./throttle/rate_limit"
12
+ require_relative "./throttle/version"
7
13
 
8
14
  # @see https://github.com/redis/redis-rb
9
15
  class Redis
10
- # Distributed threshold and concurrency throttling.
11
- module Throttle; end
16
+ # Distributed rate limit and concurrency throttling.
17
+ class Throttle
18
+ extend ClassMethods
19
+
20
+ # @param (see Api#initialize)
21
+ def initialize(redis: nil)
22
+ @api = Api.new(:redis => redis)
23
+ @strategies = SortedSet.new
24
+ end
25
+
26
+ # @api private
27
+ #
28
+ # Dup internal strategies plan.
29
+ #
30
+ # @return [void]
31
+ def initialize_dup(original)
32
+ super
33
+
34
+ @strategies = original.strategies.dup
35
+ end
36
+
37
+ # @api private
38
+ #
39
+ # Clone internal strategies plan.
40
+ #
41
+ # @return [void]
42
+ def initialize_clone(original)
43
+ super
44
+
45
+ @strategies = original.strategies.clone
46
+ end
47
+
48
+ # Add *concurrency* strategy to the throttle. Use it to guarantee `limit`
49
+ # amount of concurrently running code blocks.
50
+ #
51
+ # @example
52
+ # throttle = Redis::Throttle.new
53
+ #
54
+ # # Allow max 2 concurrent execution units
55
+ # throttle.concurrency(:xxx, :limit => 2, :ttl => 10)
56
+ #
57
+ # throttle.acquire(:token => "a") && :aye || :nay # => :aye
58
+ # throttle.acquire(:token => "b") && :aye || :nay # => :aye
59
+ # throttle.acquire(:token => "c") && :aye || :nay # => :nay
60
+ #
61
+ # throttle.release(:token => "a")
62
+ #
63
+ # throttle.acquire(:token => "c") && :aye || :nay # => :aye
64
+ #
65
+ #
66
+ # @param (see Concurrency#initialize)
67
+ # @return [Throttle] self
68
+ def concurrency(bucket, limit:, ttl:)
69
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
70
+
71
+ @strategies << Concurrency.new(bucket, :limit => limit, :ttl => ttl)
72
+
73
+ self
74
+ end
75
+
76
+ # Add *rate limit* strategy to the throttle. Use it to guarantee `limit`
77
+ # amount of units in `period` of time.
78
+ #
79
+ # @example
80
+ # throttle = Redis::Throttle.new
81
+ #
82
+ # # Allow 2 execution units per 10 seconds
83
+ # throttle.rate_limit(:xxx, :limit => 2, :period => 10)
84
+ #
85
+ # throttle.acquire && :aye || :nay # => :aye
86
+ # sleep 5
87
+ #
88
+ # throttle.acquire && :aye || :nay # => :aye
89
+ # throttle.acquire && :aye || :nay # => :nay
90
+ #
91
+ # sleep 6
92
+ # throttle.acquire && :aye || :nay # => :aye
93
+ # throttle.acquire && :aye || :nay # => :nay
94
+ #
95
+ # @param (see RateLimit#initialize)
96
+ # @return [Throttle] self
97
+ def rate_limit(bucket, limit:, period:)
98
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
99
+
100
+ @strategies << RateLimit.new(bucket, :limit => limit, :period => period)
101
+
102
+ self
103
+ end
104
+
105
+ # Merge in strategies of the `other` throttle.
106
+ #
107
+ # @example
108
+ # a = Redis::Throttle.concurrency(:a, :limit => 1, :ttl => 2)
109
+ # b = Redis::Throttle.rate_limit(:b, :limit => 3, :period => 4)
110
+ # c = Redis::Throttle
111
+ # .concurrency(:a, :limit => 1, :ttl => 2)
112
+ # .rate_limit(:b, :limit => 3, :period => 4)
113
+ #
114
+ # a.merge!(b)
115
+ #
116
+ # a == c # => true
117
+ #
118
+ # @return [Throttle] self
119
+ def merge!(other)
120
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
121
+
122
+ @strategies.merge(other.strategies)
123
+
124
+ self
125
+ end
126
+
127
+ alias << merge!
128
+
129
+ # Non-destructive version of {#merge!}. Returns new {Throttle} instance with
130
+ # union of `self` and `other` strategies.
131
+ #
132
+ # @example
133
+ # a = Redis::Throttle.concurrency(:a, :limit => 1, :ttl => 2)
134
+ # b = Redis::Throttle.rate_limit(:b, :limit => 3, :period => 4)
135
+ # c = Redis::Throttle
136
+ # .concurrency(:a, :limit => 1, :ttl => 2)
137
+ # .rate_limit(:b, :limit => 3, :period => 4)
138
+ #
139
+ # a.merge(b) == c # => true
140
+ # a == c # => false
141
+ #
142
+ # @return [Throttle] new throttle
143
+ def merge(other)
144
+ dup.merge!(other)
145
+ end
146
+
147
+ alias | merge
148
+
149
+ # Prevents further modifications to the throttle instance.
150
+ #
151
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-freeze
152
+ # @return [Throttle] self
153
+ def freeze
154
+ @strategies.freeze
155
+
156
+ super
157
+ end
158
+
159
+ # Returns `true` if the `other` is an instance of {Throttle} with the same
160
+ # set of strategies.
161
+ #
162
+ # @example
163
+ # a = Redis::Throttle
164
+ # .concurrency(:a, :limit => 1, :ttl => 2)
165
+ # .rate_limit(:b, :limit => 3, :period => 4)
166
+ #
167
+ # b = Redis::Throttle
168
+ # .rate_limit(:b, :limit => 3, :period => 4)
169
+ # .concurrency(:a, :limit => 1, :ttl => 2)
170
+ #
171
+ # a == b # => true
172
+ #
173
+ # @return [Boolean]
174
+ def ==(other)
175
+ other.is_a?(self.class) && @strategies == other.strategies
176
+ end
177
+
178
+ alias eql? ==
179
+
180
+ # Calls given block execution lock was acquired, and ensures to {#release}
181
+ # it after the block.
182
+ #
183
+ # @example
184
+ # throttle = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 10)
185
+ #
186
+ # throttle.call { :aye } # => :aye
187
+ # throttle.call { :aye } # => :aye
188
+ #
189
+ # throttle.acquire
190
+ #
191
+ # throttle.call { :aye } # => nil
192
+ #
193
+ # @param (see #acquire)
194
+ # @return [Object] last satement of the block if execution lock was acquired.
195
+ # @return [nil] otherwise
196
+ def call(token: SecureRandom.uuid)
197
+ return unless acquire(:token => token)
198
+
199
+ begin
200
+ yield
201
+ ensure
202
+ release(:token => token)
203
+ end
204
+ end
205
+
206
+ # Acquire execution lock.
207
+ #
208
+ # @example
209
+ # throttle = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 10)
210
+ #
211
+ # if (token = throttle.acquire)
212
+ # # ... do something
213
+ # end
214
+ #
215
+ # throttle.release(:token => token) if token
216
+ #
217
+ # @see #call
218
+ # @see #release
219
+ # @param token [#to_s] Unit of work ID
220
+ # @return [#to_s] `token` as is if lock was acquired
221
+ # @return [nil] otherwise
222
+ def acquire(token: SecureRandom.uuid)
223
+ token if @api.acquire(:strategies => @strategies, :token => token.to_s)
224
+ end
225
+
226
+ # Release acquired execution lock. Notice that this affects {#concurrency}
227
+ # locks only.
228
+ #
229
+ # @example
230
+ # concurrency = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 60)
231
+ # rate_limit = Redis::Throttle.rate_limit(:xxx, :limit => 1, :period => 60)
232
+ # throttle = concurrency | rate_limit
233
+ #
234
+ # throttle.acquire(:token => "uno")
235
+ # throttle.release(:token => "uno")
236
+ #
237
+ # concurrency.acquire(:token => "dos") # => "dos"
238
+ # rate_limit.acquire(:token => "dos") # => nil
239
+ #
240
+ # @see #acquire
241
+ # @see #reset
242
+ # @see #call
243
+ # @param token [#to_s] Unit of work ID
244
+ # @return [void]
245
+ def release(token:)
246
+ @api.release(:strategies => @strategies, :token => token.to_s)
247
+
248
+ nil
249
+ end
250
+
251
+ # Flush all counters.
252
+ #
253
+ # @example
254
+ # throttle = Redis::Throttle.concurrency(:xxx, :limit => 2, :ttl => 60)
255
+ #
256
+ # thottle.acquire(:token => "a") # => "a"
257
+ # thottle.acquire(:token => "b") # => "b"
258
+ # thottle.acquire(:token => "c") # => nil
259
+ #
260
+ # throttle.reset
261
+ #
262
+ # thottle.acquire(:token => "c") # => "c"
263
+ # thottle.acquire(:token => "d") # => "d"
264
+ #
265
+ # @return [void]
266
+ def reset
267
+ @api.reset(:strategies => @strategies)
268
+
269
+ nil
270
+ end
271
+
272
+ # Return usage info for all strategies of the throttle.
273
+ #
274
+ # @example
275
+ # throttle.info.each do |strategy, current_value|
276
+ # # ...
277
+ # end
278
+ #
279
+ # @return (see Api#info)
280
+ def info
281
+ @api.info(:strategies => @strategies)
282
+ end
283
+
284
+ protected
285
+
286
+ attr_accessor :strategies
287
+ end
12
288
  end
@@ -0,0 +1,195 @@
1
+ if 1 ~= #KEYS or 0 == #ARGV then
2
+ return redis.error_reply("syntax error")
3
+ end
4
+
5
+ local commands = {}
6
+
7
+ commands.ACQUIRE = {
8
+ params = { "strategies", "token", "timestamp" },
9
+ handler = function (params)
10
+ local now = params.timestamp
11
+ local token = params.token
12
+ local locks = {}
13
+
14
+ local acquire = {
15
+ rate_limit = function (strategy)
16
+ local key, limit, period = strategy.key, strategy.limit, strategy.period
17
+
18
+ if redis.call("LLEN", key) < limit or tonumber(redis.call("LINDEX", key, -1)) < now then
19
+ return function ()
20
+ redis.call("LPUSH", key, now + period)
21
+ redis.call("LTRIM", key, 0, limit - 1)
22
+ redis.call("EXPIRE", key, period)
23
+ end
24
+ end
25
+ end,
26
+
27
+ concurrency = function (strategy)
28
+ local key, limit, ttl = strategy.key, strategy.limit, strategy.ttl
29
+
30
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. now)
31
+
32
+ if redis.call("ZCARD", key) < limit or redis.call("ZSCORE", key, token) then
33
+ return function ()
34
+ redis.call("ZADD", key, now + ttl, token)
35
+ redis.call("EXPIRE", key, ttl)
36
+ end
37
+ end
38
+ end
39
+ }
40
+
41
+ for _, strategy in ipairs(params.strategies) do
42
+ local lock = acquire[strategy.name](strategy)
43
+
44
+ if lock then
45
+ table.insert(locks, lock)
46
+ else
47
+ return 1
48
+ end
49
+ end
50
+
51
+ for _, lock in ipairs(locks) do
52
+ lock()
53
+ end
54
+
55
+ return 0
56
+ end
57
+ }
58
+
59
+ commands.RELEASE = {
60
+ params = { "strategies", "token" },
61
+ handler = function (params)
62
+ for _, strategy in ipairs(params.strategies) do
63
+ if "concurrency" == strategy.name then
64
+ redis.call("ZREM", strategy.key, params.token)
65
+ end
66
+ end
67
+
68
+ return redis.status_reply("ok")
69
+ end
70
+ }
71
+
72
+ commands.RESET = {
73
+ params = { "strategies" },
74
+ handler = function (params)
75
+ for _, strategy in ipairs(params.strategies) do
76
+ redis.call("DEL", strategy.key)
77
+ end
78
+
79
+ return redis.status_reply("ok")
80
+ end
81
+ }
82
+
83
+ commands.INFO = {
84
+ params = { "strategies", "timestamp" },
85
+ handler = function (params)
86
+ local usage, now = {}, params.timestamp
87
+
88
+ for _, strategy in ipairs(params.strategies) do
89
+ local key = strategy.key
90
+
91
+ if "concurrency" == strategy.name then
92
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. now)
93
+ table.insert(usage, redis.call("ZCARD", key))
94
+ elseif "rate_limit" == strategy.name then
95
+ local last = tonumber(redis.call("LINDEX", key, -1) or now)
96
+
97
+ while last < now do
98
+ redis.call("RPOP", key)
99
+ last = tonumber(redis.call("LINDEX", key, -1) or now)
100
+ end
101
+
102
+ table.insert(usage, redis.call("LLEN", key))
103
+ end
104
+ end
105
+
106
+ return usage
107
+ end
108
+ }
109
+
110
+ local function parse_params (parts)
111
+ local parse = {}
112
+
113
+ function parse.strategies (pos)
114
+ local strategies = {}
115
+
116
+ while pos + 3 <= #ARGV do
117
+ local name, strategy = string.lower(ARGV[pos]), nil
118
+ local bucket, limit, ttl_or_period = ARGV[pos + 1], tonumber(ARGV[pos + 2]), tonumber(ARGV[pos + 3])
119
+
120
+ if "concurrency" == name then
121
+ strategy = { name = name, bucket = bucket, limit = limit, ttl = ttl_or_period }
122
+ elseif "rate_limit" == name then
123
+ strategy = { name = name, bucket = bucket, limit = limit, period = ttl_or_period }
124
+ else
125
+ break
126
+ end
127
+
128
+ if bucket and 0 < limit and 0 < ttl_or_period then
129
+ strategy.key = table.concat({ KEYS[1], name, bucket, limit, ttl_or_period }, ":")
130
+ table.insert(strategies, strategy)
131
+
132
+ pos = pos + 4
133
+ else
134
+ return { err = "invalid " .. name .. " options" }
135
+ end
136
+ end
137
+
138
+ if 0 == #strategies then
139
+ return { err = "missing strategies" }
140
+ end
141
+
142
+ return { val = strategies, pos = pos }
143
+ end
144
+
145
+ function parse.token (pos)
146
+ if ARGV[pos] and ARGV[pos + 1] and "TOKEN" == string.upper(ARGV[pos]) then
147
+ return { val = ARGV[pos + 1], pos = pos + 2 }
148
+ end
149
+
150
+ return { err = "missing or invalid token" }
151
+ end
152
+
153
+ function parse.timestamp (pos)
154
+ if ARGV[pos] and ARGV[pos + 1] and "TS" == string.upper(ARGV[pos]) then
155
+ local timestamp = tonumber(ARGV[pos + 1])
156
+
157
+ if 0 < timestamp then
158
+ return { val = timestamp, pos = pos + 2 }
159
+ end
160
+ end
161
+
162
+ return { err = "missing or invalid timestamp" }
163
+ end
164
+
165
+ local params, pos = {}, 2
166
+
167
+ for _, part in ipairs(parts) do
168
+ local out = parse[part](pos)
169
+
170
+ if out.err then
171
+ return out
172
+ end
173
+
174
+ params[part] = out.val
175
+ pos = out.pos
176
+ end
177
+
178
+ if pos < #ARGV then
179
+ return { err = "wrong number of arguments" }
180
+ end
181
+
182
+ return { val = params }
183
+ end
184
+
185
+ local command = commands[string.upper(ARGV[1])]
186
+ if not command then
187
+ return redis.error_reply("invalid command")
188
+ end
189
+
190
+ local params = parse_params(command.params)
191
+ if params.err then
192
+ return redis.error_reply(params.err)
193
+ end
194
+
195
+ return command.handler(params.val)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ require_relative "./script"
6
+ require_relative "./concurrency"
7
+ require_relative "./rate_limit"
8
+
9
+ class Redis
10
+ class Throttle
11
+ # @api private
12
+ class Api
13
+ NAMESPACE = "throttle"
14
+ private_constant :NAMESPACE
15
+
16
+ KEYS_PATTERN = %r{
17
+ \A
18
+ #{NAMESPACE}:
19
+ (?<strategy>concurrency|rate_limit):
20
+ (?<bucket>.+):
21
+ (?<limit>\d+):
22
+ (?<ttl_or_period>\d+)
23
+ \z
24
+ }x.freeze
25
+ private_constant :KEYS_PATTERN
26
+
27
+ SCRIPT = Script.new(File.read("#{__dir__}/api.lua"))
28
+ private_constant :SCRIPT
29
+
30
+ # @param redis [Redis, Redis::Namespace, #to_proc]
31
+ def initialize(redis: nil)
32
+ @redis =
33
+ if redis.respond_to?(:to_proc)
34
+ redis.to_proc
35
+ else
36
+ ->(&b) { b.call(redis || Redis.current) }
37
+ end
38
+ end
39
+
40
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
41
+ # @param token [String]
42
+ # @return [Boolean]
43
+ def acquire(strategies:, token:)
44
+ execute(:ACQUIRE, to_params(strategies) << :TOKEN << token << :TS << Time.now.to_i).zero?
45
+ end
46
+
47
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
48
+ # @param token [String]
49
+ # @return [void]
50
+ def release(strategies:, token:)
51
+ execute(:RELEASE, to_params(strategies.grep(Concurrency)) << :TOKEN << token)
52
+ end
53
+
54
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
55
+ # @return [void]
56
+ def reset(strategies:)
57
+ execute(:RESET, to_params(strategies))
58
+ end
59
+
60
+ # @param match [String]
61
+ # @return [Array<Concurrency, RateLimit>]
62
+ def strategies(match:)
63
+ results = []
64
+
65
+ @redis.call do |redis|
66
+ redis.scan_each(:match => "#{NAMESPACE}:*:#{match}:*:*") do |key|
67
+ strategy = from_key(key)
68
+ results << strategy if strategy
69
+ end
70
+ end
71
+
72
+ results
73
+ end
74
+
75
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
76
+ # @return [Hash{Concurrency => Integer, RateLimit => Integer}]
77
+ def info(strategies:)
78
+ strategies.zip(execute(:INFO, to_params(strategies) << :TS << Time.now.to_i)).to_h
79
+ end
80
+
81
+ # @note Used for specs only.
82
+ # @return [void]
83
+ def ping
84
+ @redis.call(&:ping)
85
+ end
86
+
87
+ private
88
+
89
+ def execute(command, argv)
90
+ @redis.call { |redis| SCRIPT.call(redis, :keys => [NAMESPACE], :argv => [command, *argv]) }
91
+ end
92
+
93
+ def from_key(key)
94
+ md = KEYS_PATTERN.match(key)
95
+
96
+ case md && md[:strategy]
97
+ when "concurrency"
98
+ Concurrency.new(md[:bucket], :limit => md[:limit], :ttl => md[:ttl_or_period])
99
+ when "rate_limit"
100
+ RateLimit.new(md[:bucket], :limit => md[:limit], :period => md[:ttl_or_period])
101
+ end
102
+ end
103
+
104
+ def to_params(strategies)
105
+ result = []
106
+
107
+ strategies.each do |strategy|
108
+ case strategy
109
+ when Concurrency
110
+ result << "concurrency" << strategy.bucket << strategy.limit << strategy.ttl
111
+ when RateLimit
112
+ result << "rate_limit" << strategy.bucket << strategy.limit << strategy.period
113
+ end
114
+ end
115
+
116
+ result
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./api"
4
+
5
+ class Redis
6
+ class Throttle
7
+ module ClassMethods
8
+ # Syntax sugar for {Throttle#concurrency}.
9
+ #
10
+ # @see #concurrency
11
+ # @param (see Throttle#initialize)
12
+ # @param (see Throttle#concurrency)
13
+ # @return (see Throttle#concurrency)
14
+ def concurrency(bucket, limit:, ttl:, redis: nil)
15
+ new(:redis => redis).concurrency(bucket, :limit => limit, :ttl => ttl)
16
+ end
17
+
18
+ # Syntax sugar for {Throttle#rate_limit}.
19
+ #
20
+ # @see #concurrency
21
+ # @param (see Throttle#initialize)
22
+ # @param (see Throttle#rate_limit)
23
+ # @return (see Throttle#rate_limit)
24
+ def rate_limit(bucket, limit:, period:, redis: nil)
25
+ new(:redis => redis).rate_limit(bucket, :limit => limit, :period => period)
26
+ end
27
+
28
+ # Return usage info for all known (in use) strategies.
29
+ #
30
+ # @example
31
+ # Redis::Throttle.info(:match => "*_api").each do |strategy, current_value|
32
+ # # ...
33
+ # end
34
+ #
35
+ # @param match [#to_s]
36
+ # @return (see Api#info)
37
+ def info(match: "*", redis: nil)
38
+ api = Api.new(:redis => redis)
39
+ strategies = api.strategies(:match => match.to_s)
40
+
41
+ api.info(:strategies => strategies)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,42 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./script"
4
-
5
3
  class Redis
6
- module Throttle
4
+ class Throttle
7
5
  class Concurrency
8
- SCRIPT = Script.new(File.read("#{__dir__}/concurrency.lua"))
9
- private_constant :SCRIPT
6
+ # @!attribute [r] bucket
7
+ # @return [String] Throttling group name
8
+ attr_reader :bucket
9
+
10
+ # @!attribute [r] limit
11
+ # @return [Integer] Max allowed concurrent units
12
+ attr_reader :limit
10
13
 
11
- # @param bucket [#to_s]
12
- # @param limit [#to_i]
13
- # @param ttl [#to_i]
14
+ # @!attribute [r] ttl
15
+ # @return [Integer] Time (in seconds) to hold the lock before
16
+ # releasing it (in case it wasn't released already)
17
+ attr_reader :ttl
18
+
19
+ # @param bucket [#to_s] Throttling group name
20
+ # @param limit [#to_i] Max allowed concurrent units
21
+ # @param ttl [#to_i] Time (in seconds) to hold the lock before
22
+ # releasing it (in case it wasn't released already)
14
23
  def initialize(bucket, limit:, ttl:)
15
- @bucket = bucket.to_s
24
+ @bucket = -bucket.to_s
16
25
  @limit = limit.to_i
17
26
  @ttl = ttl.to_i
18
27
  end
19
28
 
20
- # @param redis [Redis, Redis::Namespace]
21
- # @param token [#to_s]
29
+ # Returns `true` if `other` is a {Concurrency} instance with the same
30
+ # {#bucket}, {#limit}, and {#ttl}.
31
+ #
32
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-eql-3F
33
+ # @param other [Object]
22
34
  # @return [Boolean]
23
- def acquire(redis, token:)
24
- SCRIPT
25
- .call(redis, :keys => [@bucket], :argv => [token.to_s, @limit, @ttl, Time.now.to_i])
26
- .zero?
35
+ def ==(other)
36
+ return true if equal? other
37
+ return false unless other.is_a?(self.class)
38
+
39
+ @bucket == other.bucket && @limit == other.limit && @ttl == other.ttl
40
+ end
41
+
42
+ alias eql? ==
43
+
44
+ # @api private
45
+ #
46
+ # Compare `self` with `other` strategy:
47
+ #
48
+ # - Returns `nil` if `other` is neither {Concurrency} nor {RateLimit}
49
+ # - Returns `1` if `other` is a {RateLimit}
50
+ # - Returns `1` if `other` is a {Concurrency} with lower {#limit}
51
+ # - Returns `0` if `other` is a {Concurrency} with the same {#limit}
52
+ # - Returns `-1` if `other` is a {Concurrency} with bigger {#limit}
53
+ #
54
+ # @return [-1, 0, 1, nil]
55
+ def <=>(other)
56
+ complexity <=> other.complexity if other.respond_to? :complexity
27
57
  end
28
58
 
29
- # @param redis [Redis, Redis::Namespace]
30
- # @param token [#to_s]
31
- # @return [void]
32
- def release(redis, token:)
33
- redis.zrem(@bucket, token.to_s)
59
+ # @api private
60
+ #
61
+ # Generates an Integer hash value for this object.
62
+ #
63
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-hash
64
+ # @return [Integer]
65
+ def hash
66
+ @hash ||= [@bucket, @limit, @ttl].hash
34
67
  end
35
68
 
36
- # @param redis [Redis, Redis::Namespace]
37
- # @return [void]
38
- def reset(redis)
39
- redis.del(@bucket)
69
+ # @api private
70
+ #
71
+ # @return [Array(Integer, Integer)] Strategy complexity pseudo-score
72
+ def complexity
73
+ [1, @limit]
40
74
  end
41
75
  end
42
76
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Redis
4
- module Throttle
4
+ class Throttle
5
5
  class Error < StandardError; end
6
- class LuaError < Error; end
6
+ class ScriptError < Error; end
7
+
8
+ class FrozenError < RuntimeError; end if RUBY_VERSION < "2.5"
7
9
  end
8
10
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ class Throttle
5
+ class RateLimit
6
+ # @!attribute [r] bucket
7
+ # @return [String] Throttling group name
8
+ attr_reader :bucket
9
+
10
+ # @!attribute [r] limit
11
+ # @return [Integer] Max allowed units per {#period}
12
+ attr_reader :limit
13
+
14
+ # @!attribute [r] period
15
+ # @return [Integer] Period in seconds
16
+ attr_reader :period
17
+
18
+ # @param bucket [#to_s] Throttling group name
19
+ # @param limit [#to_i] Max allowed units per `period`
20
+ # @param period [#to_i] Period in seconds
21
+ def initialize(bucket, limit:, period:)
22
+ @bucket = -bucket.to_s
23
+ @limit = limit.to_i
24
+ @period = period.to_i
25
+ end
26
+
27
+ # Returns `true` if `other` is a {RateLimit} instance with the same
28
+ # {#bucket}, {#limit}, and {#period}.
29
+ #
30
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-eql-3F
31
+ # @param other [Object]
32
+ # @return [Boolean]
33
+ def ==(other)
34
+ return true if equal? other
35
+ return false unless other.is_a?(self.class)
36
+
37
+ @bucket == other.bucket && @limit == other.limit && @period == other.period
38
+ end
39
+
40
+ alias eql? ==
41
+
42
+ # @api private
43
+ #
44
+ # Compare `self` with `other` strategy:
45
+ #
46
+ # - Returns `nil` if `other` is neither {Concurrency} nor {RateLimit}
47
+ # - Returns `-1` if `other` is a {Concurrency}
48
+ # - Returns `1` if `other` is a {RateLimit} with lower {#limit}
49
+ # - Returns `0` if `other` is a {RateLimit} with the same {#limit}
50
+ # - Returns `-1` if `other` is a {RateLimit} with bigger {#limit}
51
+ #
52
+ # @return [-1, 0, 1, nil]
53
+ def <=>(other)
54
+ complexity <=> other.complexity if other.respond_to? :complexity
55
+ end
56
+
57
+ # @api private
58
+ #
59
+ # Generates an Integer hash value for this object.
60
+ #
61
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-hash
62
+ # @return [Integer]
63
+ def hash
64
+ @hash ||= [@bucket, @limit, @period].hash
65
+ end
66
+
67
+ # @api private
68
+ #
69
+ # @return [Array(Integer, Integer)] Strategy complexity pseudo-score
70
+ def complexity
71
+ [0, @limit]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -6,9 +6,10 @@ require "redis/errors"
6
6
  require_relative "./errors"
7
7
 
8
8
  class Redis
9
- module Throttle
10
- # Simple helper to run script by it's sha1 digest with fallbak to script
11
- # load if it was not loaded yet.
9
+ class Throttle
10
+ # @api private
11
+ #
12
+ # Lazy-compile and run acquire script by it's sha1 digest.
12
13
  class Script
13
14
  # Redis error fired when script ID is unkown.
14
15
  NOSCRIPT = "NOSCRIPT"
@@ -16,29 +17,31 @@ class Redis
16
17
 
17
18
  LUA_ERROR_MESSAGE = %r{
18
19
  ERR\s
19
- (?<message>Error\s(?:compiling|running)\sscript\s\(.*?\)):\s
20
- (?:[^:]+:\d+:\s)+
20
+ (?<message>Error\s(?:compiling|running)\sscript)
21
+ \s\([^()]+\):\s
22
+ (?:@[^:]+:\d+:\s)?
23
+ [^:]+:(?<loc>\d+):\s
21
24
  (?<details>.+)
22
25
  }x.freeze
23
26
  private_constant :LUA_ERROR_MESSAGE
24
27
 
25
28
  def initialize(source)
26
29
  @source = -source.to_s
27
- @digest = Digest::SHA1.hexdigest(@source)
30
+ @digest = Digest::SHA1.hexdigest(@source).freeze
28
31
  end
29
32
 
30
33
  def call(redis, keys: [], argv: [])
31
- __call__(redis, :keys => keys, :argv => argv)
34
+ __eval__(redis, keys, argv)
32
35
  rescue Redis::CommandError => e
33
36
  md = LUA_ERROR_MESSAGE.match(e.message.to_s)
34
37
  raise unless md
35
38
 
36
- raise LuaError, [md[:message], md[:details]].compact.join(": ")
39
+ raise ScriptError, "#{md[:message]} @#{md[:loc]}: #{md[:details]}"
37
40
  end
38
41
 
39
42
  private
40
43
 
41
- def __call__(redis, keys:, argv:)
44
+ def __eval__(redis, keys, argv)
42
45
  redis.evalsha(@digest, keys, argv)
43
46
  rescue Redis::CommandError => e
44
47
  raise unless e.message.include?(NOSCRIPT)
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Redis
4
- module Throttle
4
+ class Throttle
5
5
  # Gem version.
6
- VERSION = "0.0.1"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-throttle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Zapparov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-08-18 00:00:00.000000000 Z
11
+ date: 2020-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -28,16 +28,16 @@ dependencies:
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '2.0'
33
+ version: '0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '2.0'
40
+ version: '0'
41
41
  description:
42
42
  email:
43
43
  - alexey@zapparov.com
@@ -45,25 +45,24 @@ executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
- - CHANGES.md
49
48
  - LICENSE.txt
50
- - README.md
51
49
  - lib/redis/throttle.rb
52
- - lib/redis/throttle/concurrency.lua
50
+ - lib/redis/throttle/api.lua
51
+ - lib/redis/throttle/api.rb
52
+ - lib/redis/throttle/class_methods.rb
53
53
  - lib/redis/throttle/concurrency.rb
54
54
  - lib/redis/throttle/errors.rb
55
+ - lib/redis/throttle/rate_limit.rb
55
56
  - lib/redis/throttle/script.rb
56
- - lib/redis/throttle/threshold.lua
57
- - lib/redis/throttle/threshold.rb
58
57
  - lib/redis/throttle/version.rb
59
58
  homepage: https://gitlab.com/ixti/redis-throttle
60
59
  licenses:
61
60
  - MIT
62
61
  metadata:
63
62
  homepage_uri: https://gitlab.com/ixti/redis-throttle
64
- source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v0.0.1
63
+ source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v1.0.0
65
64
  bug_tracker_uri: https://gitlab.com/ixti/redis-throttle/issues
66
- changelog_uri: https://gitlab.com/ixti/redis-throttle/blob/v0.0.1/CHANGES.md
65
+ changelog_uri: https://gitlab.com/ixti/redis-throttle/blob/v1.0.0/CHANGES.md
67
66
  post_install_message:
68
67
  rdoc_options: []
69
68
  require_paths:
@@ -72,7 +71,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
72
71
  requirements:
73
72
  - - "~>"
74
73
  - !ruby/object:Gem::Version
75
- version: '2.5'
74
+ version: '2.4'
76
75
  required_rubygems_version: !ruby/object:Gem::Requirement
77
76
  requirements:
78
77
  - - ">="
@@ -82,5 +81,5 @@ requirements: []
82
81
  rubygems_version: 3.1.2
83
82
  signing_key:
84
83
  specification_version: 4
85
- summary: Redis based threshold and concurrency throttling.
84
+ summary: Redis based rate limit and concurrency throttling.
86
85
  test_files: []
data/CHANGES.md DELETED
@@ -1,7 +0,0 @@
1
- # v0.0.1 (2020-08-17)
2
-
3
- * Initial release. Effectively this version represents extraction of concurrency
4
- and threshold strategies from [sidekiq-throttled][].
5
-
6
-
7
- [sidekiq-throttled]: https://github.com/sensortower/sidekiq-throttled
data/README.md DELETED
@@ -1,123 +0,0 @@
1
- # Redis::Throttle
2
-
3
- [Redis](https://redis.io/) based threshold and concurrency throttling.
4
-
5
-
6
- ## Installation
7
-
8
- Add this line to your application's Gemfile:
9
-
10
- ```ruby
11
- gem "redis-throttle"
12
- ```
13
-
14
- And then execute:
15
-
16
- $ bundle install
17
-
18
- Or install it yourself as:
19
-
20
- $ gem install redis-throttle
21
-
22
-
23
- ## Usage
24
-
25
- ### Limit concurrency
26
-
27
- ``` ruby
28
- # Allow 1 concurrent calls. If call takes more than 10 seconds, consider it
29
- # gone (as if process died, or by any other reason did not called `#release`):
30
- concurrency = Redis::Throttle::Concurrency.new(:bucket_name,
31
- :limit => 1,
32
- :ttl => 10
33
- )
34
-
35
- concurrency.acquire(Redis.current, :token => "abc") # => true
36
- concurrency.acquire(Redis.current, :token => "xyz") # => false
37
-
38
- concurrency.release(Redis.current, :token => "abc")
39
-
40
- concurrency.acquire(Redis.current, :token => "xyz") # => true
41
- ```
42
-
43
- ### Limit threshold
44
-
45
- ``` ruby
46
- # Allow 1 calls per 10 seconds:
47
- threshold = Redis::Throttle::Threshold.new(:bucket_name,
48
- :limit => 1,
49
- :period => 10
50
- )
51
-
52
- threshold.acquire(Redis.current) # => true
53
- threshold.acquire(Redis.current) # => false
54
-
55
- sleep 10
56
-
57
- threshold.acquire(Redis.current) # => true
58
- ```
59
-
60
-
61
- ## Supported Ruby Versions
62
-
63
- This library aims to support and is tested against:
64
-
65
- * Ruby
66
- * MRI 2.4.x
67
- * MRI 2.5.x
68
- * MRI 2.6.x
69
- * MRI 2.7.x
70
- * JRuby 9.2.x
71
-
72
- * [redis-rb](https://github.com/redis/redis-rb)
73
- * 4.0.x
74
- * 4.1.x
75
- * 4.2.x
76
-
77
- * [redis-namespace](https://github.com/resque/redis-namespace)
78
- * 1.6.x
79
- * 1.7.x
80
-
81
-
82
- If something doesn't work on one of these versions, it's a bug.
83
-
84
- This library may inadvertently work (or seem to work) on other Ruby versions,
85
- however support will only be provided for the versions listed above.
86
-
87
- If you would like this library to support another Ruby version or
88
- implementation, you may volunteer to be a maintainer. Being a maintainer
89
- entails making sure all tests run and pass on that implementation. When
90
- something breaks on your implementation, you will be responsible for providing
91
- patches in a timely fashion. If critical issues for a particular implementation
92
- exist at the time of a major release, support for that Ruby version may be
93
- dropped.
94
-
95
-
96
- ## Development
97
-
98
- After checking out the repo, run `bundle install` to install dependencies.
99
- Then, run `bundle exec rake spec` to run the tests with ruby-rb client.
100
-
101
- To install this gem onto your local machine, run `bundle exec rake install`.
102
- To release a new version, update the version number in `version.rb`, and then
103
- run `bundle exec rake release`, which will create a git tag for the version,
104
- push git commits and tags, and push the `.gem` file to [rubygems.org][].
105
-
106
-
107
- ## Contributing
108
-
109
- * Fork redis-throttle
110
- * Make your changes
111
- * Ensure all tests pass (`bundle exec rake`)
112
- * Send a merge request
113
- * If we like them we'll merge them
114
- * If we've accepted a patch, feel free to ask for commit access!
115
-
116
-
117
- ## Copyright
118
-
119
- Copyright (c) 2020 Alexey Zapparov<br>
120
- See [LICENSE.txt][] for further details.
121
-
122
-
123
- [LICENSE.txt]: https://gitlab.com/ixti/redis-throttle/blob/master/LICENSE.txt
@@ -1,13 +0,0 @@
1
- local bucket, token, limit, ttl, now =
2
- KEYS[1], ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
3
-
4
- redis.call("ZREMRANGEBYSCORE", bucket, "-inf", "(" .. now)
5
-
6
- if limit <= redis.call("ZCARD", bucket) and not redis.call("ZSCORE", bucket, token) then
7
- return 1
8
- end
9
-
10
- redis.call("ZADD", bucket, now + ttl, token)
11
- redis.call("EXPIRE", bucket, ttl)
12
-
13
- return 0
@@ -1,12 +0,0 @@
1
- local bucket, limit, period, now =
2
- KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
3
-
4
- if limit <= redis.call("LLEN", bucket) and now - redis.call("LINDEX", bucket, -1) < period then
5
- return 1
6
- end
7
-
8
- redis.call("LPUSH", bucket, now)
9
- redis.call("LTRIM", bucket, 0, limit - 1)
10
- redis.call("EXPIRE", bucket, period)
11
-
12
- return 0
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "./script"
4
-
5
- class Redis
6
- module Throttle
7
- class Threshold
8
- SCRIPT = Script.new(File.read("#{__dir__}/threshold.lua"))
9
- private_constant :SCRIPT
10
-
11
- NOOP = ->(_) { nil }
12
- private_constant :NOOP
13
-
14
- # @param bucket [#to_s]
15
- # @param limit [#to_i]
16
- # @param period [#to_i]
17
- def initialize(bucket, limit:, period:)
18
- @bucket = bucket.to_s
19
- @limit = limit.to_i
20
- @period = period.to_i
21
- end
22
-
23
- # @param redis [Redis, Redis::Namespace]
24
- # @return [Boolean]
25
- def acquire(redis)
26
- SCRIPT
27
- .call(redis, :keys => [@bucket], :argv => [@limit, @period, Time.now.to_i])
28
- .zero?
29
- end
30
-
31
- # @param redis [Redis, Redis::Namespace]
32
- # @return [void]
33
- def reset(redis)
34
- redis.del(@bucket)
35
- end
36
- end
37
- end
38
- end