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.
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