simple_throttle 1.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47859d64b667fe3e43c8a98ecbce2fa305177530a5d625ed0bd38a500875d268
4
- data.tar.gz: 3c0df333efeec1abc8487328a2d131a9ffc2c87273fb5f0ec4048bc340ba1d55
3
+ metadata.gz: bfa3230d5cc40054d4745124ca652988cda2d3443d46693cf4de2611e1c6eac1
4
+ data.tar.gz: c75e2af15e6a114f26f705e48874e4b70bb05efbd2f68d9c52f0236d8c9e9ca2
5
5
  SHA512:
6
- metadata.gz: 88fb81aaa2ac35d7ae5932ccded080c05fe3dd718bcfa9d73b93e8cfd3eb1706b4b5da42b02a06bcd64c861de6f3c98df9bba6005d1b104881b0d5a91f111d06
7
- data.tar.gz: b8a94b2c2a24b7c88266a70f052a93a0a6a0fac5b48842e2fd89e56200ebf72c0229defca71ee775cc4a72810a5d4c9c8ee4feac37ab8281d8e67a0a924bf311
6
+ metadata.gz: c66cd0cac05fa9edf641c9b63acd664b0631421f8f31cd3937d2f2d67f7dc13cfe6764b30a3feb6963b50b4795594d3109c58de757c8b866dd82cc1a9627383c
7
+ data.tar.gz: 2312af9ccb5e69534195098109d3965fa6e1b92ea9e4bd8f92e591fecaa85359a6ad5ff1e6fbc3a56cc6c8d2c6479701b55a3f3e5c76d3801ce060cf746d76a2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ 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
+
21
+ ## 1.1.1
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `increment!` method to return the correct value after removing expired requests rather than the raw count from Redis.
26
+
7
27
  ## 1.1.0
8
28
 
9
29
  ### Added
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Simple Throttle
2
2
 
