redis-throttle 1.0.0 → 2.0.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: 6dbef2b5249fc1cd8ff9c199e00b11b98dafa0ec8f0a707e9da8af4c5fa3ed1d
4
+ data.tar.gz: 2a8ddfd1fe58e6f9a3e63cb219ab42450518f804cce806e7778aaca251563cb1
5
5
  SHA512:
6
- metadata.gz: 89d197efa9dadad39539510da10ec183c2df3f00c1b12063591827ab7ffd02b368ab92a9643a20d83dc40751bbfa2cdf33ae42f2d41717d314d40f496628af53
7
- data.tar.gz: d07140bc01e01b051dcabd266b0a2d5410ce9289d74b5550c44da903c8d3b0f5e574a8cef3ae5b777b26fb1d5557e2fb294e9b0a7394b75e77c0c31e047def75
6
+ metadata.gz: 74c91479a17ea0759cb6d9a9bcf78c27d1454339a3dca5678cb90aa28c582ed6f11f5d825ff38401a51e9a039728f3d4494887466674f2c29aae4615b376af41
7
+ data.tar.gz: 4e299f8ae32084daf1c5941629601ba48d4af09d5cec9504421e05a462e0be1a24131929c56c3913f5edf70b4aa53a3a8a57d0d72d90a494193695d8fffb3db5
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,157 @@
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(redis, token: "abc") # => "abc"
31
+ concurrency.acquire(redis, token: "xyz") # => nil
32
+
33
+ concurrency.release(redis, token: "abc")
34
+
35
+ concurrency.acquire(redis, 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(redis) # => "6a6c6546-268d-4216-bcf3-3139b8e11609"
49
+ rate_limit.acquire(redis) # => nil
50
+
51
+ sleep 10
52
+
53
+ rate_limit.acquire(redis) # => "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(redis, 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(redis) do
81
+ # ...
82
+ end
83
+ ----
84
+
85
+
86
+ == Compatibility
87
+
88
+ This library aims to support and is tested against:
89
+
90
+ * https://www.ruby-lang.org[Ruby]
91
+ ** MRI 2.7.x
92
+ ** MRI 3.0.x
93
+ ** MRI 3.1.x
94
+ ** MRI 3.2.x
95
+ * https://redis.io[Redis Server]
96
+ ** 6.0.x
97
+ ** 6.2.x
98
+ ** 7.0.x
99
+ * https://github.com/redis/redis-rb[redis-rb]
100
+ ** 4.1.x
101
+ ** 4.2.x
102
+ ** 4.3.x
103
+ ** 4.4.x
104
+ ** 4.5.x
105
+ ** 4.6.x
106
+ ** 4.7.x
107
+ ** 4.8.x
108
+ ** 5.0.x
109
+ * https://github.com/resque/redis-namespace[redis-namespace]
110
+ ** 1.10.x
111
+ * https://github.com/redis-rb/redis-client[redis-client]
112
+ ** 0.12.x
113
+ ** 0.13.x
114
+ ** 0.14.x
115
+
116
+ If something doesn't work on one of these versions, it's a bug.
117
+
118
+ This library may inadvertently work (or seem to work) on other Ruby versions,
119
+ however support will only be provided for the versions listed above.
120
+
121
+ If you would like this library to support another Ruby version or
122
+ implementation, you may volunteer to be a maintainer. Being a maintainer
123
+ entails making sure all tests run and pass on that implementation. When
124
+ something breaks on your implementation, you will be responsible for providing
125
+ patches in a timely fashion. If critical issues for a particular implementation
126
+ exist at the time of a major release, support for that Ruby version may be
127
+ dropped.
128
+
129
+ The same applies to *Redis Server*, *redis-rb*, *redis-namespace*,
130
+ and *redis-client* support.
131
+
132
+
133
+ == Development
134
+
135
+ scripts/update-gemfiles
136
+ scripts/run-rspec
137
+ bundle exec rubocop
138
+
139
+
140
+ == Contributing
141
+
142
+ * Fork redis-throttle
143
+ * Make your changes
144
+ * Ensure all tests pass (`bundle exec rake`)
145
+ * Send a merge request
146
+ * If we like them we'll merge them
147
+ * If we've accepted a patch, feel free to ask for commit access!
148
+
149
+
150
+ == Appreciations
151
+
152
+ Thanks to all how providede suggestions and criticism, especially to those who
153
+ helped me shape some of the initial ideas:
154
+
155
+ * https://gitlab.com/freemanoid[@freemanoid]
156
+ * https://gitlab.com/petethepig[@petethepig]
157
+ * https://gitlab.com/dervus[@dervus]
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./redis_throttle"
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis_prescription"
4
+
5
+ require_relative "./concurrency"
6
+ require_relative "./rate_limit"
7
+
8
+ class RedisThrottle
9
+ # @api private
10
+ class Api
11
+ NAMESPACE = "throttle"
12
+ private_constant :NAMESPACE
13
+
14
+ KEYS_PATTERN = %r{
15
+ \A
16
+ #{NAMESPACE}:
17
+ (?<strategy>concurrency|rate_limit):
18
+ (?<bucket>.+):
19
+ (?<limit>\d+):
20
+ (?<ttl_or_period>\d+)
21
+ \z
22
+ }x.freeze
23
+ private_constant :KEYS_PATTERN
24
+
25
+ SCRIPT = RedisPrescription.new(File.read("#{__dir__}/api.lua"))
26
+ private_constant :SCRIPT
27
+
28
+ # @param redis [Redis, Redis::Namespace, RedisClient, RedisClient::Decorator::Client]
29
+ def initialize(redis)
30
+ @redis = redis
31
+ end
32
+
33
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
34
+ # @param token [String]
35
+ # @return [Boolean]
36
+ def acquire(strategies:, token:)
37
+ execute(:ACQUIRE, to_params(strategies.sort_by(&:itself)) << :TOKEN << token << :TS << Time.now.to_i).zero?
38
+ end
39
+
40
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
41
+ # @param token [String]
42
+ # @return [void]
43
+ def release(strategies:, token:)
44
+ execute(:RELEASE, to_params(strategies.grep(Concurrency)) << :TOKEN << token)
45
+ end
46
+
47
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
48
+ # @return [void]
49
+ def reset(strategies:)
50
+ execute(:RESET, to_params(strategies))
51
+ end
52
+
53
+ # @param match [String]
54
+ # @return [Array<Concurrency, RateLimit>]
55
+ def strategies(match:)
56
+ results = []
57
+ block = ->(key) { from_key(key)&.then { |strategy| results << strategy } }
58
+
59
+ if redis_client?
60
+ @redis.scan("MATCH", "#{NAMESPACE}:*:#{match}:*:*", &block)
61
+ else
62
+ @redis.scan_each(match: "#{NAMESPACE}:*:#{match}:*:*", &block)
63
+ end
64
+
65
+ results
66
+ end
67
+
68
+ # @param strategies [Enumerable<Concurrency, RateLimit>]
69
+ # @return [Hash{Concurrency => Integer, RateLimit => Integer}]
70
+ def info(strategies:)
71
+ strategies.zip(execute(:INFO, to_params(strategies) << :TS << Time.now.to_i)).to_h
72
+ end
73
+
74
+ private
75
+
76
+ def redis_client?
77
+ return true if defined?(::RedisClient) && @redis.is_a?(::RedisClient)
78
+ return true if defined?(::RedisClient::Decorator::Client) && @redis.is_a?(::RedisClient::Decorator::Client)
79
+
80
+ false
81
+ end
82
+
83
+ def execute(command, argv)
84
+ SCRIPT.call(@redis, keys: [NAMESPACE], argv: [command, *argv])
85
+ end
86
+
87
+ def from_key(key)
88
+ md = KEYS_PATTERN.match(key)
89
+
90
+ case md && md[:strategy]
91
+ when "concurrency"
92
+ Concurrency.new(md[:bucket], limit: md[:limit], ttl: md[:ttl_or_period])
93
+ when "rate_limit"
94
+ RateLimit.new(md[:bucket], limit: md[:limit], period: md[:ttl_or_period])
95
+ end
96
+ end
97
+
98
+ def to_params(strategies)
99
+ result = []
100
+
101
+ strategies.each do |strategy|
102
+ case strategy
103
+ when Concurrency
104
+ result << "concurrency" << strategy.bucket << strategy.limit << strategy.ttl
105
+ when RateLimit
106
+ result << "rate_limit" << strategy.bucket << strategy.limit << strategy.period
107
+ end
108
+ end
109
+
110
+ result
111
+ end
112
+ end
113
+ 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
@@ -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 = "2.0.0"
6
+ end