redis-throttle 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/set"
4
+ require "securerandom"
5
+
6
+ require_relative "./redis_throttle/api"
7
+ require_relative "./redis_throttle/concurrency"
8
+ require_relative "./redis_throttle/rate_limit"
9
+ require_relative "./redis_throttle/version"
10
+
11
+ class RedisThrottle
12
+ class << self
13
+ # Syntax sugar for `RedisThrottle.new.concurrency(...)`.
14
+ #
15
+ # @see #concurrency
16
+ # @param (see #concurrency)
17
+ # @return (see #concurrency)
18
+ def concurrency(...)
19
+ new.concurrency(...)
20
+ end
21
+
22
+ # Syntax sugar for `RedisThrottle.new.rate_limit(...)`.
23
+ #
24
+ # @see #rate_limit
25
+ # @param (see #rate_limit)
26
+ # @return (see #rate_limit)
27
+ def rate_limit(...)
28
+ new.rate_limit(...)
29
+ end
30
+
31
+ # Return usage info for all known (in use) strategies.
32
+ #
33
+ # @example
34
+ # RedisThrottle.info(:match => "*_api").each do |strategy, current_value|
35
+ # # ...
36
+ # end
37
+ #
38
+ # @param redis (see Api#initialize)
39
+ # @param match [#to_s]
40
+ # @return (see Api#info)
41
+ def info(redis, match: "*")
42
+ Api.new(redis).then { |api| api.info(strategies: api.strategies(match: match.to_s)) }
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @strategies = Concurrent::Set.new
48
+ end
49
+
50
+ # @api private
51
+ #
52
+ # Dup internal strategies plan.
53
+ #
54
+ # @return [void]
55
+ def initialize_dup(original)
56
+ super
57
+
58
+ @strategies = original.strategies.dup
59
+ end
60
+
61
+ # @api private
62
+ #
63
+ # Clone internal strategies plan.
64
+ #
65
+ # @return [void]
66
+ def initialize_clone(original)
67
+ super
68
+
69
+ @strategies = original.strategies.clone
70
+ end
71
+
72
+ # Add *concurrency* strategy to the throttle. Use it to guarantee `limit`
73
+ # amount of concurrently running code blocks.
74
+ #
75
+ # @example
76
+ # throttle = RedisThrottle.new
77
+ #
78
+ # # Allow max 2 concurrent execution units
79
+ # throttle.concurrency(:xxx, limit: 2, ttl: 10)
80
+ #
81
+ # throttle.acquire(redis, token: "a") && :aye || :nay # => :aye
82
+ # throttle.acquire(redis, token: "b") && :aye || :nay # => :aye
83
+ # throttle.acquire(redis, token: "c") && :aye || :nay # => :nay
84
+ #
85
+ # throttle.release(redis, token: "a")
86
+ #
87
+ # throttle.acquire(redis, token: "c") && :aye || :nay # => :aye
88
+ #
89
+ #
90
+ # @param (see Concurrency#initialize)
91
+ # @return [Throttle] self
92
+ def concurrency(bucket, limit:, ttl:)
93
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
94
+
95
+ @strategies << Concurrency.new(bucket, limit: limit, ttl: ttl)
96
+
97
+ self
98
+ end
99
+
100
+ # Add *rate limit* strategy to the throttle. Use it to guarantee `limit`
101
+ # amount of units in `period` of time.
102
+ #
103
+ # @example
104
+ # throttle = RedisThrottle.new
105
+ #
106
+ # # Allow 2 execution units per 10 seconds
107
+ # throttle.rate_limit(:xxx, limit: 2, period: 10)
108
+ #
109
+ # throttle.acquire(redis) && :aye || :nay # => :aye
110
+ # sleep 5
111
+ #
112
+ # throttle.acquire(redis) && :aye || :nay # => :aye
113
+ # throttle.acquire(redis) && :aye || :nay # => :nay
114
+ #
115
+ # sleep 6
116
+ # throttle.acquire(redis) && :aye || :nay # => :aye
117
+ # throttle.acquire(redis) && :aye || :nay # => :nay
118
+ #
119
+ # @param (see RateLimit#initialize)
120
+ # @return [Throttle] self
121
+ def rate_limit(bucket, limit:, period:)
122
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
123
+
124
+ @strategies << RateLimit.new(bucket, limit: limit, period: period)
125
+
126
+ self
127
+ end
128
+
129
+ # Merge in strategies of the `other` throttle.
130
+ #
131
+ # @example
132
+ # a = RedisThrottle.concurrency(:a, limit: 1, ttl: 2)
133
+ # b = RedisThrottle.rate_limit(:b, limit: 3, period: 4)
134
+ # c = RedisThrottle
135
+ # .concurrency(:a, limit: 1, ttl: 2)
136
+ # .rate_limit(:b, limit: 3, period: 4)
137
+ #
138
+ # a.merge!(b)
139
+ #
140
+ # a == c # => true
141
+ #
142
+ # @return [Throttle] self
143
+ def merge!(other)
144
+ raise FrozenError, "can't modify frozen #{self.class}" if frozen?
145
+
146
+ @strategies.merge(other.strategies)
147
+
148
+ self
149
+ end
150
+
151
+ # Non-destructive version of {#merge!}. Returns new {RedisThrottle} instance
152
+ # with union of `self` and `other` strategies.
153
+ #
154
+ # @example
155
+ # a = RedisThrottle.concurrency(:a, limit: 1, ttl: 2)
156
+ # b = RedisThrottle.rate_limit(:b, limit: 3, period: 4)
157
+ # c = RedisThrottle
158
+ # .concurrency(:a, limit: 1, ttl: 2)
159
+ # .rate_limit(:b, limit: 3, period: 4)
160
+ #
161
+ # a.merge(b) == c # => true
162
+ # a == c # => false
163
+ #
164
+ # @return [Throttle] new throttle
165
+ def merge(other)
166
+ dup.merge!(other)
167
+ end
168
+
169
+ alias + merge
170
+
171
+ # Prevents further modifications to the throttle instance.
172
+ #
173
+ # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-freeze
174
+ # @return [Throttle] self
175
+ def freeze
176
+ @strategies.freeze
177
+
178
+ super
179
+ end
180
+
181
+ # Returns `true` if the `other` is an instance of {RedisThrottle} with
182
+ # the same set of strategies.
183
+ #
184
+ # @example
185
+ # a = RedisThrottle
186
+ # .concurrency(:a, limit: 1, ttl: 2)
187
+ # .rate_limit(:b, limit: 3, period: 4)
188
+ #
189
+ # b = RedisThrottle
190
+ # .rate_limit(:b, limit: 3, period: 4)
191
+ # .concurrency(:a, limit: 1, ttl: 2)
192
+ #
193
+ # a == b # => true
194
+ #
195
+ # @return [Boolean]
196
+ def ==(other)
197
+ other.is_a?(self.class) && @strategies == other.strategies
198
+ end
199
+
200
+ alias eql? ==
201
+
202
+ # Calls given block execution lock was acquired, and ensures to {#release}
203
+ # it after the block.
204
+ #
205
+ # @example
206
+ # throttle = RedisThrottle.concurrency(:xxx, limit: 1, ttl: 10)
207
+ #
208
+ # throttle.call(redis) { :aye } # => :aye
209
+ # throttle.call(redis) { :aye } # => :aye
210
+ #
211
+ # throttle.acquire(redis)
212
+ #
213
+ # throttle.call(redis) { :aye } # => nil
214
+ #
215
+ # @param redis (see Api#initialize)
216
+ # @param token (see #acquire)
217
+ # @return [Object] last satement of the block if execution lock was acquired.
218
+ # @return [nil] otherwise
219
+ def call(redis, token: SecureRandom.uuid)
220
+ return unless acquire(redis, token: token)
221
+
222
+ begin
223
+ yield
224
+ ensure
225
+ release(redis, token: token)
226
+ end
227
+ end
228
+
229
+ # Acquire execution lock.
230
+ #
231
+ # @example
232
+ # throttle = RedisThrottle.concurrency(:xxx, limit: 1, ttl: 10)
233
+ #
234
+ # if (token = throttle.acquire(redis))
235
+ # # ... do something
236
+ # end
237
+ #
238
+ # throttle.release(redis, token: token) if token
239
+ #
240
+ # @see #call
241
+ # @see #release
242
+ # @param redis (see Api#initialize)
243
+ # @param token [#to_s] Unit of work ID
244
+ # @return [#to_s] `token` as is if lock was acquired
245
+ # @return [nil] otherwise
246
+ def acquire(redis, token: SecureRandom.uuid)
247
+ token if Api.new(redis).acquire(strategies: @strategies, token: token.to_s)
248
+ end
249
+
250
+ # Release acquired execution lock. Notice that this affects {#concurrency}
251
+ # locks only.
252
+ #
253
+ # @example
254
+ # concurrency = RedisThrottle.concurrency(:xxx, limit: 1, ttl: 60)
255
+ # rate_limit = RedisThrottle.rate_limit(:xxx, limit: 1, period: 60)
256
+ # throttle = concurrency + rate_limit
257
+ #
258
+ # throttle.acquire(redis, token: "uno")
259
+ # throttle.release(redis, token: "uno")
260
+ #
261
+ # concurrency.acquire(redis, token: "dos") # => "dos"
262
+ # rate_limit.acquire(redis, token: "dos") # => nil
263
+ #
264
+ # @see #acquire
265
+ # @see #reset
266
+ # @see #call
267
+ # @param redis (see Api#initialize)
268
+ # @param token [#to_s] Unit of work ID
269
+ # @return [void]
270
+ def release(redis, token:)
271
+ Api.new(redis).release(strategies: @strategies, token: token.to_s)
272
+
273
+ nil
274
+ end
275
+
276
+ # Flush all counters.
277
+ #
278
+ # @example
279
+ # throttle = RedisThrottle.concurrency(:xxx, limit: 2, ttl: 60)
280
+ #
281
+ # thottle.acquire(redis, token: "a") # => "a"
282
+ # thottle.acquire(redis, token: "b") # => "b"
283
+ # thottle.acquire(redis, token: "c") # => nil
284
+ #
285
+ # throttle.reset(redis)
286
+ #
287
+ # thottle.acquire(redis, token: "c") # => "c"
288
+ # thottle.acquire(redis, token: "d") # => "d"
289
+ #
290
+ # @param redis (see Api#initialize)
291
+ # @return [void]
292
+ def reset(redis)
293
+ Api.new(redis).reset(strategies: @strategies)
294
+
295
+ nil
296
+ end
297
+
298
+ # Return usage info for all strategies of the throttle.
299
+ #
300
+ # @example
301
+ # throttle.info(redis).each do |strategy, current_value|
302
+ # # ...
303
+ # end
304
+ #
305
+ # @param redis (see Api#initialize)
306
+ # @return (see Api#info)
307
+ def info(redis)
308
+ Api.new(redis).info(strategies: @strategies)
309
+ end
310
+
311
+ protected
312
+
313
+ attr_accessor :strategies
314
+ end
metadata CHANGED
@@ -1,44 +1,44 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-throttle
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexey Zapparov
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-09-14 00:00:00.000000000 Z
11
+ date: 2023-04-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: redis
14
+ name: concurrent-ruby
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '4.0'
19
+ version: 1.2.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '4.0'
26
+ version: 1.2.0
27
27
  - !ruby/object:Gem::Dependency