3
3
  [![Continuous Integration](https://github.com/bdurand/simple_throttle/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/simple_throttle/actions/workflows/continuous_integration.yml)
4
- [![Regression Test](https://github.com/bdurand/simple_throttle/actions/workflows/regression_test.yml/badge.svg)](https://github.com/bdurand/simple_throttle/actions/workflows/regression_test.yml)
5
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![Gem Version](https://badge.fury.io/rb/simple_throttle.svg)](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
- Redis server 2.6 or greater is required for this code.
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.0
1
+ 1.1.2
@@ -11,16 +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 now = ARGV[3]
18
- local pause_to_recover = tonumber(ARGV[4])
19
- local amount = tonumber(ARGV[5])
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)
20
27
 
21
28
  local size = redis.call('llen', list_key)
22
- if size >= limit then
23
- local expired = tonumber(now) - ttl
29
+ if size >= limit or (cleanup > 0 and size > 0) then
30
+ local expired = now - ttl
24
31
  while size > 0 do
25
32
  local t = redis.call('lpop', list_key)
26
33
  if tonumber(t) > expired then
@@ -50,6 +57,9 @@ class SimpleThrottle
50
57
  LUA
51
58
 
52
59
  @lock = Mutex.new
60
+ @redis_client = nil
61
+ @script_sha_1 = nil
62
+ @throttles = {}.freeze
53
63
 
54
64
  class << self
55
65
  # Add a global throttle that can be referenced later with the [] method.
@@ -62,12 +72,15 @@ class SimpleThrottle
62
72
  # @param pause_to_recover [Boolean] require processes calling the throttle
63
73
  # to pause at least temporarily before freeing up the throttle. If this is true,
64
74
  # then a throttle called constantly with no pauses will never free up.
65
- # @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redos instance
75
+ # @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
66
76
  # @return [void]
67
77
  def add(name, ttl:, limit:, pause_to_recover: false, redis: nil)
68
78
  @lock.synchronize do
69
- @throttles ||= {}
70
- @throttles[name.to_s] = new(name, limit: limit, ttl: ttl, pause_to_recover: pause_to_recover, redis: redis)
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
71
84
  end
72
85
  end
73
86
 
@@ -76,9 +89,9 @@ class SimpleThrottle
76
89
  # @param name [String, Symbol] name of the throttle
77
90
  # @return [SimpleThrottle]
78
91
  def [](name)
79
- if defined?(@throttles) && @throttles
80
- @throttles[name.to_s]
81
- end
92
+ # Assign then read for thread safety.
93
+ throttles = @throttles
94
+ throttles[name.to_s] if throttles
82
95
  end
83
96
 
84
97
  # Set the Redis instance to use for maintaining the throttle. This can either be set
@@ -98,11 +111,12 @@ class SimpleThrottle
98
111
  #
99
112
  # @return [Redis]
100
113
  def redis
101
- @redis_client ||= Redis.new
102
- if @redis_client.is_a?(Proc)
103
- @redis_client.call
114
+ @lock.synchronize { @redis_client ||= Redis.new } unless @redis_client
115
+ client = @redis_client
116
+ if client.is_a?(Proc)
117
+ client.call
104
118
  else
105
- @redis_client
119
+ client
106
120
  end
107
121
  end
108
122
 
@@ -110,14 +124,15 @@ class SimpleThrottle
110
124
 
111
125
  def execute_lua_script(redis:, keys:, args:)
112
126
  client = redis
113
- @script_sha_1 ||= client.script(:load, LUA_SCRIPT)
127
+ sha1 = @script_sha_1
128
+ sha1 ||= @lock.synchronize { @script_sha_1 ||= client.script(:load, LUA_SCRIPT) }
114
129
  attempts = 0
115
130
 
116
131
  begin
117
- client.evalsha(@script_sha_1, Array(keys), Array(args))
132
+ client.evalsha(sha1, Array(keys), Array(args))
118
133
  rescue Redis::CommandError => e
119
134
  if e.message.include?("NOSCRIPT") && attempts < 2
120
- @script_sha_1 = client.script(:load, LUA_SCRIPT)
135
+ sha1 = @lock.synchronize { @script_sha_1 = client.script(:load, LUA_SCRIPT) }
121
136
  attempts += 1
122
137
  retry
123
138
  else
@@ -137,10 +152,9 @@ class SimpleThrottle
137
152
  # @param pause_to_recover [Boolean] require processes calling the throttle
138
153
  # to pause at least temporarily before freeing up the throttle. If this is true,
139
154
  # then a throttle called constantly with no pauses will never free up.
140
- # @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redos instance
155
+ # @param redis [Redis, Proc] Redis instance to use or a Proc that yields a Redis instance
141
156
  def initialize(name, ttl:, limit:, pause_to_recover: false, redis: nil)
142
- @name = name.to_s
143
- @name = name.dup.freeze unless name.frozen?
157
+ @name = name.to_s.dup.freeze
144
158
  @limit = limit.to_i
145
159
  @ttl = ttl.to_f
146
160
  @pause_to_recover = !!pause_to_recover
@@ -152,7 +166,7 @@ class SimpleThrottle
152
166
  #
153
167
  # @return [Boolean]
154
168
  def allowed!
155
- size = increment!
169
+ size = add_request(1, false)
156
170
  size <= limit
157
171
  end
158
172
 
@@ -160,18 +174,13 @@ class SimpleThrottle
160
174
  # how the throttle is implemented in Redis, the return value will always max
161
175
  # out at the throttle limit + 1 or, if the pause to recover option is set, limit + 2.
162
176
  #
163
- # @param amount [Integer] amount to increment the throttle by
177
+ # @param amount [Integer] amount to increment the throttle by (must be positive)
164
178
  # @return [Integer]
165
179
  def increment!(amount = 1)
166
- pause_to_recover_arg = (@pause_to_recover ? 1 : 0)
167
- time_ms = (Time.now.to_f * 1000).round
168
- ttl_ms = (ttl * 1000).ceil
169
- self.class.send(
170
- :execute_lua_script,
171
- redis: redis_client,
172
- keys: [redis_key],
173
- args: [limit, ttl_ms, time_ms, pause_to_recover_arg, amount]
174
- )
180
+ amount = amount.to_i
181
+ raise ArgumentError, "amount must be a positive integer" if amount < 1
182
+
183
+ add_request(amount, true)
175
184
  end
176
185
 
177
186
  # Reset a throttle back to zero.
@@ -185,8 +194,8 @@ class SimpleThrottle
185
194
  #
186
195
  # @return [Integer]
187
196
  def peek
188
- timestamps = redis_client.lrange(redis_key, 0, -1).collect(&:to_i)
189
- min_timestamp = ((Time.now.to_f - ttl) * 1000).ceil
197
+ timestamps, now = timestamps_with_server_time
198
+ min_timestamp = ((now - ttl) * 1000).ceil
190
199
  timestamps.count { |t| t > min_timestamp }
191
200
  end
192
201
 
@@ -196,13 +205,19 @@ class SimpleThrottle
196
205
  #
197
206
  # @return [Float]
198
207
  def wait_time
199
- if peek < limit
208
+ timestamps, now = timestamps_with_server_time
209
+ min_timestamp = ((now - ttl) * 1000).ceil
210
+ if timestamps.count { |t| t > min_timestamp } < limit
200
211
  0.0
201
212
  else
202
- first = redis_client.lindex(redis_key, 0).to_f / 1000.0
203
- delta = Time.now.to_f - first
204
- delta = 0.0 if delta < 0
205
- ttl - delta
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)
206
221
  end
207
222
  end
208
223
 
@@ -219,4 +234,31 @@ class SimpleThrottle
219
234
  def redis_key
220
235
  "simple_throttle.#{name}"
221
236
  end
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
+
254
+ def add_request(amount, cleanup)
255
+ pause_to_recover_arg = (@pause_to_recover ? 1 : 0)
256
+ ttl_ms = (ttl * 1000).ceil
257
+ self.class.send(
258
+ :execute_lua_script,
259
+ redis: redis_client,
260
+ keys: [redis_key],
261
+ args: [limit, ttl_ms, pause_to_recover_arg, amount, (cleanup ? 1 : 0)]
262
+ )
263
+ end
222
264
  end
@@ -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.5"
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.0
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: 2024-01-30 00:00:00.000000000 Z
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: '0'
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: '0'
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
- post_install_message:
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.5'
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: 3.4.10
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: []