throttle_machines 0.3.0 → 0.3.3
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/README.md +26 -0
- data/lib/throttle_machines/native_speedup.rb +14 -2
- data/lib/throttle_machines/storage/base.rb +20 -0
- data/lib/throttle_machines/storage/memory.rb +108 -32
- data/lib/throttle_machines/storage/redis/gcra.lua +6 -2
- data/lib/throttle_machines/storage/redis/peek_gcra.lua +6 -2
- data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +9 -3
- data/lib/throttle_machines/storage/redis/token_bucket.lua +7 -3
- data/lib/throttle_machines/storage/redis.rb +21 -22
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a080af0b8e5ed4c52fc3f7df63757a7b8a91beb76124f49ddd6ff06ae02e8072
|
|
4
|
+
data.tar.gz: f4ee5682bb2c846e4ec69364da967127285d186b379d76c2b378bdb99ff92675
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d10328c5affbb67291563d9190c7395b6800c2e9b7085789afcf7e891693c3d00116d5d63dc6bf634eb7b96e6c60e74e65118629702ca7bedc68e7867f90a21
|
|
7
|
+
data.tar.gz: eed2c6323e3ba2f52901ef5280d1fee89ca5e0a8d9f0e316c54ca7151e2870f452a5f59d30096b8d216aa37f4585beb96213ab5190f07d157d8ec31310d1ceb6
|
data/README.md
CHANGED
|
@@ -100,6 +100,32 @@ end
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## Bounded in-memory storage
|
|
104
|
+
|
|
105
|
+
The memory backend tracks at most **65536 entries**, shared across fixed-window
|
|
106
|
+
counters, GCRA states, and token buckets, including when native speedups are loaded.
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
ThrottleMachines.storage = ThrottleMachines::Storage::Memory.new(max_keys: 10_000)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When full, expired entries are reclaimed before admission. If no slot is
|
|
113
|
+
available, new identities are throttled; active identities are never evicted
|
|
114
|
+
to make room and can continue under their existing limits. Peeks also fail
|
|
115
|
+
closed for untrackable identities. Capacity refusals use a one-second retry
|
|
116
|
+
hint, not a reservation. At saturation, on-demand expiry scans run at most
|
|
117
|
+
once per second, even with `auto_cleanup: false`.
|
|
118
|
+
|
|
119
|
+
`max_keys` must be a non-negative integer; zero denies all identities. For
|
|
120
|
+
direct storage callers, an untrackable fixed-window counter reads/increments
|
|
121
|
+
as `Float::INFINITY` so it cannot pass a finite rate limit.
|
|
122
|
+
|
|
123
|
+
This bounds entry count, not bytes: also bound untrusted identity lengths.
|
|
124
|
+
Redis storage uses TTLs but has no application-level key cap; configure Redis
|
|
125
|
+
memory limits separately. Evicting active Redis keys can reset their limits.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
103
129
|
## 🌠 The Ultra-Thin Philosophy
|
|
104
130
|
|
|
105
131
|
Like the best spacecraft, ThrottleMachines follows the principle of **ultra-thin design**:
|
|
@@ -21,6 +21,7 @@ begin
|
|
|
21
21
|
# Native GCRA implementation using Rust.
|
|
22
22
|
module NativeGCRA
|
|
23
23
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
24
|
+
reclaim_capacity
|
|
24
25
|
with_write_lock(key) do
|
|
25
26
|
now = current_time
|
|
26
27
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
@@ -30,15 +31,20 @@ begin
|
|
|
30
31
|
state[:tat], now, emission_interval, delay_tolerance
|
|
31
32
|
) => { allowed:, state: new_tat, retry_after: }
|
|
32
33
|
|
|
33
|
-
@gcra_states
|
|
34
|
+
if allowed && !store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
|
|
35
|
+
return capacity_gcra_refusal(now, delay_tolerance)
|
|
36
|
+
end
|
|
34
37
|
|
|
35
38
|
{ allowed: allowed, retry_after: retry_after, tat: new_tat }
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def peek_gcra_limit(key, _emission_interval, delay_tolerance)
|
|
43
|
+
reclaim_capacity
|
|
40
44
|
with_read_lock(key) do
|
|
41
45
|
now = current_time
|
|
46
|
+
return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
|
|
47
|
+
|
|
42
48
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
43
49
|
|
|
44
50
|
# Rust does the math
|
|
@@ -54,6 +60,7 @@ begin
|
|
|
54
60
|
# Native Token Bucket implementation using Rust.
|
|
55
61
|
module NativeTokenBucket
|
|
56
62
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
63
|
+
reclaim_capacity
|
|
57
64
|
with_write_lock(key) do
|
|
58
65
|
now = current_time
|
|
59
66
|
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
@@ -64,7 +71,9 @@ begin
|
|
|
64
71
|
) => { allowed:, state: new_tokens, retry_after: }
|
|
65
72
|
|
|
66
73
|
if allowed
|
|
67
|
-
@token_buckets
|
|
74
|
+
unless store_entry(@token_buckets, key, { tokens: new_tokens, last_refill: now, expires_at: now + ttl })
|
|
75
|
+
return capacity_token_bucket_refusal
|
|
76
|
+
end
|
|
68
77
|
end
|
|
69
78
|
|
|
70
79
|
{ allowed: allowed, retry_after: retry_after, tokens_remaining: new_tokens.floor }
|
|
@@ -72,8 +81,11 @@ begin
|
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
84
|
+
reclaim_capacity
|
|
75
85
|
with_read_lock(key) do
|
|
76
86
|
now = current_time
|
|
87
|
+
return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
|
|
88
|
+
|
|
77
89
|
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
78
90
|
|
|
79
91
|
# Rust does the math
|
|
@@ -57,8 +57,28 @@ module ThrottleMachines
|
|
|
57
57
|
nil
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Rounding slack for time comparisons, in units of the clock's precision.
|
|
61
|
+
# Matches the native core.
|
|
62
|
+
SLACK_ULPS = 64
|
|
63
|
+
|
|
60
64
|
protected
|
|
61
65
|
|
|
66
|
+
# Times are running sums and differences of float seconds, so an
|
|
67
|
+
# on-schedule arrival can miss an exact boundary by a few ulps. The slack
|
|
68
|
+
# scales with the clock's magnitude.
|
|
69
|
+
def rounding_slack(time)
|
|
70
|
+
Float::EPSILON * SLACK_ULPS * [time.abs, 1.0].max
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def gcra_allowed?(tat, now, delay_tolerance)
|
|
74
|
+
tat - now <= delay_tolerance + rounding_slack(tat)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether a token is available, allowing for rounding in the refill.
|
|
78
|
+
def token_available?(tokens, now, refill_rate)
|
|
79
|
+
tokens >= 1 - (rounding_slack(now) * refill_rate)
|
|
80
|
+
end
|
|
81
|
+
|
|
62
82
|
def current_time
|
|
63
83
|
# Use monotonic time for consistency with BreakerMachines
|
|
64
84
|
ThrottleMachines.monotonic_time
|
|
@@ -5,8 +5,20 @@ require 'concurrent'
|
|
|
5
5
|
module ThrottleMachines
|
|
6
6
|
module Storage
|
|
7
7
|
class Memory < Base
|
|
8
|
+
DEFAULT_MAX_KEYS = 65_536
|
|
9
|
+
CAPACITY_RETRY_AFTER = 1.0
|
|
10
|
+
|
|
8
11
|
def initialize(options = {})
|
|
9
12
|
super
|
|
13
|
+
@max_keys = options.fetch(:max_keys, DEFAULT_MAX_KEYS)
|
|
14
|
+
unless @max_keys.is_a?(Integer) && @max_keys >= 0
|
|
15
|
+
raise ArgumentError, 'max_keys must be a non-negative integer'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@stored_keys = 0
|
|
19
|
+
@capacity_lock = Mutex.new
|
|
20
|
+
@capacity_cleanup_lock = Mutex.new
|
|
21
|
+
@next_capacity_cleanup_at = -Float::INFINITY
|
|
10
22
|
@counters = Concurrent::Hash.new
|
|
11
23
|
@gcra_states = Concurrent::Hash.new
|
|
12
24
|
@token_buckets = Concurrent::Hash.new
|
|
@@ -31,6 +43,7 @@ module ThrottleMachines
|
|
|
31
43
|
# Rate limiting operations
|
|
32
44
|
def increment_counter(key, window, amount = 1)
|
|
33
45
|
window_key = "#{key}:#{window}"
|
|
46
|
+
reclaim_capacity
|
|
34
47
|
|
|
35
48
|
with_write_lock(window_key) do
|
|
36
49
|
now = current_time
|
|
@@ -40,22 +53,24 @@ module ThrottleMachines
|
|
|
40
53
|
if counter.nil? || counter[:expires_at] <= now
|
|
41
54
|
# Create or reset counter atomically
|
|
42
55
|
new_count = amount
|
|
43
|
-
@counters
|
|
56
|
+
stored = store_entry(@counters, window_key, { count: new_count, expires_at: now + window })
|
|
44
57
|
else
|
|
45
58
|
# Increment existing counter atomically
|
|
46
59
|
new_count = counter[:count] + amount
|
|
47
|
-
@counters
|
|
60
|
+
stored = store_entry(@counters, window_key, { count: new_count, expires_at: counter[:expires_at] })
|
|
48
61
|
end
|
|
49
|
-
|
|
62
|
+
# An untrackable counter must never look like an admitted request.
|
|
63
|
+
stored ? new_count : Float::INFINITY
|
|
50
64
|
end
|
|
51
65
|
end
|
|
52
66
|
|
|
53
67
|
def get_counter(key, window)
|
|
54
68
|
window_key = "#{key}:#{window}"
|
|
69
|
+
reclaim_capacity
|
|
55
70
|
|
|
56
71
|
with_read_lock(window_key) do
|
|
57
72
|
counter = @counters[window_key]
|
|
58
|
-
return 0 unless counter
|
|
73
|
+
return capacity_available?(@counters, window_key) ? 0 : Float::INFINITY unless counter
|
|
59
74
|
return 0 if counter[:expires_at] <= current_time
|
|
60
75
|
|
|
61
76
|
counter[:count]
|
|
@@ -67,7 +82,7 @@ module ThrottleMachines
|
|
|
67
82
|
|
|
68
83
|
with_read_lock(window_key) do
|
|
69
84
|
counter = @counters[window_key]
|
|
70
|
-
return 0 unless counter
|
|
85
|
+
return capacity_available?(@counters, window_key) ? 0 : CAPACITY_RETRY_AFTER unless counter
|
|
71
86
|
|
|
72
87
|
ttl = counter[:expires_at] - current_time
|
|
73
88
|
[ttl, 0].max
|
|
@@ -76,21 +91,24 @@ module ThrottleMachines
|
|
|
76
91
|
|
|
77
92
|
def reset_counter(key, window)
|
|
78
93
|
window_key = "#{key}:#{window}"
|
|
79
|
-
with_write_lock(window_key) { @counters
|
|
94
|
+
with_write_lock(window_key) { delete_entry(@counters, window_key) }
|
|
80
95
|
end
|
|
81
96
|
|
|
82
97
|
# GCRA operations (atomic simulation)
|
|
83
98
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
99
|
+
reclaim_capacity
|
|
84
100
|
with_write_lock(key) do
|
|
85
101
|
now = current_time
|
|
86
102
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
87
103
|
|
|
88
104
|
tat = [state[:tat], now].max
|
|
89
|
-
allow = tat
|
|
105
|
+
allow = gcra_allowed?(tat, now, delay_tolerance)
|
|
90
106
|
|
|
91
107
|
if allow
|
|
92
108
|
new_tat = tat + emission_interval
|
|
93
|
-
@gcra_states
|
|
109
|
+
unless store_entry(@gcra_states, key, { tat: new_tat, expires_at: now + ttl })
|
|
110
|
+
return capacity_gcra_refusal(now, delay_tolerance)
|
|
111
|
+
end
|
|
94
112
|
end
|
|
95
113
|
|
|
96
114
|
{
|
|
@@ -102,12 +120,15 @@ module ThrottleMachines
|
|
|
102
120
|
end
|
|
103
121
|
|
|
104
122
|
def peek_gcra_limit(key, _emission_interval, delay_tolerance)
|
|
123
|
+
reclaim_capacity
|
|
105
124
|
with_read_lock(key) do
|
|
106
125
|
now = current_time
|
|
126
|
+
return capacity_gcra_refusal(now, delay_tolerance) unless capacity_available?(@gcra_states, key)
|
|
127
|
+
|
|
107
128
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
108
129
|
|
|
109
130
|
tat = [state[:tat], now].max
|
|
110
|
-
allow = tat
|
|
131
|
+
allow = gcra_allowed?(tat, now, delay_tolerance)
|
|
111
132
|
|
|
112
133
|
{
|
|
113
134
|
allowed: allow,
|
|
@@ -119,6 +140,7 @@ module ThrottleMachines
|
|
|
119
140
|
|
|
120
141
|
# Token bucket operations (atomic simulation)
|
|
121
142
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
143
|
+
reclaim_capacity
|
|
122
144
|
with_write_lock(key) do
|
|
123
145
|
now = current_time
|
|
124
146
|
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
@@ -130,9 +152,11 @@ module ThrottleMachines
|
|
|
130
152
|
bucket[:last_refill] = now
|
|
131
153
|
|
|
132
154
|
# Check if we can consume a token
|
|
133
|
-
if bucket[:tokens]
|
|
134
|
-
bucket[:tokens]
|
|
135
|
-
@token_buckets
|
|
155
|
+
if token_available?(bucket[:tokens], now, refill_rate)
|
|
156
|
+
bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
|
|
157
|
+
unless store_entry(@token_buckets, key, bucket.merge(expires_at: now + ttl))
|
|
158
|
+
return capacity_token_bucket_refusal
|
|
159
|
+
end
|
|
136
160
|
|
|
137
161
|
{
|
|
138
162
|
allowed: true,
|
|
@@ -152,8 +176,11 @@ module ThrottleMachines
|
|
|
152
176
|
end
|
|
153
177
|
|
|
154
178
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
179
|
+
reclaim_capacity
|
|
155
180
|
with_read_lock(key) do
|
|
156
181
|
now = current_time
|
|
182
|
+
return capacity_token_bucket_refusal unless capacity_available?(@token_buckets, key)
|
|
183
|
+
|
|
157
184
|
bucket = @token_buckets[key] || { tokens: capacity, last_refill: now }
|
|
158
185
|
|
|
159
186
|
# Calculate tokens without modifying state
|
|
@@ -161,11 +188,11 @@ module ThrottleMachines
|
|
|
161
188
|
tokens_to_add = elapsed * refill_rate
|
|
162
189
|
current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
|
|
163
190
|
|
|
164
|
-
if current_tokens
|
|
191
|
+
if token_available?(current_tokens, now, refill_rate)
|
|
165
192
|
{
|
|
166
193
|
allowed: true,
|
|
167
194
|
retry_after: 0,
|
|
168
|
-
tokens_remaining:
|
|
195
|
+
tokens_remaining: [current_tokens - 1, 0].max.floor
|
|
169
196
|
}
|
|
170
197
|
else
|
|
171
198
|
retry_after = (1 - current_tokens) / refill_rate
|
|
@@ -188,14 +215,17 @@ module ThrottleMachines
|
|
|
188
215
|
|
|
189
216
|
# Clear matching keys from all stores
|
|
190
217
|
[@counters, @gcra_states, @token_buckets].each do |store|
|
|
191
|
-
store.
|
|
192
|
-
|
|
218
|
+
store.keys.each do |k|
|
|
219
|
+
with_write_lock(k) { delete_entry(store, k) } if k&.match?(regex)
|
|
193
220
|
end
|
|
194
221
|
end
|
|
195
222
|
else
|
|
196
|
-
@
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
@capacity_lock.synchronize do
|
|
224
|
+
@counters.clear
|
|
225
|
+
@gcra_states.clear
|
|
226
|
+
@token_buckets.clear
|
|
227
|
+
@stored_keys = 0
|
|
228
|
+
end
|
|
199
229
|
end
|
|
200
230
|
end
|
|
201
231
|
|
|
@@ -212,6 +242,57 @@ module ThrottleMachines
|
|
|
212
242
|
|
|
213
243
|
private
|
|
214
244
|
|
|
245
|
+
# Called with the key's write lock held. The short admission lock makes
|
|
246
|
+
# the cap atomic across stripes and all three stores.
|
|
247
|
+
def store_entry(store, key, value)
|
|
248
|
+
@capacity_lock.synchronize do
|
|
249
|
+
new_key = !store.key?(key)
|
|
250
|
+
return false if new_key && @stored_keys >= @max_keys
|
|
251
|
+
|
|
252
|
+
store[key] = value
|
|
253
|
+
@stored_keys += 1 if new_key
|
|
254
|
+
true
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def delete_entry(store, key)
|
|
259
|
+
@capacity_lock.synchronize do
|
|
260
|
+
value = store.delete(key)
|
|
261
|
+
@stored_keys -= 1 if value
|
|
262
|
+
value
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def capacity_available?(store, key)
|
|
267
|
+
@capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def capacity_gcra_refusal(now, delay_tolerance)
|
|
271
|
+
{ allowed: false, retry_after: CAPACITY_RETRY_AFTER, tat: now + delay_tolerance + CAPACITY_RETRY_AFTER }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def capacity_token_bucket_refusal
|
|
275
|
+
{ allowed: false, retry_after: CAPACITY_RETRY_AFTER, tokens_remaining: 0 }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Run before taking any striped lock. At saturation, attempt expiry
|
|
279
|
+
# cleanup at most once per second, even with auto_cleanup disabled.
|
|
280
|
+
# Rejected identity sprays must not trigger a full scan on every call.
|
|
281
|
+
def reclaim_capacity
|
|
282
|
+
return unless @capacity_lock.synchronize { @stored_keys >= @max_keys }
|
|
283
|
+
return unless @capacity_cleanup_lock.try_lock
|
|
284
|
+
|
|
285
|
+
begin
|
|
286
|
+
now = current_time
|
|
287
|
+
return if now < @next_capacity_cleanup_at
|
|
288
|
+
|
|
289
|
+
@next_capacity_cleanup_at = now + CAPACITY_RETRY_AFTER
|
|
290
|
+
clean_expired_entries
|
|
291
|
+
ensure
|
|
292
|
+
@capacity_cleanup_lock.unlock
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
215
296
|
def with_read_lock(key, &block)
|
|
216
297
|
lock_for(key).with_read_lock(&block)
|
|
217
298
|
end
|
|
@@ -242,19 +323,14 @@ module ThrottleMachines
|
|
|
242
323
|
def clean_expired_entries
|
|
243
324
|
now = current_time
|
|
244
325
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Clean expired token buckets
|
|
256
|
-
@token_buckets.each_pair do |key, data|
|
|
257
|
-
with_write_lock(key) { @token_buckets.delete(key) } if data[:expires_at] && data[:expires_at] <= now
|
|
326
|
+
[@counters, @gcra_states, @token_buckets].each do |store|
|
|
327
|
+
store.keys.each do |key|
|
|
328
|
+
with_write_lock(key) do
|
|
329
|
+
# Re-read under the lock: a writer may have refreshed the TTL.
|
|
330
|
+
data = store[key]
|
|
331
|
+
delete_entry(store, key) if data && data[:expires_at] && data[:expires_at] <= now
|
|
332
|
+
end
|
|
333
|
+
end
|
|
258
334
|
end
|
|
259
335
|
|
|
260
336
|
# No breaker state cleanup: breaker state is managed by BreakerMachines
|
|
@@ -12,11 +12,15 @@ else
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
tat = math.max(tat, now)
|
|
15
|
-
|
|
15
|
+
-- The TAT is a running sum of emission intervals, so an on-schedule arrival
|
|
16
|
+
-- can overshoot the tolerance by a few ulps; the slack scales with the clock.
|
|
17
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
|
|
18
|
+
local allow = (tat - now) <= delay_tolerance + slack
|
|
16
19
|
|
|
17
20
|
if allow then
|
|
18
21
|
local new_tat = tat + emission_interval
|
|
19
22
|
redis.call('SET', key, new_tat, 'EX', ttl)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
-- Redis truncates Lua numbers in replies to integers; send the TAT as text.
|
|
26
|
+
return { allow and 1 or 0, string.format('%.17g', tat) }
|
|
@@ -11,6 +11,10 @@ else
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
tat = math.max(tat, now)
|
|
14
|
-
|
|
14
|
+
-- The TAT is a running sum of emission intervals, so an on-schedule arrival
|
|
15
|
+
-- can overshoot the tolerance by a few ulps; the slack scales with the clock.
|
|
16
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
|
|
17
|
+
local allow = (tat - now) <= delay_tolerance + slack
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
-- Redis truncates Lua numbers in replies to integers; send the TAT as text.
|
|
20
|
+
return { allow and 1 or 0, string.format('%.17g', tat) }
|
|
@@ -12,7 +12,13 @@ local elapsed = now - last_refill
|
|
|
12
12
|
local tokens_to_add = elapsed * refill_rate
|
|
13
13
|
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
-- `elapsed` carries the clock's rounding; a token due exactly now may come
|
|
16
|
+
-- out a hair short of 1. The slack scales with the clock, as in the native core.
|
|
17
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
|
|
18
|
+
local allow = tokens >= 1 - slack
|
|
19
|
+
-- Admitted: tokens left after this request. Refused: the fractional balance,
|
|
20
|
+
-- which retry_after is computed from.
|
|
21
|
+
local tokens_after = allow and math.max(tokens - 1, 0) or tokens
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
-- Redis truncates Lua numbers in replies to integers; send the count as text.
|
|
24
|
+
return { allow and 1 or 0, string.format('%.17g', tokens_after) }
|
|
@@ -13,11 +13,15 @@ local elapsed = now - last_refill
|
|
|
13
13
|
local tokens_to_add = elapsed * refill_rate
|
|
14
14
|
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
-- `elapsed` carries the clock's rounding; a token due exactly now may come
|
|
17
|
+
-- out a hair short of 1. The slack scales with the clock, as in the native core.
|
|
18
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
|
|
19
|
+
local allow = tokens >= 1 - slack
|
|
17
20
|
if allow then
|
|
18
|
-
tokens = tokens - 1
|
|
21
|
+
tokens = math.max(tokens - 1, 0)
|
|
19
22
|
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
|
20
23
|
redis.call('EXPIRE', key, ttl)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
-- Redis truncates Lua numbers in replies to integers; send the count as text.
|
|
27
|
+
return { allow and 1 or 0, string.format('%.17g', tokens) }
|
|
@@ -59,13 +59,13 @@ module ThrottleMachines
|
|
|
59
59
|
|
|
60
60
|
# GCRA operations (atomic via Lua)
|
|
61
61
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
62
|
+
now = current_time
|
|
62
63
|
result = run_script(GCRA_SCRIPT, GCRA_SHA,
|
|
63
64
|
keys: [prefixed(key)],
|
|
64
|
-
argv: [emission_interval, delay_tolerance, ttl,
|
|
65
|
+
argv: [emission_interval, delay_tolerance, ttl, now])
|
|
65
66
|
|
|
66
67
|
allowed = result[0] == 1
|
|
67
|
-
tat = result[1]
|
|
68
|
-
now = current_time
|
|
68
|
+
tat = Float(result[1])
|
|
69
69
|
|
|
70
70
|
{
|
|
71
71
|
allowed: allowed,
|
|
@@ -80,25 +80,18 @@ module ThrottleMachines
|
|
|
80
80
|
keys: [prefixed(key)],
|
|
81
81
|
argv: [capacity, refill_rate, ttl, current_time])
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
tokens = result[1]
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
allowed: allowed,
|
|
88
|
-
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
|
89
|
-
tokens_remaining: tokens.floor
|
|
90
|
-
}
|
|
83
|
+
token_bucket_result(result, refill_rate)
|
|
91
84
|
end
|
|
92
85
|
|
|
93
86
|
# Peek methods for non-consuming checks
|
|
94
87
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
88
|
+
now = current_time
|
|
95
89
|
result = run_script(PEEK_GCRA_SCRIPT, PEEK_GCRA_SHA,
|
|
96
90
|
keys: [prefixed(key)],
|
|
97
|
-
argv: [emission_interval, delay_tolerance,
|
|
91
|
+
argv: [emission_interval, delay_tolerance, now])
|
|
98
92
|
|
|
99
93
|
allowed = result[0] == 1
|
|
100
|
-
tat = result[1]
|
|
101
|
-
now = current_time
|
|
94
|
+
tat = Float(result[1])
|
|
102
95
|
|
|
103
96
|
{
|
|
104
97
|
allowed: allowed,
|
|
@@ -112,14 +105,7 @@ module ThrottleMachines
|
|
|
112
105
|
keys: [prefixed(key)],
|
|
113
106
|
argv: [capacity, refill_rate, current_time])
|
|
114
107
|
|
|
115
|
-
|
|
116
|
-
tokens_remaining = result[1]
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
allowed: allowed,
|
|
120
|
-
retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
|
|
121
|
-
tokens_remaining: tokens_remaining.floor
|
|
122
|
-
}
|
|
108
|
+
token_bucket_result(result, refill_rate)
|
|
123
109
|
end
|
|
124
110
|
|
|
125
111
|
# No circuit breaker operations: breaker state is owned by BreakerMachines
|
|
@@ -147,6 +133,19 @@ module ThrottleMachines
|
|
|
147
133
|
|
|
148
134
|
private
|
|
149
135
|
|
|
136
|
+
# `result` is [allowed, tokens]: the balance after an admission, or the
|
|
137
|
+
# fractional balance a refusal waits on. Matches Storage::Memory.
|
|
138
|
+
def token_bucket_result(result, refill_rate)
|
|
139
|
+
allowed = result[0] == 1
|
|
140
|
+
tokens = Float(result[1])
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
allowed: allowed,
|
|
144
|
+
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
|
145
|
+
tokens_remaining: allowed ? tokens.floor : 0
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
150
149
|
def prefixed(key)
|
|
151
150
|
"#{@prefix}#{key}"
|
|
152
151
|
end
|
data/lib/throttle_machines.rb
CHANGED
|
@@ -12,6 +12,7 @@ require 'breaker_machines'
|
|
|
12
12
|
# Set up Zeitwerk loader
|
|
13
13
|
loader = Zeitwerk::Loader.for_gem
|
|
14
14
|
loader.ignore("#{__dir__}/throttle_machines/engine.rb") unless defined?(Rails::Engine)
|
|
15
|
+
loader.ignore("#{__dir__}/throttle_machines/native_speedup.rb")
|
|
15
16
|
loader.setup
|
|
16
17
|
|
|
17
18
|
module ThrottleMachines
|