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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94cbed31445714a0f2883bfbe8e317dff6813db80d93c73b7a16a32dfb45524a
4
- data.tar.gz: 5aa8ecb1c5d5301f314f844bbdbdd720d26d881b6820966cf42682302bd00178
3
+ metadata.gz: df74619037a1515419ebbf8381326577c540376855146fece1b9624f9ee443f6
4
+ data.tar.gz: 4b465e4412d3fa8408b48470b867576457737524cded5b41ea8bb04aef53a39b
5
5
  SHA512:
6
- metadata.gz: 89d197efa9dadad39539510da10ec183c2df3f00c1b12063591827ab7ffd02b368ab92a9643a20d83dc40751bbfa2cdf33ae42f2d41717d314d40f496628af53
7
- data.tar.gz: d07140bc01e01b051dcabd266b0a2d5410ce9289d74b5550c44da903c8d3b0f5e574a8cef3ae5b777b26fb1d5557e2fb294e9b0a7394b75e77c0c31e047def75
6
+ metadata.gz: cfcdaef05593dd858baef2d65ae0a1240cea10cbb3adda0989f5bba724672233c4ec8551040b746421a8d40eb5240a0338de5f027bafa3eabe6a3c6a28071c0d
7
+ data.tar.gz: e2a761f5962106a211ed285f527a73e24d040c414053a707c26fa11dad1b2477f73ecd38248b4ba639c32c47a773719af788dd02918af8259af1c283f100e979
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2020 Alexey Zapparov
3
+ Copyright (c) 2020-2021 Alexey Zapparov
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.adoc ADDED
@@ -0,0 +1,178 @@
1
+ = RedisThrottle
2
+
3
+ Redis based rate limit and concurrency throttling.
4
+
5
+
6
+ == Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ $ bundle add redis-throttle
11
+
12
+ Or install it yourself as:
13
+
14
+ $ gem install redis-throttle
15
+
16
+
17
+ == Usage
18
+
19
+ === Concurrency Limit
20
+
21
+ [source,ruby]
22
+ ----
23
+ # Allow 1 concurrent calls. If call takes more than 10 seconds, consider it
24
+ # gone (as if process died, or by any other reason did not called `#release`):
25
+ concurrency = RedisThrottle.concurrency(:bucket_name,
26
+ :limit => 1,
27
+ :ttl => 10
28
+ )
29
+
30
+ concurrency.acquire(:token => "abc") # => "abc"
31
+ concurrency.acquire(:token => "xyz") # => nil
32
+
33
+ concurrency.release(:token => "abc")
34
+
35
+ concurrency.acquire(:token => "xyz") # => "xyz"
36
+ ----
37
+
38
+ === Rate Limit
39
+
40
+ [source,ruby]
41
+ ----
42
+ # Allow 1 calls per 10 seconds:
43
+ rate_limit = RedisThrottle.rate_limit(:bucket_name,
44
+ :limit => 1,
45
+ :period => 10
46
+ )
47
+
48
+ rate_limit.acquire # => "6a6c6546-268d-4216-bcf3-3139b8e11609"
49
+ rate_limit.acquire # => nil
50
+
51
+ sleep 10
52
+
53
+ rate_limit.acquire # => "e2926a90-2cf4-4bff-9401-65f3a70d32bd"
54
+ ----
55
+
56
+
57
+ === Multi-strategy
58
+
59
+ [source,ruby]
60
+ ----
61
+ throttle = RedisThrottle
62
+ .concurrency(:db, :limit => 3, :ttl => 900)
63
+ .rate_limit(:api_minutely, :limit => 1, :period => 60)
64
+ .rate_limit(:api_hourly, :limit => 10, :period => 3600)
65
+
66
+ throttle.call(:token => "abc") do
67
+ # do something if all strategies are resolved
68
+ end
69
+ ----
70
+
71
+ You can also compose multiple throttlers together:
72
+
73
+ [source,ruby]
74
+ ----
75
+ db_limiter = RedisThrottle.concurrency(:db, :limit => 3, :ttl => 900)
76
+ api_limiter = RedisThrottle
77
+ .rate_limit(:api_minutely, :limit => 1, :period => 60)
78
+ .rate_limit(:api_hourly, :limit => 10, :period => 3600)
79
+
80
+ (db_limiter + api_limiter).call do
81
+ # ...
82
+ end
83
+ ----
84
+
85
+
86
+ === With ConnectionPool
87
+
88
+ If you're using [connection_pool](https://github.com/mperham/connection_pool),
89
+ you can pass its `#with` method as connection builder:
90
+
91
+ [source,ruby]
92
+ ----
93
+ pool = ConnectionPool.new { Redis.new }
94
+ throttle = RedisThrottle.new(:redis => pool.method(:with))
95
+ ----
96
+
97
+ === With Sidekiq
98
+
99
+ [Sidekiq](https://github.com/mperham/sidekiq): uses ConnectionPool, so you can
100
+ use the same approach:
101
+
102
+ [source,ruby]
103
+ ----
104
+ throttle = RedisThrottle.new(:redis => Sidekiq.redis_pool.method(:with))
105
+ ----
106
+
107
+ Or, you can use its `.redis` method directly:
108
+
109
+ [source,ruby]
110
+ ----
111
+ throttle = RedisThrottle.new(:redis => Sidekiq.method(:redis))
112
+ ----
113
+
114
+
115
+ == Compatibility
116
+
117
+ This library aims to support and is tested against:
118
+
119
+ * https://www.ruby-lang.org[Ruby]
120
+ ** MRI 2.7.x
121
+ ** MRI 3.0.x
122
+ ** MRI 3.1.x
123
+ ** MRI 3.2.x
124
+ * https://redis.io[Redis Server]
125
+ ** 6.0.x
126
+ ** 6.2.x
127
+ ** 7.0.x
128
+ * https://github.com/redis/redis-rb[redis-rb]
129
+ ** 4.1.x
130
+ ** 4.2.x
131
+ ** 4.3.x
132
+ ** 4.4.x
133
+ ** 4.5.x
134
+ ** 4.6.x
135
+ * https://github.com/resque/redis-namespace[redis-namespace]
136
+ ** 1.10.x
137
+
138
+ If something doesn't work on one of these versions, it's a bug.
139
+
140
+ This library may inadvertently work (or seem to work) on other Ruby versions,
141
+ however support will only be provided for the versions listed above.
142
+
143
+ If you would like this library to support another Ruby version or
144
+ implementation, you may volunteer to be a maintainer. Being a maintainer
145
+ entails making sure all tests run and pass on that implementation. When
146
+ something breaks on your implementation, you will be responsible for providing
147
+ patches in a timely fashion. If critical issues for a particular implementation
148
+ exist at the time of a major release, support for that Ruby version may be
149
+ dropped.
150
+
151
+ The same applies to *Redis Server*, *redis-rb*, and *redis-namespace* support.
152
+
153
+
154
+ == Development
155
+
156
+ scripts/update-gemfiles
157
+ scripts/run-rspec
158
+ bundle exec rubocop
159
+
160
+
161
+ == Contributing
162
+
163
+ * Fork redis-throttle
164
+ * Make your changes
165
+ * Ensure all tests pass (`bundle exec rake`)
166
+ * Send a merge request
167
+ * If we like them we'll merge them
168
+ * If we've accepted a patch, feel free to ask for commit access!
169
+
170
+
171
+ == Appreciations
172
+
173
+ Thanks to all how providede suggestions and criticism, especially to those who
174
+ helped me shape some of the initial ideas:
175
+
176
+ * https://gitlab.com/freemanoid[@freemanoid]
177
+ * https://gitlab.com/petethepig[@petethepig]
178
+ * https://gitlab.com/dervus[@dervus]
@@ -1,288 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "redis"
4
- require "set"
5
- require "securerandom"
3
+ require_relative "../redis_throttle"
6
4
 
7
- require_relative "./throttle/api"
8
- require_relative "./throttle/errors"
9
- require_relative "./throttle/class_methods"
10
- require_relative "./throttle/concurrency"
11
- require_relative "./throttle/rate_limit"
12
- require_relative "./throttle/version"
13
-
14
- # @see https://github.com/redis/redis-rb
15
5
  class Redis
16
- # Distributed rate limit and concurrency throttling.
17
- class Throttle
18
- extend ClassMethods
19
-
20
- # @param (see Api#initialize)
21
- def initialize(redis: nil)
22
- @api = Api.new(:redis => redis)
23
- @strategies = SortedSet.new
24
- end
25
-
26
- # @api private
27
- #
28
- # Dup internal strategies plan.
29
- #
30
- # @return [void]
31
- def initialize_dup(original)
32
- super
33
-
34
- @strategies = original.strategies.dup
35
- end
36
-
37
- # @api private
38
- #
39
- # Clone internal strategies plan.
40
- #
41
- # @return [void]
42
- def initialize_clone(original)
43
- super
44
-
45
- @strategies = original.strategies.clone
46
- end
47
-
48
- # Add *concurrency* strategy to the throttle. Use it to guarantee `limit`
49
- # amount of concurrently running code blocks.
50
- #
51
- # @example
52
- # throttle = Redis::Throttle.new
53
- #
54
- # # Allow max 2 concurrent execution units
55
- # throttle.concurrency(:xxx, :limit => 2, :ttl => 10)
56
- #
57
- # throttle.acquire(:token => "a") && :aye || :nay # => :aye
58
- # throttle.acquire(:token => "b") && :aye || :nay # => :aye
59
- # throttle.acquire(:token => "c") && :aye || :nay # => :nay
60
- #
61
- # throttle.release(:token => "a")
62
- #
63
- # throttle.acquire(:token => "c") && :aye || :nay # => :aye
64
- #
65
- #
66
- # @param (see Concurrency#initialize)
67
- # @return [Throttle] self
68
- def concurrency(bucket, limit:, ttl:)
69
- raise FrozenError, "can't modify frozen #{self.class}" if frozen?
70
-
71
- @strategies << Concurrency.new(bucket, :limit => limit, :ttl => ttl)
72
-
73
- self
74
- end
75
-
76
- # Add *rate limit* strategy to the throttle. Use it to guarantee `limit`
77
- # amount of units in `period` of time.
78
- #
79
- # @example
80
- # throttle = Redis::Throttle.new
81
- #
82
- # # Allow 2 execution units per 10 seconds
83
- # throttle.rate_limit(:xxx, :limit => 2, :period => 10)
84
- #
85
- # throttle.acquire && :aye || :nay # => :aye
86
- # sleep 5
87
- #
88
- # throttle.acquire && :aye || :nay # => :aye
89
- # throttle.acquire && :aye || :nay # => :nay
90
- #
91
- # sleep 6
92
- # throttle.acquire && :aye || :nay # => :aye
93
- # throttle.acquire && :aye || :nay # => :nay
94
- #
95
- # @param (see RateLimit#initialize)
96
- # @return [Throttle] self
97
- def rate_limit(bucket, limit:, period:)
98
- raise FrozenError, "can't modify frozen #{self.class}" if frozen?
99
-
100
- @strategies << RateLimit.new(bucket, :limit => limit, :period => period)
101
-
102
- self
103
- end
104
-
105
- # Merge in strategies of the `other` throttle.
106
- #
107
- # @example
108
- # a = Redis::Throttle.concurrency(:a, :limit => 1, :ttl => 2)
109
- # b = Redis::Throttle.rate_limit(:b, :limit => 3, :period => 4)
110
- # c = Redis::Throttle
111
- # .concurrency(:a, :limit => 1, :ttl => 2)
112
- # .rate_limit(:b, :limit => 3, :period => 4)
113
- #
114
- # a.merge!(b)
115
- #
116
- # a == c # => true
117
- #
118
- # @return [Throttle] self
119
- def merge!(other)
120
- raise FrozenError, "can't modify frozen #{self.class}" if frozen?
121
-
122
- @strategies.merge(other.strategies)
123
-
124
- self
6
+ # @deprecated Use ::RedisThrottle
7
+ class Throttle < RedisThrottle
8
+ class << self
9
+ attr_accessor :silence_deprecation_warning
125
10
  end
126
11
 
127
- alias << merge!
12
+ self.silence_deprecation_warning = false
128
13
 
129
- # Non-destructive version of {#merge!}. Returns new {Throttle} instance with
130
- # union of `self` and `other` strategies.
131
- #
132
- # @example
133
- # a = Redis::Throttle.concurrency(:a, :limit => 1, :ttl => 2)
134
- # b = Redis::Throttle.rate_limit(:b, :limit => 3, :period => 4)
135
- # c = Redis::Throttle
136
- # .concurrency(:a, :limit => 1, :ttl => 2)
137
- # .rate_limit(:b, :limit => 3, :period => 4)
138
- #
139
- # a.merge(b) == c # => true
140
- # a == c # => false
141
- #
142
- # @return [Throttle] new throttle
143
- def merge(other)
144
- dup.merge!(other)
145
- end
146
-
147
- alias | merge
148
-
149
- # Prevents further modifications to the throttle instance.
150
- #
151
- # @see https://docs.ruby-lang.org/en/master/Object.html#method-i-freeze
152
- # @return [Throttle] self
153
- def freeze
154
- @strategies.freeze
155
-
156
- super
157
- end
158
-
159
- # Returns `true` if the `other` is an instance of {Throttle} with the same
160
- # set of strategies.
161
- #
162
- # @example
163
- # a = Redis::Throttle
164
- # .concurrency(:a, :limit => 1, :ttl => 2)
165
- # .rate_limit(:b, :limit => 3, :period => 4)
166
- #
167
- # b = Redis::Throttle
168
- # .rate_limit(:b, :limit => 3, :period => 4)
169
- # .concurrency(:a, :limit => 1, :ttl => 2)
170
- #
171
- # a == b # => true
172
- #
173
- # @return [Boolean]
174
- def ==(other)
175
- other.is_a?(self.class) && @strategies == other.strategies
176
- end
177
-
178
- alias eql? ==
179
-
180
- # Calls given block execution lock was acquired, and ensures to {#release}
181
- # it after the block.
182
- #
183
- # @example
184
- # throttle = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 10)
185
- #
186
- # throttle.call { :aye } # => :aye
187
- # throttle.call { :aye } # => :aye
188
- #
189
- # throttle.acquire
190
- #
191
- # throttle.call { :aye } # => nil
192
- #
193
- # @param (see #acquire)
194
- # @return [Object] last satement of the block if execution lock was acquired.
195
- # @return [nil] otherwise
196
- def call(token: SecureRandom.uuid)
197
- return unless acquire(:token => token)
198
-
199
- begin
200
- yield
201
- ensure
202
- release(:token => token)
203
- end
204
- end
205
-
206
- # Acquire execution lock.
207
- #
208
- # @example
209
- # throttle = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 10)
210
- #
211
- # if (token = throttle.acquire)
212
- # # ... do something
213
- # end
214
- #
215
- # throttle.release(:token => token) if token
216
- #
217
- # @see #call
218
- # @see #release
219
- # @param token [#to_s] Unit of work ID
220
- # @return [#to_s] `token` as is if lock was acquired
221
- # @return [nil] otherwise
222
- def acquire(token: SecureRandom.uuid)
223
- token if @api.acquire(:strategies => @strategies, :token => token.to_s)
224
- end
225
-
226
- # Release acquired execution lock. Notice that this affects {#concurrency}
227
- # locks only.
228
- #
229
- # @example
230
- # concurrency = Redis::Throttle.concurrency(:xxx, :limit => 1, :ttl => 60)
231
- # rate_limit = Redis::Throttle.rate_limit(:xxx, :limit => 1, :period => 60)
232
- # throttle = concurrency | rate_limit
233
- #
234
- # throttle.acquire(:token => "uno")
235
- # throttle.release(:token => "uno")
236
- #
237
- # concurrency.acquire(:token => "dos") # => "dos"
238
- # rate_limit.acquire(:token => "dos") # => nil
239
- #
240
- # @see #acquire
241
- # @see #reset
242
- # @see #call
243
- # @param token [#to_s] Unit of work ID
244
- # @return [void]
245
- def release(token:)
246
- @api.release(:strategies => @strategies, :token => token.to_s)
247
-
248
- nil
249
- end
250
-
251
- # Flush all counters.
252
- #
253
- # @example
254
- # throttle = Redis::Throttle.concurrency(:xxx, :limit => 2, :ttl => 60)
255
- #
256
- # thottle.acquire(:token => "a") # => "a"
257
- # thottle.acquire(:token => "b") # => "b"
258
- # thottle.acquire(:token => "c") # => nil
259
- #
260
- # throttle.reset
261
- #
262
- # thottle.acquire(:token => "c") # => "c"
263
- # thottle.acquire(:token => "d") # => "d"
264
- #
265
- # @return [void]
266
- def reset
267
- @api.reset(:strategies => @strategies)
14
+ def initialize(*args, **kwargs, &block)
15
+ super(*args, **kwargs, &block)
268
16
 
269
- nil
270
- end
17
+ return if self.class.silence_deprecation_warning
271
18
 
272
- # Return usage info for all strategies of the throttle.
273
- #
274
- # @example
275
- # throttle.info.each do |strategy, current_value|
276
- # # ...
277
- # end
278
- #
279
- # @return (see Api#info)
280
- def info
281
- @api.info(:strategies => @strategies)
19
+ warn "#{self.class} usage was deprecated, please use RedisThrottle instead"
282
20
  end
283
-
284
- protected
285
-
286
- attr_accessor :strategies
287
21
  end
288
22
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./redis_throttle"
4
+ require_relative "./redis/throttle"
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "redis-prescription"
5
+
6
+ require_relative "./concurrency"
7
+ require_relative "./rate_limit"
8
+
9
+ class RedisThrottle
10
+ # @api private
11
+ class Api
12
+ NAMESPACE = "throttle"
13
+ private_constant :NAMESPACE
14
+
15
+ KEYS_PATTERN = %r{
16
+ \A
17
+ #{NAMESPACE}:
18
+ (?<strategy>concurrency|rate_limit):
19
+ (?<bucket>.+):
20
+ (?<limit>\d+):
21
+ (?<ttl_or_period>\d+)
22
+ \z
23
+ }x.freeze
24
+ private_constant :KEYS_PATTERN
25
+
26
+ SCRIPT = RedisPrescription.new(File.read("#{__dir__}/api.lua"))
27
+ private_constant :SCRIPT
28
+
29
+ # @param redis [Redis, Redis::Namespace, #to_proc]
30
+ def initialize(redis: nil)
31
+ @redis =
32
+ if redis.respond_to?(:to_proc)
33
+ redis.to_proc
34
+ else
35
+ ->(&b) { b.call(redis || Redis.current) }
36
+ end
37
+ end
38
+
39
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
40
+ # @param token [String]
41
+ # @return [Boolean]
42
+ def acquire(strategies:, token:)
43
+ execute(:ACQUIRE, to_params(strategies.sort_by(&:itself)) << :TOKEN << token << :TS << Time.now.to_i).zero?
44
+ end
45
+
46
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
47
+ # @param token [String]
48
+ # @return [void]
49
+ def release(strategies:, token:)
50
+ execute(:RELEASE, to_params(strategies.grep(Concurrency)) << :TOKEN << token)
51
+ end
52
+
53
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
54
+ # @return [void]
55
+ def reset(strategies:)
56
+ execute(:RESET, to_params(strategies))
57
+ end
58
+
59
+ # @param match [String]
60
+ # @return [Array<Concurrency, RateLimit>]
61
+ def strategies(match:)
62
+ results = []
63
+
64
+ @redis.call do |redis|
65
+ redis.scan_each(match: "#{NAMESPACE}:*:#{match}:*:*") do |key|
66
+ strategy = from_key(key)
67
+ results << strategy if strategy
68
+ end
69
+ end
70
+
71
+ results
72
+ end
73
+
74
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
75
+ # @return [Hash{Concurrency => Integer, RateLimit => Integer}]
76
+ def info(strategies:)
77
+ strategies.zip(execute(:INFO, to_params(strategies) << :TS << Time.now.to_i)).to_h
78
+ end
79
+
80
+ # @note Used for specs only.
81
+ # @return [void]
82
+ def ping
83
+ @redis.call(&:ping)
84
+ end
85
+
86
+ private
87
+
88
+ def execute(command, argv)
89
+ @redis.call { |redis| SCRIPT.call(redis, keys: [NAMESPACE], argv: [command, *argv]) }
90
+ end
91
+
92
+ def from_key(key)
93
+ md = KEYS_PATTERN.match(key)
94
+
95
+ case md && md[:strategy]
96
+ when "concurrency"
97
+ Concurrency.new(md[:bucket], limit: md[:limit], ttl: md[:ttl_or_period])
98
+ when "rate_limit"
99
+ RateLimit.new(md[:bucket], limit: md[:limit], period: md[:ttl_or_period])
100
+ end
101
+ end
102
+
103
+ def to_params(strategies)
104
+ result = []
105
+
106
+ strategies.each do |strategy|
107
+ case strategy
108
+ when Concurrency
109
+ result << "concurrency" << strategy.bucket << strategy.limit << strategy.ttl
110
+ when RateLimit
111
+ result << "rate_limit" << strategy.bucket << strategy.limit << strategy.period
112
+ end
113
+ end
114
+
115
+ result
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./api"
4
+
5
+ class RedisThrottle
6
+ module ClassMethods
7
+ # Syntax sugar for {Throttle#concurrency}.
8
+ #
9
+ # @see #concurrency
10
+ # @param (see Throttle#initialize)
11
+ # @param (see Throttle#concurrency)
12
+ # @return (see Throttle#concurrency)
13
+ def concurrency(bucket, limit:, ttl:, redis: nil)
14
+ new(redis: redis).concurrency(bucket, limit: limit, ttl: ttl)
15
+ end
16
+
17
+ # Syntax sugar for {Throttle#rate_limit}.
18
+ #
19
+ # @see #concurrency
20
+ # @param (see Throttle#initialize)
21
+ # @param (see Throttle#rate_limit)
22
+ # @return (see Throttle#rate_limit)
23
+ def rate_limit(bucket, limit:, period:, redis: nil)
24
+ new(redis: redis).rate_limit(bucket, limit: limit, period: period)
25
+ end
26
+
27
+ # Return usage info for all known (in use) strategies.
28
+ #
29
+ # @example
30
+ # Redis::Throttle.info(:match => "*_api").each do |strategy, current_value|
31
+ # # ...
32
+ # end
33
+ #
34
+ # @param match [#to_s]
35
+ # @return (see Api#info)
36
+ def info(match: "*", redis: nil)
37
+ api = Api.new(redis: redis)
38
+ strategies = api.strategies(match: match.to_s)
39
+
40
+ api.info(strategies: strategies)
41
+ end
42
+ end
43
+ end