redis-throttle 1.0.0 → 1.1.0

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