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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6578fcf4ed5b682873d8f6c31a629bf8ab04fa3f747a6e85e67d552fa22ad40
4
- data.tar.gz: c3d2ea3307187912af4d4f93ecee4f8a2fc2e386922511b63e3a9ad93e547c49
3
+ metadata.gz: df74619037a1515419ebbf8381326577c540376855146fece1b9624f9ee443f6
4
+ data.tar.gz: 4b465e4412d3fa8408b48470b867576457737524cded5b41ea8bb04aef53a39b
5
5
  SHA512:
6
- metadata.gz: 861efde66ca39700db8a6922add9e7539ecd13b9ecea1a79fe14575df1ca75772ac49608a298bbdb92fce7ec7681cb40eeaf3768089e7481395c57eb6ad23418
7
- data.tar.gz: a1299fb9d99b21c5cca90823812e77feda7fccc7ed4e69778afcd2bb87a0c74aa94f48746047f3fd8b02ccf2076d4344c4047ca499b69090bfffd823cdc6ae87
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,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "throttle/errors"
4
- require_relative "throttle/version"
5
- require_relative "throttle/concurrency"
6
- require_relative "throttle/threshold"
3
+ require_relative "../redis_throttle"
7
4
 
8
- # @see https://github.com/redis/redis-rb
9
5
  class Redis
