redis-throttle 1.0.0 → 2.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.
@@ -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