redis-throttle 0.0.1 → 1.0.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: 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