10
- # Distributed threshold and concurrency throttling.
11
- module Throttle; end
6
+ # @deprecated Use ::RedisThrottle
7
+ class Throttle < RedisThrottle
8
+ class << self
9
+ attr_accessor :silence_deprecation_warning
10
+ end
11
+
12
+ self.silence_deprecation_warning = false
13
+
14
+ def initialize(*args, **kwargs, &block)
15
+ super(*args, **kwargs, &block)
16
+
17
+ return if self.class.silence_deprecation_warning
18
+
19
+ warn "#{self.class} usage was deprecated, please use RedisThrottle instead"
20
+ end
21
+ end
12
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,195 @@
1
+ if 1 ~= #KEYS or 0 == #ARGV then
2
+ return redis.error_reply("syntax error")
3
+ end
4
+
5
+ local commands = {}
6
+
7
+ commands.ACQUIRE = {
8
+ params = { "strategies", "token", "timestamp" },
9
+ handler = function (params)
10
+ local now = params.timestamp
11
+ local token = params.token
12
+ local locks = {}
13
+
14
+ local acquire = {
15
+ rate_limit = function (strategy)
16
+ local key, limit, period = strategy.key, strategy.limit, strategy.period
17
+
18
+ if redis.call("LLEN", key) < limit or tonumber(redis.call("LINDEX", key, -1)) < now then
19
+ return function ()
20
+ redis.call("LPUSH", key, now + period)
21
+ redis.call("LTRIM", key, 0, limit - 1)
22
+ redis.call("EXPIRE", key, period)
23
+ end
24
+ end
25
+ end,
26
+
27
+ concurrency = function (strategy)
28
+ local key, limit, ttl = strategy.key, strategy.limit, strategy.ttl
29
+
30
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. now)
31
+
32
+ if redis.call("ZCARD", key) < limit or redis.call("ZSCORE", key, token) then
33
+ return function ()
34
+ redis.call("ZADD", key, now + ttl, token)
35
+ redis.call("EXPIRE", key, ttl)
36
+ end
37
+ end
38
+ end
39
+ }
40
+
41
+ for _, strategy in ipairs(params.strategies) do
42
+ local lock = acquire[strategy.name](strategy)
43
+
44
+ if lock then
45
+ table.insert(locks, lock)
46
+ else
47
+ return 1
48
+ end
49
+ end
50
+
51
+ for _, lock in ipairs(locks) do
52
+ lock()
53
+ end
54
+
55
+ return 0
56
+ end
57
+ }
58
+
59
+ commands.RELEASE = {
60
+ params = { "strategies", "token" },
61
+ handler = function (params)
62
+ for _, strategy in ipairs(params.strategies) do
63
+ if "concurrency" == strategy.name then
64
+ redis.call("ZREM", strategy.key, params.token)
65
+ end
66
+ end
67
+
68
+ return redis.status_reply("ok")
69
+ end
70
+ }
71
+
72
+ commands.RESET = {
73
+ params = { "strategies" },
74
+ handler = function (params)
75
+ for _, strategy in ipairs(params.strategies) do
76
+ redis.call("DEL", strategy.key)
77
+ end
78
+
79
+ return redis.status_reply("ok")
80
+ end
81
+ }
82
+
83
+ commands.INFO = {
84
+ params = { "strategies", "timestamp" },
85
+ handler = function (params)
86
+ local usage, now = {}, params.timestamp
87
+
88
+ for _, strategy in ipairs(params.strategies) do
89
+ local key = strategy.key
90
+
91
+ if "concurrency" == strategy.name then
92
+ redis.call("ZREMRANGEBYSCORE", key, "-inf", "(" .. now)
93
+ table.insert(usage, redis.call("ZCARD", key))
94
+ elseif "rate_limit" == strategy.name then
95
+ local last = tonumber(redis.call("LINDEX", key, -1) or now)
96
+
97
+ while last < now do
98
+ redis.call("RPOP", key)
99
+ last = tonumber(redis.call("LINDEX", key, -1) or now)
100
+ end
101
+
102
+ table.insert(usage, redis.call("LLEN", key))
103
+ end
104
+ end
105
+
106
+ return usage
107
+ end
108
+ }
109
+
110
+ local function parse_params (parts)
111
+ local parse = {}
112
+
113
+ function parse.strategies (pos)
114
+ local strategies = {}
115
+
116
+ while pos + 3 <= #ARGV do
117
+ local name, strategy = string.lower(ARGV[pos]), nil
118
+ local bucket, limit, ttl_or_period = ARGV[pos + 1], tonumber(ARGV[pos + 2]), tonumber(ARGV[pos + 3])
119
+
120
+ if "concurrency" == name then
121
+ strategy = { name = name, bucket = bucket, limit = limit, ttl = ttl_or_period }
122
+ elseif "rate_limit" == name then
123
+ strategy = { name = name, bucket = bucket, limit = limit, period = ttl_or_period }
124
+ else
125
+ break
126
+ end
127
+
128
+ if bucket and 0 < limit and 0 < ttl_or_period then
129
+ strategy.key = table.concat({ KEYS[1], name, bucket, limit, ttl_or_period }, ":")
130
+ table.insert(strategies, strategy)
131
+
132
+ pos = pos + 4
133
+ else
134
+ return { err = "invalid " .. name .. " options" }
135
+ end
136
+ end
137
+
138
+ if 0 == #strategies then
139
+ return { err = "missing strategies" }
140
+ end
141
+
142
+ return { val = strategies, pos = pos }
143
+ end
144
+
145
+ function parse.token (pos)
146
+ if ARGV[pos] and ARGV[pos + 1] and "TOKEN" == string.upper(ARGV[pos]) then
147
+ return { val = ARGV[pos + 1], pos = pos + 2 }
148
+ end
149
+
150
+ return { err = "missing or invalid token" }
151
+ end
152
+
153
+ function parse.timestamp (pos)
154
+ if ARGV[pos] and ARGV[pos + 1] and "TS" == string.upper(ARGV[pos]) then
155
+ local timestamp = tonumber(ARGV[pos + 1])
156
+
157
+ if 0 < timestamp then
158
+ return { val = timestamp, pos = pos + 2 }
159
+ end
160
+ end
161
+
162
+ return { err = "missing or invalid timestamp" }
163
+ end
164
+
165
+ local params, pos = {}, 2
166
+
167
+ for _, part in ipairs(parts) do
168
+ local out = parse[part](pos)
169
+
170
+ if out.err then
171
+ return out
172
+ end
173
+
174
+ params[part] = out.val
175
+ pos = out.pos
176
+ end
177
+
178
+ if pos < #ARGV then
179
+ return { err = "wrong number of arguments" }
180
+ end
181
+
182
+ return { val = params }
183
+ end
184
+
185
+ local command = commands[string.upper(ARGV[1])]
186
+ if not command then
187
+ return redis.error_reply("invalid command")
188
+ end
189
+
190
+ local params = parse_params(command.params)
191
+ if params.err then
192
+ return redis.error_reply(params.err)
193
+ end
194
+
195
+ return command.handler(params.val)
@@ -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
@@ -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