throttle_machines 0.3.1-aarch64-linux → 0.3.4-aarch64-linux
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 +54 -0
- data/lib/throttle_machines/limiter.rb +13 -0
- data/lib/throttle_machines/native_speedup.rb +14 -2
- data/lib/throttle_machines/storage/base.rb +28 -10
- data/lib/throttle_machines/storage/cache.rb +73 -0
- data/lib/throttle_machines/storage/memory.rb +102 -26
- data/lib/throttle_machines/unsupported_operation_error.rb +5 -0
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +3 -0
- data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf82a5943916b99c3c670f1a40f8b56846dd2271d0b1e52ac8d360b9f179980b
|
|
4
|
+
data.tar.gz: 27fac49e6a413ee51444304bdfaa048f992bb2ef6c28a5a4b06b2d9ac956bd94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c797ad1427d795c2003376798713b0c7ba9af1b63b3d927931e617a626b0fee4aed5febaac367f2ebf15d8fd5b4f7fa2f35e42f7df1bab64903c90c527ab180e
|
|
7
|
+
data.tar.gz: f6f38cccc1f50c4cac5b9ba62167e51daf99ce5fe09b18517a63d1f06563426ec35bfbb43780b981b19bda526795ba92628dad3edfbe09ccd5f7fd11d6c86403
|
data/README.md
CHANGED
|
@@ -100,6 +100,60 @@ 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
|
+
|
|
129
|
+
## Rails cache storage
|
|
130
|
+
|
|
131
|
+
`Storage::Cache` keeps fixed-window counters in any `ActiveSupport::Cache`
|
|
132
|
+
store, so limits share Solid Cache, Redis, or Memcached with the rest of the app
|
|
133
|
+
(and with `BreakerMachines::Storage::Cache`).
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
ThrottleMachines.storage = ThrottleMachines::Storage::Cache.new(cache_store: Rails.cache)
|
|
137
|
+
# or, inside Rails: config.default_storage = :cache
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Windows align to the Unix epoch and each one is its own cache entry, admitted
|
|
141
|
+
with a single atomic `increment`. On Solid Cache with PostgreSQL, the first
|
|
142
|
+
increments of a window can race and undercount by the number of concurrent
|
|
143
|
+
first requests.
|
|
144
|
+
|
|
145
|
+
It supports `:fixed_window` only; the cache API has no atomic read-modify-write
|
|
146
|
+
for GCRA or token buckets. A limiter built with an algorithm its storage lacks
|
|
147
|
+
raises `ArgumentError` on construction, for custom backends too.
|
|
148
|
+
|
|
149
|
+
When the store fails to count (an outage, `NullStore`), requests are admitted.
|
|
150
|
+
Pass `fail_closed: true` to throttle them instead. `clear` deletes only this
|
|
151
|
+
backend's keys and needs `delete_matched`; on Solid Cache and Memcached it
|
|
152
|
+
raises `ThrottleMachines::UnsupportedOperationError`, so reset single limiters
|
|
153
|
+
with `Limiter#reset!`.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
103
157
|
## 🌠 The Ultra-Thin Philosophy
|
|
104
158
|
|
|
105
159
|
Like the best spacecraft, ThrottleMachines follows the principle of **ultra-thin design**:
|
|
@@ -10,6 +10,7 @@ module ThrottleMachines
|
|
|
10
10
|
@period = period
|
|
11
11
|
@algorithm = algorithm
|
|
12
12
|
@storage = storage || ThrottleMachines.storage
|
|
13
|
+
validate_algorithm!
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def allow?
|
|
@@ -163,5 +164,17 @@ module ThrottleMachines
|
|
|
163
164
|
retry_after: retry_after
|
|
164
165
|
}
|
|
165
166
|
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def validate_algorithm!
|
|
171
|
+
unless Storage::Base::ALGORITHM_OPERATIONS.key?(@algorithm)
|
|
172
|
+
raise ArgumentError, "Unknown algorithm: #{@algorithm.inspect}"
|
|
173
|
+
end
|
|
174
|
+
return unless @storage.respond_to?(:supports?)
|
|
175
|
+
return if @storage.supports?(@algorithm)
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "#{@storage.class} does not support the #{@algorithm.inspect} algorithm"
|
|
178
|
+
end
|
|
166
179
|
end
|
|
167
180
|
end
|
|
@@ -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
|
|
@@ -3,52 +3,70 @@
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
module Storage
|
|
5
5
|
class Base
|
|
6
|
+
ALGORITHM_OPERATIONS = {
|
|
7
|
+
fixed_window: %i[increment_counter get_counter get_counter_ttl reset_counter],
|
|
8
|
+
gcra: %i[check_gcra_limit peek_gcra_limit],
|
|
9
|
+
token_bucket: %i[check_token_bucket peek_token_bucket]
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
HEALTH_KEY = 'throttle_machines:health'
|
|
13
|
+
|
|
6
14
|
def initialize(options = {})
|
|
7
15
|
@options = options
|
|
8
16
|
end
|
|
9
17
|
|
|
18
|
+
def supports?(algorithm)
|
|
19
|
+
operations = ALGORITHM_OPERATIONS[algorithm]
|
|
20
|
+
return false unless operations
|
|
21
|
+
|
|
22
|
+
operations.all? { |operation| self.class.instance_method(operation).owner != Base }
|
|
23
|
+
end
|
|
24
|
+
|
|
10
25
|
# Rate limiting operations
|
|
11
26
|
def increment_counter(key, window, amount = 1)
|
|
12
|
-
raise NotImplementedError
|
|
27
|
+
raise NotImplementedError, "#{self.class} does not implement #increment_counter"
|
|
13
28
|
end
|
|
14
29
|
|
|
15
30
|
def get_counter(key, window)
|
|
16
|
-
raise NotImplementedError
|
|
31
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter"
|
|
17
32
|
end
|
|
18
33
|
|
|
19
34
|
def get_counter_ttl(key, window)
|
|
20
|
-
raise NotImplementedError
|
|
35
|
+
raise NotImplementedError, "#{self.class} does not implement #get_counter_ttl"
|
|
21
36
|
end
|
|
22
37
|
|
|
23
38
|
def reset_counter(key, window)
|
|
24
|
-
raise NotImplementedError
|
|
39
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_counter"
|
|
25
40
|
end
|
|
26
41
|
|
|
27
42
|
# GCRA operations (atomic)
|
|
28
43
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
29
|
-
raise NotImplementedError
|
|
44
|
+
raise NotImplementedError, "#{self.class} does not implement #check_gcra_limit"
|
|
30
45
|
end
|
|
31
46
|
|
|
32
47
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
33
|
-
raise NotImplementedError
|
|
48
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_gcra_limit"
|
|
34
49
|
end
|
|
35
50
|
|
|
36
51
|
# Token bucket operations (atomic)
|
|
37
52
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
38
|
-
raise NotImplementedError
|
|
53
|
+
raise NotImplementedError, "#{self.class} does not implement #check_token_bucket"
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
42
|
-
raise NotImplementedError
|
|
57
|
+
raise NotImplementedError, "#{self.class} does not implement #peek_token_bucket"
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# Utility operations
|
|
46
61
|
def clear(pattern = nil)
|
|
47
|
-
raise NotImplementedError
|
|
62
|
+
raise NotImplementedError, "#{self.class} does not implement #clear"
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
def healthy?
|
|
51
|
-
|
|
66
|
+
get_counter(HEALTH_KEY, 1)
|
|
67
|
+
true
|
|
68
|
+
rescue StandardError
|
|
69
|
+
false
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
def with_timeout(timeout, &block)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThrottleMachines
|
|
4
|
+
module Storage
|
|
5
|
+
class Cache < Base
|
|
6
|
+
GLOB_SPECIALS = /[?\[\]\\]/
|
|
7
|
+
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
super
|
|
10
|
+
@cache = options[:cache_store] || (defined?(Rails) && Rails.cache)
|
|
11
|
+
raise ArgumentError, 'Cache storage requires a cache_store outside Rails' unless @cache
|
|
12
|
+
|
|
13
|
+
@prefix = options[:prefix] || 'throttle_machines'
|
|
14
|
+
@fail_closed = options.fetch(:fail_closed, false)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def increment_counter(key, window, amount = 1)
|
|
18
|
+
count = @cache.increment(window_key(key, window), amount, expires_in: window.to_f.ceil + 1)
|
|
19
|
+
count || (@fail_closed ? Float::INFINITY : 0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get_counter(key, window)
|
|
23
|
+
@cache.read(window_key(key, window), raw: true).to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_counter_ttl(_key, window)
|
|
27
|
+
window = positive_window(window)
|
|
28
|
+
window - (wall_time % window)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_counter(key, window)
|
|
32
|
+
@cache.delete(window_key(key, window))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear(pattern = nil)
|
|
36
|
+
@cache.delete_matched(matcher_for("#{@prefix}:#{pattern || '*'}"))
|
|
37
|
+
rescue NotImplementedError
|
|
38
|
+
raise UnsupportedOperationError,
|
|
39
|
+
"#{@cache.class} cannot delete by pattern; reset limiters individually with Limiter#reset!"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def healthy?
|
|
43
|
+
!@cache.increment("#{@prefix}:health", 1, expires_in: 60).nil?
|
|
44
|
+
rescue StandardError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def window_key(key, window)
|
|
51
|
+
window = positive_window(window)
|
|
52
|
+
"#{@prefix}:#{key}:#{window}:#{(wall_time / window).floor}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def positive_window(window)
|
|
56
|
+
window = window.to_f
|
|
57
|
+
raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
|
|
58
|
+
|
|
59
|
+
window
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matcher_for(glob)
|
|
63
|
+
return glob.gsub(GLOB_SPECIALS) { |char| "\\#{char}" } if @cache.respond_to?(:redis)
|
|
64
|
+
|
|
65
|
+
Regexp.new("^#{Regexp.escape(glob).gsub('\*', '.*')}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def wall_time
|
|
69
|
+
Time.now.to_f
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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,11 +91,12 @@ 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 }
|
|
@@ -90,7 +106,9 @@ module ThrottleMachines
|
|
|
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,8 +120,11 @@ 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
|
|
@@ -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 }
|
|
@@ -132,7 +154,9 @@ module ThrottleMachines
|
|
|
132
154
|
# Check if we can consume a token
|
|
133
155
|
if token_available?(bucket[:tokens], now, refill_rate)
|
|
134
156
|
bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
|
|
135
|
-
@token_buckets
|
|
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
|
|
@@ -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
|
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
|
|
@@ -158,6 +159,8 @@ module ThrottleMachines
|
|
|
158
159
|
case symbol
|
|
159
160
|
when :memory
|
|
160
161
|
Storage::Memory.new
|
|
162
|
+
when :cache
|
|
163
|
+
Storage::Cache.new
|
|
161
164
|
when :redis
|
|
162
165
|
raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
|
|
163
166
|
|
|
Binary file
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: throttle_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.4
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -70,16 +70,16 @@ dependencies:
|
|
|
70
70
|
name: breaker_machines
|
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
|
72
72
|
requirements:
|
|
73
|
-
- - "
|
|
73
|
+
- - ">="
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version:
|
|
75
|
+
version: 0.22.1
|
|
76
76
|
type: :runtime
|
|
77
77
|
prerelease: false
|
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
79
|
requirements:
|
|
80
|
-
- - "
|
|
80
|
+
- - ">="
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
|
-
version:
|
|
82
|
+
version: 0.22.1
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
84
|
name: chrono_machines
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,6 +172,7 @@ files:
|
|
|
172
172
|
- lib/throttle_machines/rack_middleware/throttle.rb
|
|
173
173
|
- lib/throttle_machines/rack_middleware/track.rb
|
|
174
174
|
- lib/throttle_machines/storage/base.rb
|
|
175
|
+
- lib/throttle_machines/storage/cache.rb
|
|
175
176
|
- lib/throttle_machines/storage/memory.rb
|
|
176
177
|
- lib/throttle_machines/storage/null.rb
|
|
177
178
|
- lib/throttle_machines/storage/redis.rb
|
|
@@ -181,6 +182,7 @@ files:
|
|
|
181
182
|
- lib/throttle_machines/storage/redis/peek_token_bucket.lua
|
|
182
183
|
- lib/throttle_machines/storage/redis/token_bucket.lua
|
|
183
184
|
- lib/throttle_machines/throttled_error.rb
|
|
185
|
+
- lib/throttle_machines/unsupported_operation_error.rb
|
|
184
186
|
- lib/throttle_machines/version.rb
|
|
185
187
|
- lib/throttle_machines_native/throttle_machines_native.so
|
|
186
188
|
homepage: https://github.com/seuros/throttle_machines
|