redis-throttle 1.0.0 → 1.1.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,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