28
- name: bundler
28
+ name: redis-prescription
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ">="
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
33
+ version: '2.5'
34
+ type: :runtime
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: '0'
41
- description:
40
+ version: '2.5'
41
+ description:
42
42
  email:
43
43
  - alexey@zapparov.com
44
44
  executables: []
@@ -46,40 +46,40 @@ extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
48
  - LICENSE.txt
49
- - lib/redis/throttle.rb
50
- - lib/redis/throttle/api.lua
51
- - lib/redis/throttle/api.rb
52
- - lib/redis/throttle/class_methods.rb
53
- - lib/redis/throttle/concurrency.rb
54
- - lib/redis/throttle/errors.rb
55
- - lib/redis/throttle/rate_limit.rb
56
- - lib/redis/throttle/script.rb
57
- - lib/redis/throttle/version.rb
49
+ - README.adoc
50
+ - lib/redis-throttle.rb
51
+ - lib/redis_throttle.rb
52
+ - lib/redis_throttle/api.lua
53
+ - lib/redis_throttle/api.rb
54
+ - lib/redis_throttle/concurrency.rb
55
+ - lib/redis_throttle/rate_limit.rb
56
+ - lib/redis_throttle/version.rb
58
57
  homepage: https://gitlab.com/ixti/redis-throttle
