simple_throttle 1.1.1 → 1.1.2
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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +24 -2
- data/VERSION +1 -1
- data/lib/simple_throttle.rb +68 -31
- data/simple_throttle.gemspec +8 -4
- metadata +10 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfa3230d5cc40054d4745124ca652988cda2d3443d46693cf4de2611e1c6eac1
|
|
4
|
+
data.tar.gz: c75e2af15e6a114f26f705e48874e4b70bb05efbd2f68d9c52f0236d8c9e9ca2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c66cd0cac05fa9edf641c9b63acd664b0631421f8f31cd3937d2f2d67f7dc13cfe6764b30a3feb6963b50b4795594d3109c58de757c8b866dd82cc1a9627383c
|
|
7
|
+
data.tar.gz: 2312af9ccb5e69534195098109d3965fa6e1b92ea9e4bd8f92e591fecaa85359a6ad5ff1e6fbc3a56cc6c8d2c6479701b55a3f3e5c76d3801ce060cf746d76a2
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.1.2
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Fixed `wait_time` returning negative values when the throttle list held more than `limit` entries (possible after `increment!` or with `pause_to_recover`); it now reads the entry that actually frees a slot, handles a concurrently expired key, and never returns a value below zero.
|
|
12
|
+
- Fixed the throttle timestamps to use the Redis server clock instead of the calling process clock so that entries are ordered consistently and cleanup works correctly when multiple clients with skewed clocks share a throttle. `peek` and `wait_time` also read the Redis server clock so clock skew cannot cause miscounts or wait times longer than the ttl; `wait_time` is additionally capped at the ttl.
|
|
13
|
+
- Fixed `SimpleThrottle.new` to always store the name as a frozen `String` regardless of the argument type.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Throttle time is now measured entirely by the Redis server clock. Manipulating time in the calling process (i.e. with Timecop or by stubbing `Time.now`) no longer has any effect on throttles; tests need to use a short `ttl` and actually sleep. Reading the server clock does not add a round trip; `peek` and `wait_time` pipeline it with the read of the timestamp list.
|
|
18
|
+
- `increment!` now raises `ArgumentError` when given a non-positive amount instead of corrupting the count.
|
|
19
|
+
- Hardened thread safety of the global throttle registry and lazily-initialized Redis client.
|
|
20
|
+
|
|
7
21
|
## 1.1.1
|
|
8
22
|
|
|
9
23
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Simple Throttle
|
|
2
2
|
|
|
3
3
|
[](https://github.com/bdurand/simple_throttle/actions/workflows/continuous_integration.yml)
|
|
4
|
-
[](https://github.com/bdurand/simple_throttle/actions/workflows/regression_test.yml)
|
|
5
4
|
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/simple_throttle)
|
|
6
6
|
|
|
7
7
|
This gem provides a very simple throttling mechanism backed by Redis for limiting access to a resource. The throttle can be thought of as a limit on the number of calls in a set time frame (i.e. 100 calls per hour).
|
|
8
8
|
|
|
@@ -78,7 +78,29 @@ end
|
|
|
78
78
|
|
|
79
79
|
### Redis requirement
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
The `redis` gem version 4.5 or greater is required. Versions 4.5, 5.x, and 6.x are all supported and tested.
|
|
82
|
+
|
|
83
|
+
Redis server 3.2 or greater is required. The Lua script that maintains the throttle reads the server clock and then writes to the throttle list, which is only allowed under effects replication.
|
|
84
|
+
|
|
85
|
+
### Time is measured by the Redis server clock
|
|
86
|
+
|
|
87
|
+
All throttle timestamps come from the Redis server's clock rather than the clock of the process calling the throttle. This is deliberate: a throttle is usually shared by many processes, and if each one stamped entries with its own clock, then clock skew between them would corrupt the ordering of the list and cause entries to expire too early or too late. Using a single clock means every client agrees on the time window no matter how skewed their local clocks are.
|
|
88
|
+
|
|
89
|
+
There are two consequences worth knowing about:
|
|
90
|
+
|
|
91
|
+
- **Manipulating time in tests has no effect on throttles.** Tools like [Timecop](https://github.com/travisjeffery/timecop), `ActiveSupport::Testing::TimeHelpers`, or stubbing `Time.now` only change the clock inside your Ruby process. The Redis server is a separate process and keeps reporting the real time, so freezing or traveling through time will not expire throttle entries or change what `wait_time` returns. To test throttle expiration, create the throttle with a very short `ttl` and actually `sleep`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
throttle = SimpleThrottle.new("test", limit: 1, ttl: 0.1)
|
|
95
|
+
expect(throttle.allowed!).to eq true
|
|
96
|
+
expect(throttle.allowed!).to eq false
|
|
97
|
+
sleep(0.15)
|
|
98
|
+
expect(throttle.allowed!).to eq true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `reset!` to clear a throttle between tests.
|
|
102
|
+
|
|
103
|
+
- **Reading the server clock does not cost an extra round trip.** `allowed!` and `increment!` read the clock inside the Lua script they already run, and `peek` and `wait_time` pipeline the clock read together with the read of the timestamp list. Every operation is still a single round trip to Redis.
|
|
82
104
|
|
|
83
105
|
## Installation
|
|
84
106
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1.
|
|
1
|
+
1.1.2
|
data/lib/simple_throttle.rb
CHANGED
|
@@ -11,17 +11,23 @@ class SimpleThrottle
|
|
|
11
11
|
# then the current entry will be added. The list is marked to expire with the oldest entry so
|
|
12
12
|
# there's no need to cleanup the lists.
|
|
13
13
|
LUA_SCRIPT = <<~LUA
|
|
14
|
+
redis.replicate_commands()
|
|
15
|
+
|
|
14
16
|
local list_key = KEYS[1]
|
|
15
17
|
local limit = tonumber(ARGV[1])
|
|
16
18
|
local ttl = tonumber(ARGV[2])
|
|
17
|
-
local
|
|
18
|
-
local
|
|
19
|
-
local
|
|
20
|
-
|
|
19
|
+
local pause_to_recover = tonumber(ARGV[3])
|
|
20
|
+
local amount = tonumber(ARGV[4])
|
|
21
|
+
local cleanup = tonumber(ARGV[5])
|
|
22
|
+
|
|
23
|
+
-- Use the Redis server clock so timestamps are consistent and monotonic
|
|
24
|
+
-- across all clients regardless of individual machine clock skew.
|
|
25
|
+
local time = redis.call('time')
|
|
26
|
+
local now = (tonumber(time[1]) * 1000) + math.floor(tonumber(time[2]) / 1000)
|
|
21
27
|
|
|
22
28
|
local size = redis.call('llen', list_key)
|
|
23
29
|
if size >= limit or (cleanup > 0 and size > 0) then
|
|
24
|
-
local expired =
|
|
30
|
+
local expired = now - ttl
|
|
25
31
|
while size > 0 do
|
|
26
32
|
local t = redis.call('lpop', list_key)
|
|
27
33
|
if tonumber(t) > expired then
|
|
@@ -51,6 +57,9 @@ class SimpleThrottle
|
|
|
51
57
|
LUA
|
|
52
58
|
|
|
53
59
|
@lock = Mutex.new
|
|
60
|
+
@redis_client = nil
|
|
61
|
+
@script_sha_1 = nil
|
|
62
|
+
@throttles = {}.freeze
|
|
54
63
|
|
|
55
64
|
class << self
|
|
56
65
|
# Add a global throttle that can be referenced later with the [] method.
|
|
@@ -63,12 +72,15 @@ class SimpleThrottle
|
|
|
63
72
|
# @param pause_to_recover [Boolean] require processes calling the throttle
|
|
64
73
|
# to pause at least temporarily before freeing up the throttle. If this is true,
|
|
65
74
|
# then a throttle called constantly with no pauses will never free up.
|
|
66
|
-
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a
|
|
75
|
+
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
|
|
67
76
|
# @return [void]
|
|
68
77
|
def add(name, ttl:, limit:, pause_to_recover: false, redis: nil)
|
|
69
78
|
@lock.synchronize do
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
# Copy-on-write so that lock-free readers in `[]` always see a
|
|
80
|
+
# fully-populated, immutable hash and never a partially rehashed one.
|
|
81
|
+
throttles = @throttles.dup
|
|
82
|
+
throttles[name.to_s] = new(name, limit: limit, ttl: ttl, pause_to_recover: pause_to_recover, redis: redis)
|
|
83
|
+
@throttles = throttles.freeze
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
|
@@ -77,9 +89,9 @@ class SimpleThrottle
|
|
|
77
89
|
# @param name [String, Symbol] name of the throttle
|
|
78
90
|
# @return [SimpleThrottle]
|
|
79
91
|
def [](name)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
# Assign then read for thread safety.
|
|
93
|
+
throttles = @throttles
|
|
94
|
+
throttles[name.to_s] if throttles
|
|
83
95
|
end
|
|
84
96
|
|
|
85
97
|
# Set the Redis instance to use for maintaining the throttle. This can either be set
|
|
@@ -99,11 +111,12 @@ class SimpleThrottle
|
|
|
99
111
|
#
|
|
100
112
|
# @return [Redis]
|
|
101
113
|
def redis
|
|
102
|
-
@redis_client ||= Redis.new
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
@lock.synchronize { @redis_client ||= Redis.new } unless @redis_client
|
|
115
|
+
client = @redis_client
|
|
116
|
+
if client.is_a?(Proc)
|
|
117
|
+
client.call
|
|
105
118
|
else
|
|
106
|
-
|
|
119
|
+
client
|
|
107
120
|
end
|
|
108
121
|
end
|
|
109
122
|
|
|
@@ -111,14 +124,15 @@ class SimpleThrottle
|
|
|
111
124
|
|
|
112
125
|
def execute_lua_script(redis:, keys:, args:)
|
|
113
126
|
client = redis
|
|
114
|
-
@script_sha_1
|
|
127
|
+
sha1 = @script_sha_1
|
|
128
|
+
sha1 ||= @lock.synchronize { @script_sha_1 ||= client.script(:load, LUA_SCRIPT) }
|
|
115
129
|
attempts = 0
|
|
116
130
|
|
|
117
131
|
begin
|
|
118
|
-
client.evalsha(
|
|
132
|
+
client.evalsha(sha1, Array(keys), Array(args))
|
|
119
133
|
rescue Redis::CommandError => e
|
|
120
134
|
if e.message.include?("NOSCRIPT") && attempts < 2
|
|
121
|
-
@script_sha_1 = client.script(:load, LUA_SCRIPT)
|
|
135
|
+
sha1 = @lock.synchronize { @script_sha_1 = client.script(:load, LUA_SCRIPT) }
|
|
122
136
|
attempts += 1
|
|
123
137
|
retry
|
|
124
138
|
else
|
|
@@ -138,10 +152,9 @@ class SimpleThrottle
|
|
|
138
152
|
# @param pause_to_recover [Boolean] require processes calling the throttle
|
|
139
153
|
# to pause at least temporarily before freeing up the throttle. If this is true,
|
|
140
154
|
# then a throttle called constantly with no pauses will never free up.
|
|
141
|
-
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a
|
|
155
|
+
# @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
|
|
142
156
|
def initialize(name, ttl:, limit:, pause_to_recover: false, redis: nil)
|
|
143
|
-
@name = name.to_s
|
|
144
|
-
@name = name.dup.freeze unless name.frozen?
|
|
157
|
+
@name = name.to_s.dup.freeze
|
|
145
158
|
@limit = limit.to_i
|
|
146
159
|
@ttl = ttl.to_f
|
|
147
160
|
@pause_to_recover = !!pause_to_recover
|
|
@@ -161,9 +174,12 @@ class SimpleThrottle
|
|
|
161
174
|
# how the throttle is implemented in Redis, the return value will always max
|
|
162
175
|
# out at the throttle limit + 1 or, if the pause to recover option is set, limit + 2.
|
|
163
176
|
#
|
|
164
|
-
# @param amount [Integer] amount to increment the throttle by
|
|
177
|
+
# @param amount [Integer] amount to increment the throttle by (must be positive)
|
|
165
178
|
# @return [Integer]
|
|
166
179
|
def increment!(amount = 1)
|
|
180
|
+
amount = amount.to_i
|
|
181
|
+
raise ArgumentError, "amount must be a positive integer" if amount < 1
|
|
182
|
+
|
|
167
183
|
add_request(amount, true)
|
|
168
184
|
end
|
|
169
185
|
|
|
@@ -178,8 +194,8 @@ class SimpleThrottle
|
|
|
178
194
|
#
|
|
179
195
|
# @return [Integer]
|
|
180
196
|
def peek
|
|
181
|
-
timestamps =
|
|
182
|
-
min_timestamp = ((
|
|
197
|
+
timestamps, now = timestamps_with_server_time
|
|
198
|
+
min_timestamp = ((now - ttl) * 1000).ceil
|
|
183
199
|
timestamps.count { |t| t > min_timestamp }
|
|
184
200
|
end
|
|
185
201
|
|
|
@@ -189,13 +205,19 @@ class SimpleThrottle
|
|
|
189
205
|
#
|
|
190
206
|
# @return [Float]
|
|
191
207
|
def wait_time
|
|
192
|
-
|
|
208
|
+
timestamps, now = timestamps_with_server_time
|
|
209
|
+
min_timestamp = ((now - ttl) * 1000).ceil
|
|
210
|
+
if timestamps.count { |t| t > min_timestamp } < limit
|
|
193
211
|
0.0
|
|
194
212
|
else
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
# The entry that frees up a slot is the limit-th newest (index -limit),
|
|
214
|
+
# not the head of the list, since the list can legitimately hold more
|
|
215
|
+
# than `limit` entries (increment! and pause_to_recover both add extras).
|
|
216
|
+
oldest = timestamps[-limit]
|
|
217
|
+
return 0.0 if oldest.nil?
|
|
218
|
+
first = oldest.to_f / 1000.0
|
|
219
|
+
wait = ttl - (now - first)
|
|
220
|
+
wait.clamp(0.0, ttl)
|
|
199
221
|
end
|
|
200
222
|
end
|
|
201
223
|
|
|
@@ -213,15 +235,30 @@ class SimpleThrottle
|
|
|
213
235
|
"simple_throttle.#{name}"
|
|
214
236
|
end
|
|
215
237
|
|
|
238
|
+
# The Lua script stores timestamps from the Redis server clock, so reads
|
|
239
|
+
# must be measured against that same clock rather than the local one. Both
|
|
240
|
+
# values are fetched in a single pipeline so that reading the clock doesn't
|
|
241
|
+
# cost an extra round trip.
|
|
242
|
+
#
|
|
243
|
+
# @return [Array(Array<Integer>, Float)] the tracked timestamps in
|
|
244
|
+
# milliseconds and the current Redis server time in seconds.
|
|
245
|
+
def timestamps_with_server_time
|
|
246
|
+
timestamps, time = redis_client.pipelined do |pipeline|
|
|
247
|
+
pipeline.lrange(redis_key, 0, -1)
|
|
248
|
+
pipeline.time
|
|
249
|
+
end
|
|
250
|
+
seconds, microseconds = time
|
|
251
|
+
[timestamps.collect(&:to_i), seconds.to_i + (microseconds.to_i / 1_000_000.0)]
|
|
252
|
+
end
|
|
253
|
+
|
|
216
254
|
def add_request(amount, cleanup)
|
|
217
255
|
pause_to_recover_arg = (@pause_to_recover ? 1 : 0)
|
|
218
|
-
time_ms = (Time.now.to_f * 1000).round
|
|
219
256
|
ttl_ms = (ttl * 1000).ceil
|
|
220
257
|
self.class.send(
|
|
221
258
|
:execute_lua_script,
|
|
222
259
|
redis: redis_client,
|
|
223
260
|
keys: [redis_key],
|
|
224
|
-
args: [limit, ttl_ms,
|
|
261
|
+
args: [limit, ttl_ms, pause_to_recover_arg, amount, (cleanup ? 1 : 0)]
|
|
225
262
|
)
|
|
226
263
|
end
|
|
227
264
|
end
|
data/simple_throttle.gemspec
CHANGED
|
@@ -8,6 +8,12 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.homepage = "https://github.com/bdurand/simple_throttle"
|
|
9
9
|
spec.license = "MIT"
|
|
10
10
|
|
|
11
|
+
spec.metadata = {
|
|
12
|
+
"homepage_uri" => spec.homepage,
|
|
13
|
+
"source_code_uri" => spec.homepage,
|
|
14
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
# Specify which files should be added to the gem when it is released.
|
|
12
18
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
13
19
|
ignore_files = %w[
|
|
@@ -26,9 +32,7 @@ Gem::Specification.new do |spec|
|
|
|
26
32
|
|
|
27
33
|
spec.require_paths = ["lib"]
|
|
28
34
|
|
|
29
|
-
spec.add_dependency "redis"
|
|
30
|
-
|
|
31
|
-
spec.add_development_dependency "bundler"
|
|
35
|
+
spec.add_dependency "redis", ">= 4.5"
|
|
32
36
|
|
|
33
|
-
spec.required_ruby_version = ">= 2.
|
|
37
|
+
spec.required_ruby_version = ">= 2.6"
|
|
34
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: simple_throttle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: redis
|
|
@@ -16,29 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '4.5'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: bundler
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0'
|
|
34
|
-
type: :development
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - ">="
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0'
|
|
41
|
-
description:
|
|
25
|
+
version: '4.5'
|
|
42
26
|
email:
|
|
43
27
|
- bbdurand@gmail.com
|
|
44
28
|
executables: []
|
|
@@ -54,8 +38,10 @@ files:
|
|
|
54
38
|
homepage: https://github.com/bdurand/simple_throttle
|
|
55
39
|
licenses:
|
|
56
40
|
- MIT
|
|
57
|
-
metadata:
|
|
58
|
-
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://github.com/bdurand/simple_throttle
|
|
43
|
+
source_code_uri: https://github.com/bdurand/simple_throttle
|
|
44
|
+
changelog_uri: https://github.com/bdurand/simple_throttle/blob/main/CHANGELOG.md
|
|
59
45
|
rdoc_options: []
|
|
60
46
|
require_paths:
|
|
61
47
|
- lib
|
|
@@ -63,15 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
63
49
|
requirements:
|
|
64
50
|
- - ">="
|
|
65
51
|
- !ruby/object:Gem::Version
|
|
66
|
-
version: '2.
|
|
52
|
+
version: '2.6'
|
|
67
53
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
54
|
requirements:
|
|
69
55
|
- - ">="
|
|
70
56
|
- !ruby/object:Gem::Version
|
|
71
57
|
version: '0'
|
|
72
58
|
requirements: []
|
|
73
|
-
rubygems_version:
|
|
74
|
-
signing_key:
|
|
59
|
+
rubygems_version: 4.0.3
|
|
75
60
|
specification_version: 4
|
|
76
61
|
summary: Simple redis backed throttling mechanism to limit access to a resource
|
|
77
62
|
test_files: []
|