redis-throttle 1.0.0 → 2.0.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: 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