59
58
  licenses:
60
59
  - MIT
61
60
  metadata:
62
61
  homepage_uri: https://gitlab.com/ixti/redis-throttle
63
- source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v1.0.0
62
+ source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v2.0.0
64
63
  bug_tracker_uri: https://gitlab.com/ixti/redis-throttle/issues
65
- changelog_uri: https://gitlab.com/ixti/redis-throttle/blob/v1.0.0/CHANGES.md
66
- post_install_message:
64
+ changelog_uri: https://gitlab.com/ixti/redis-throttle/blob/v2.0.0/CHANGES.md
65
+ rubygems_mfa_required: 'true'
66
+ post_install_message:
67
67
  rdoc_options: []
68
68
  require_paths:
69
69
  - lib
70
70
  required_ruby_version: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - "~>"
72
+ - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: '2.4'
74
+ version: '2.7'
75
75
  required_rubygems_version: !ruby/object:Gem::Requirement
76
76
  requirements:
77
77
  - - ">="
78
78
  - !ruby/object:Gem::Version
79
79
  version: '0'
80
80
  requirements: []
81
- rubygems_version: 3.1.2
82
- signing_key:
81
+ rubygems_version: 3.2.33
82
+ signing_key:
83
83
  specification_version: 4
84
- summary: Redis based rate limit and concurrency throttling.
84
+ summary: Redis based rate limit and concurrency throttling
85
85
  test_files: []
@@ -1,120 +0,0 @@
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
@@ -1,45 +0,0 @@
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,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Redis
4
- class Throttle
5
- class Concurrency
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
13
-
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)
23
- def initialize(bucket, limit:, ttl:)
24
- @bucket = -bucket.to_s
25
- @limit = limit.to_i
26
- @ttl = ttl.to_i
27
- end
28
-
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]
34
- # @return [Boolean]
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
57
- end
58
-
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
67
- end
68
-
69
- # @api private
70
- #
71
- # @return [Array(Integer, Integer)] Strategy complexity pseudo-score
72
- def complexity
73
- [1, @limit]
74
- end
75
- end
76
- end
77
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Redis
4
- class Throttle
5
- class Error < StandardError; end
6
- class ScriptError < Error; end
7
-
8
- class FrozenError < RuntimeError; end if RUBY_VERSION < "2.5"
9
- end
10
- end