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