redis-throttle 0.0.1 → 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,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,86 +1,87 @@
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.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-08-18 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: '2.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: '2.0'
41
- description:
40
+ version: 2.2.0
41
+ description:
42
42
  email:
43
43
  - alexey@zapparov.com
44
44
  executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
- - CHANGES.md
49
48
  - LICENSE.txt
50
- - README.md
49
+ - README.adoc
50
+ - lib/redis-throttle.rb
51
51
  - lib/redis/throttle.rb
52
- - lib/redis/throttle/concurrency.lua
53
- - lib/redis/throttle/concurrency.rb
54
- - lib/redis/throttle/errors.rb
55
- - lib/redis/throttle/script.rb
56
- - lib/redis/throttle/threshold.lua
57
- - lib/redis/throttle/threshold.rb
58
- - 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
59
59
  homepage: https://gitlab.com/ixti/redis-throttle
60
60
  licenses:
61
61
  - MIT
62
62
  metadata:
63
63
  homepage_uri: https://gitlab.com/ixti/redis-throttle
64
- source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v0.0.1
64
+ source_code_uri: https://gitlab.com/ixti/redis-throttle/tree/v1.1.0
65
65
  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
67
- 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:
68
69
  rdoc_options: []
69
70
  require_paths:
70
71
  - lib
71
72
  required_ruby_version: !ruby/object:Gem::Requirement
72
73
  requirements:
73
- - - "~>"
74
+ - - ">="
74
75
  - !ruby/object:Gem::Version
75
- version: '2.5'
76
+ version: '2.7'
76
77
  required_rubygems_version: !ruby/object:Gem::Requirement
77
78
  requirements:
78
79
  - - ">="
79
80
  - !ruby/object:Gem::Version
80
81
  version: '0'
81
82
  requirements: []
82
- rubygems_version: 3.1.2
83
- signing_key:
83
+ rubygems_version: 3.3.26
84
+ signing_key:
84
85
  specification_version: 4
85
- summary: Redis based threshold and concurrency throttling.
86
+ summary: Redis based rate limit and concurrency throttling
86
87
  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,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "./script"
4
-
5
- class Redis
6
- module Throttle
7
- class Concurrency
8
- SCRIPT = Script.new(File.read("#{__dir__}/concurrency.lua"))
9
- private_constant :SCRIPT
10
-
11
- # @param bucket [#to_s]
12
- # @param limit [#to_i]
13
- # @param ttl [#to_i]
14
- def initialize(bucket, limit:, ttl:)
15
- @bucket = bucket.to_s
16
- @limit = limit.to_i
17
- @ttl = ttl.to_i
18
- end
19
-
20
- # @param redis [Redis, Redis::Namespace]
21
- # @param token [#to_s]
22
- # @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?
27
- end
28
-
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)
34
- end
35
-
36
- # @param redis [Redis, Redis::Namespace]
37
- # @return [void]
38
- def reset(redis)
39
- redis.del(@bucket)
40
- end
41
- end
42
- end
43
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Redis
4
- module Throttle
5
- class Error < StandardError; end
6
- class LuaError < Error; end
7
- end
8
- end