redis-throttle 0.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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