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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6777d3382abb6c7e07c503e56676a8fe62be43611b2c623ac18f89928708572e
4
- data.tar.gz: da8f2432ec4bc9dcf68e42faaf6213d662379c3ded9dfb16c1eb8c2cd27cb62c
3
+ metadata.gz: bfa3230d5cc40054d4745124ca652988cda2d3443d46693cf4de2611e1c6eac1
4
+ data.tar.gz: c75e2af15e6a114f26f705e48874e4b70bb05efbd2f68d9c52f0236d8c9e9ca2
5
5
  SHA512:
6
- metadata.gz: f90008dc9debdfb8e206d25f112c0e312c4c27060256464eba6b175aefb9b5be063bbcdfb1188bbcaab05caedb6f1197d28d7b4a5ee3b40819692e4d57e9642a
7
- data.tar.gz: 31326e2b2ab31f4fbccf8dbd6fb7d0455acdd1e745da7d90ea77f526f320fef4d77c94cc98dffc03ce9d76f4c3fc7ed167aa5b9bd72761dfc0463296b7424459
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
  [![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.1
1
+ 1.1.2
@@ -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 now = ARGV[3]
18
- local pause_to_recover = tonumber(ARGV[4])
19
- local amount = tonumber(ARGV[5])
20
- local cleanup = tonumber(ARGV[6])
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 = tonumber(now) - ttl
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 Redos instance
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
- @throttles ||= {}
71
- @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
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
- if defined?(@throttles) && @throttles
81
- @throttles[name.to_s]
82
- end
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
- if @redis_client.is_a?(Proc)
104
- @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
105
118
  else
106
- @redis_client
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 ||= client.script(:load, LUA_SCRIPT)
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(@script_sha_1, Array(keys), Array(args))
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 Redos instance
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 = redis_client.lrange(redis_key, 0, -1).collect(&:to_i)
182
- min_timestamp = ((Time.now.to_f - ttl) * 1000).ceil
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
- 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
193
211
  0.0
194
212
  else
195
- first = redis_client.lindex(redis_key, 0).to_f / 1000.0
196
- delta = Time.now.to_f - first
197
- delta = 0.0 if delta < 0
198
- 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)
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, time_ms, pause_to_recover_arg, amount, (cleanup ? 1 : 0)]
261
+ args: [limit, ttl_ms, pause_to_recover_arg, amount, (cleanup ? 1 : 0)]
225
262
  )
226
263
  end
227
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.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: 2024-02-02 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: []