throttle_machines 0.3.4-x86_64-linux → 0.4.1-x86_64-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/lib/throttle_machines/async_support.rb +16 -17
- data/lib/throttle_machines/control.rb +3 -2
- data/lib/throttle_machines/hedged_request.rb +2 -5
- data/lib/throttle_machines/limiter.rb +10 -2
- data/lib/throttle_machines/storage/base.rb +35 -5
- data/lib/throttle_machines/storage/cache.rb +25 -7
- data/lib/throttle_machines/storage/memory.rb +164 -84
- data/lib/throttle_machines/storage/null.rb +12 -0
- data/lib/throttle_machines/storage/redis.rb +35 -10
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines.rb +6 -8
- data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
- metadata +22 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9845ae006c5ed94c11b0caf7f711429e87cc940d87b5a4e0cb9f44b2c87ebde
|
|
4
|
+
data.tar.gz: 1db546c88542f9292505622fc38a5834adf33516ecd93a4ae1277aebc9e4e340
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b792e326672faffe29cda49e1cf7f7e3ebb285136a3708f1faef0377550762cdc4c502a175e1d9db97d6d8ca88502f35a9b6c00eb9b4409b07ffc08b0f0f9252
|
|
7
|
+
data.tar.gz: 12153978d4b5293e91e50dfc3565cb83094e7d281b8e7fcd74eff8873b7f3eddfa11dd5362f911d29fdc3bcb2c085b6cf230e57c138bd0b89756cb4840c0ac8f
|
|
@@ -22,26 +22,25 @@ module ThrottleMachines
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def throttle_async(max_wait: nil, &block)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
caller_budget = DeadlineMachines.current
|
|
26
|
+
|
|
27
|
+
DeadlineMachines.with_deadline(max_wait) do |budget|
|
|
28
|
+
loop do
|
|
29
|
+
begin
|
|
30
|
+
throttle!
|
|
31
|
+
rescue ThrottledError => e
|
|
32
|
+
caller_budget&.check!
|
|
33
|
+
wait_time = [e.retry_after.to_f, 0.0].max
|
|
34
|
+
raise if budget.clamp_sleep(wait_time) < wait_time
|
|
35
|
+
|
|
36
|
+
sleep_for(wait_time)
|
|
37
|
+
next
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
next
|
|
40
|
-
end
|
|
40
|
+
return block.call if block
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return true
|
|
42
|
+
return true
|
|
43
|
+
end
|
|
45
44
|
end
|
|
46
45
|
end
|
|
47
46
|
|
|
@@ -27,7 +27,7 @@ module ThrottleMachines
|
|
|
27
27
|
def retry_on_failure(times:, backoff: :exponential, base_delay: 1, max_delay: 60)
|
|
28
28
|
@rules[:retry] = {
|
|
29
29
|
max_attempts: times,
|
|
30
|
-
|
|
30
|
+
backoff: backoff,
|
|
31
31
|
base_delay: base_delay,
|
|
32
32
|
max_delay: max_delay
|
|
33
33
|
}
|
|
@@ -87,7 +87,8 @@ module ThrottleMachines
|
|
|
87
87
|
max_attempts: @rules[:retry][:max_attempts],
|
|
88
88
|
base_delay: @rules[:retry][:base_delay],
|
|
89
89
|
max_delay: @rules[:retry][:max_delay],
|
|
90
|
-
|
|
90
|
+
backoff_strategy: @rules[:retry][:backoff],
|
|
91
|
+
jitter_factor: @rules[:retry][:backoff] == :exponential ? 1.0 : 0.0
|
|
91
92
|
}
|
|
92
93
|
@retrier = ChronoMachines::Executor.new(policy_options)
|
|
93
94
|
end
|
|
@@ -56,9 +56,9 @@ module ThrottleMachines
|
|
|
56
56
|
@max_attempts.times do |attempt|
|
|
57
57
|
# Schedule with delay
|
|
58
58
|
future = if attempt.zero?
|
|
59
|
-
Concurrent::Promises.
|
|
59
|
+
Concurrent::Promises.future_on(@executor) { yield(attempt) }
|
|
60
60
|
else
|
|
61
|
-
Concurrent::Promises.
|
|
61
|
+
Concurrent::Promises.schedule_on(@executor, @delay * attempt) { yield(attempt) }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Race to resolve first_result
|
|
@@ -88,9 +88,6 @@ module ThrottleMachines
|
|
|
88
88
|
else
|
|
89
89
|
first_result.value!
|
|
90
90
|
end
|
|
91
|
-
ensure
|
|
92
|
-
# Cancel pending futures
|
|
93
|
-
futures.each { |f| f.cancel if f.pending? }
|
|
94
91
|
end
|
|
95
92
|
|
|
96
93
|
# Run async version
|
|
@@ -11,6 +11,7 @@ module ThrottleMachines
|
|
|
11
11
|
@algorithm = algorithm
|
|
12
12
|
@storage = storage || ThrottleMachines.storage
|
|
13
13
|
validate_algorithm!
|
|
14
|
+
validate_rate!
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def allow?
|
|
@@ -94,8 +95,10 @@ module ThrottleMachines
|
|
|
94
95
|
case @algorithm
|
|
95
96
|
when :fixed_window
|
|
96
97
|
@storage.reset_counter(@key, @period)
|
|
97
|
-
|
|
98
|
-
@storage.
|
|
98
|
+
when :gcra
|
|
99
|
+
@storage.reset_gcra(@key)
|
|
100
|
+
when :token_bucket
|
|
101
|
+
@storage.reset_token_bucket(@key)
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
|
|
@@ -167,6 +170,11 @@ module ThrottleMachines
|
|
|
167
170
|
|
|
168
171
|
private
|
|
169
172
|
|
|
173
|
+
def validate_rate!
|
|
174
|
+
raise ArgumentError, "limit must be positive, got #{@limit.inspect}" unless @limit.is_a?(Numeric) && @limit.positive?
|
|
175
|
+
raise ArgumentError, "period must be positive, got #{@period.inspect}" unless @period.is_a?(Numeric) && @period.positive?
|
|
176
|
+
end
|
|
177
|
+
|
|
170
178
|
def validate_algorithm!
|
|
171
179
|
unless Storage::Base::ALGORITHM_OPERATIONS.key?(@algorithm)
|
|
172
180
|
raise ArgumentError, "Unknown algorithm: #{@algorithm.inspect}"
|
|
@@ -5,12 +5,18 @@ module ThrottleMachines
|
|
|
5
5
|
class Base
|
|
6
6
|
ALGORITHM_OPERATIONS = {
|
|
7
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]
|
|
8
|
+
gcra: %i[check_gcra_limit peek_gcra_limit reset_gcra],
|
|
9
|
+
token_bucket: %i[check_token_bucket peek_token_bucket reset_token_bucket]
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
12
|
HEALTH_KEY = 'throttle_machines:health'
|
|
13
13
|
|
|
14
|
+
# What a backend puts between a limiter's key and the state it appends.
|
|
15
|
+
KEY_SEPARATOR = ':'
|
|
16
|
+
|
|
17
|
+
GLOB_WILDCARD = '*'
|
|
18
|
+
GLOB_SPECIALS = /[*?\[\]\\]/
|
|
19
|
+
|
|
14
20
|
def initialize(options = {})
|
|
15
21
|
@options = options
|
|
16
22
|
end
|
|
@@ -48,6 +54,10 @@ module ThrottleMachines
|
|
|
48
54
|
raise NotImplementedError, "#{self.class} does not implement #peek_gcra_limit"
|
|
49
55
|
end
|
|
50
56
|
|
|
57
|
+
def reset_gcra(key)
|
|
58
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_gcra"
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
# Token bucket operations (atomic)
|
|
52
62
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
53
63
|
raise NotImplementedError, "#{self.class} does not implement #check_token_bucket"
|
|
@@ -57,11 +67,20 @@ module ThrottleMachines
|
|
|
57
67
|
raise NotImplementedError, "#{self.class} does not implement #peek_token_bucket"
|
|
58
68
|
end
|
|
59
69
|
|
|
70
|
+
def reset_token_bucket(key)
|
|
71
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_token_bucket"
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
# Utility operations
|
|
61
75
|
def clear(pattern = nil)
|
|
62
76
|
raise NotImplementedError, "#{self.class} does not implement #clear"
|
|
63
77
|
end
|
|
64
78
|
|
|
79
|
+
# Deletes every entry this key owns, whatever algorithm wrote it.
|
|
80
|
+
def reset_key(key)
|
|
81
|
+
raise NotImplementedError, "#{self.class} does not implement #reset_key"
|
|
82
|
+
end
|
|
83
|
+
|
|
65
84
|
def healthy?
|
|
66
85
|
get_counter(HEALTH_KEY, 1)
|
|
67
86
|
true
|
|
@@ -70,9 +89,8 @@ module ThrottleMachines
|
|
|
70
89
|
end
|
|
71
90
|
|
|
72
91
|
def with_timeout(timeout, &block)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
nil
|
|
92
|
+
DeadlineMachines.check!
|
|
93
|
+
Timeout.timeout(DeadlineMachines.clamp_sleep(timeout), &block)
|
|
76
94
|
end
|
|
77
95
|
|
|
78
96
|
# Rounding slack for time comparisons, in units of the clock's precision.
|
|
@@ -81,6 +99,18 @@ module ThrottleMachines
|
|
|
81
99
|
|
|
82
100
|
protected
|
|
83
101
|
|
|
102
|
+
def owned_by?(stored_key, key)
|
|
103
|
+
stored_key == key || stored_key.start_with?("#{key}#{KEY_SEPARATOR}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def escape_glob(literal)
|
|
107
|
+
literal.gsub(GLOB_SPECIALS) { |char| "\\#{char}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def glob_to_regexp(glob)
|
|
111
|
+
Regexp.new("\\A#{Regexp.escape(glob).gsub(Regexp.escape(GLOB_WILDCARD), '.*')}\\z")
|
|
112
|
+
end
|
|
113
|
+
|
|
84
114
|
# Times are running sums and differences of float seconds, so an
|
|
85
115
|
# on-schedule arrival can miss an exact boundary by a few ulps. The slack
|
|
86
116
|
# scales with the clock's magnitude.
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module ThrottleMachines
|
|
4
4
|
module Storage
|
|
5
5
|
class Cache < Base
|
|
6
|
-
|
|
6
|
+
# The wildcard a caller's pattern keeps; everything else is a literal.
|
|
7
|
+
PATTERN_SPECIALS = /[?\[\]\\]/
|
|
7
8
|
|
|
8
9
|
def initialize(options = {})
|
|
9
10
|
super
|
|
@@ -33,10 +34,13 @@ module ThrottleMachines
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def clear(pattern = nil)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
delete_matched(matcher_for("#{@prefix}#{KEY_SEPARATOR}#{pattern || GLOB_WILDCARD}"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The window and the bucket index a counter was written under are unknown
|
|
41
|
+
# here, so the counters go by a prefix of the key's own namespace.
|
|
42
|
+
def reset_key(key)
|
|
43
|
+
delete_matched(prefix_matcher("#{@prefix}#{KEY_SEPARATOR}#{key}#{KEY_SEPARATOR}"))
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def healthy?
|
|
@@ -59,10 +63,24 @@ module ThrottleMachines
|
|
|
59
63
|
window
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
def delete_matched(matcher)
|
|
67
|
+
@cache.delete_matched(matcher)
|
|
68
|
+
rescue NotImplementedError
|
|
69
|
+
raise UnsupportedOperationError,
|
|
70
|
+
"#{@cache.class} cannot delete by pattern; reset limiters individually with Limiter#reset!"
|
|
71
|
+
end
|
|
72
|
+
|
|
62
73
|
def matcher_for(glob)
|
|
63
|
-
return glob.gsub(
|
|
74
|
+
return glob.gsub(PATTERN_SPECIALS) { |char| "\\#{char}" } if @cache.respond_to?(:redis)
|
|
75
|
+
|
|
76
|
+
Regexp.new("^#{Regexp.escape(glob).gsub(Regexp.escape(GLOB_WILDCARD), '.*')}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prefix_matcher(literal)
|
|
80
|
+
return "#{escape_glob(literal)}#{GLOB_WILDCARD}" if @cache.respond_to?(:redis)
|
|
64
81
|
|
|
65
|
-
|
|
82
|
+
# ActiveSupport only prepends a store's namespace to a `^` source.
|
|
83
|
+
Regexp.new("^#{Regexp.escape(literal)}")
|
|
66
84
|
end
|
|
67
85
|
|
|
68
86
|
def wall_time
|
|
@@ -7,37 +7,36 @@ module ThrottleMachines
|
|
|
7
7
|
class Memory < Base
|
|
8
8
|
DEFAULT_MAX_KEYS = 65_536
|
|
9
9
|
CAPACITY_RETRY_AFTER = 1.0
|
|
10
|
+
DEFAULT_CLEANUP_INTERVAL = 60
|
|
11
|
+
DEFAULT_LOCK_POOL_SIZE = 32
|
|
12
|
+
SHUTDOWN_GRACE = 1.0
|
|
10
13
|
|
|
11
14
|
def initialize(options = {})
|
|
12
15
|
super
|
|
13
|
-
|
|
14
|
-
unless
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
max_keys = options.fetch(:max_keys, DEFAULT_MAX_KEYS)
|
|
17
|
+
raise ArgumentError, 'max_keys must be a non-negative integer' unless max_keys.is_a?(Integer) && max_keys >= 0
|
|
18
|
+
|
|
19
|
+
@state = State.new(
|
|
20
|
+
max_keys: max_keys,
|
|
21
|
+
lock_pool_size: options[:lock_pool_size] || DEFAULT_LOCK_POOL_SIZE,
|
|
22
|
+
cleanup_interval: options[:cleanup_interval] || DEFAULT_CLEANUP_INTERVAL
|
|
23
|
+
)
|
|
24
|
+
@counters = @state.counters
|
|
25
|
+
@gcra_states = @state.gcra_states
|
|
26
|
+
@token_buckets = @state.token_buckets
|
|
17
27
|
|
|
18
|
-
@stored_keys = 0
|
|
19
|
-
@capacity_lock = Mutex.new
|
|
20
28
|
@capacity_cleanup_lock = Mutex.new
|
|
21
29
|
@next_capacity_cleanup_at = -Float::INFINITY
|
|
22
|
-
@
|
|
23
|
-
@gcra_states = Concurrent::Hash.new
|
|
24
|
-
@token_buckets = Concurrent::Hash.new
|
|
25
|
-
|
|
26
|
-
# Use a striped lock pattern - pool of locks for fine-grained concurrency
|
|
27
|
-
@lock_pool_size = options[:lock_pool_size] || 32
|
|
28
|
-
@locks = Array.new(@lock_pool_size) { Concurrent::ReadWriteLock.new }
|
|
30
|
+
@cleanup_thread = nil
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
@cleanup_interval = options[:cleanup_interval] || 60
|
|
32
|
-
@shutdown = false
|
|
33
|
-
@cleanup_thread = start_cleanup_thread if options[:auto_cleanup] != false
|
|
32
|
+
return if options[:auto_cleanup] == false
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
@cleanup_thread = @state.start_cleanup_thread
|
|
36
35
|
ObjectSpace.define_finalizer(self, self.class.finalizer(@cleanup_thread))
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def self.finalizer(cleanup_thread)
|
|
40
|
-
proc { cleanup_thread
|
|
39
|
+
proc { cleanup_thread.kill }
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
# Rate limiting operations
|
|
@@ -113,7 +112,7 @@ module ThrottleMachines
|
|
|
113
112
|
|
|
114
113
|
{
|
|
115
114
|
allowed: allow,
|
|
116
|
-
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
115
|
+
retry_after: allow ? 0.0 : (tat - now - delay_tolerance),
|
|
117
116
|
tat: tat
|
|
118
117
|
}
|
|
119
118
|
end
|
|
@@ -132,12 +131,16 @@ module ThrottleMachines
|
|
|
132
131
|
|
|
133
132
|
{
|
|
134
133
|
allowed: allow,
|
|
135
|
-
retry_after: allow ? 0 : (tat - now - delay_tolerance),
|
|
134
|
+
retry_after: allow ? 0.0 : (tat - now - delay_tolerance),
|
|
136
135
|
tat: tat
|
|
137
136
|
}
|
|
138
137
|
end
|
|
139
138
|
end
|
|
140
139
|
|
|
140
|
+
def reset_gcra(key)
|
|
141
|
+
with_write_lock(key) { delete_entry(@gcra_states, key) }
|
|
142
|
+
end
|
|
143
|
+
|
|
141
144
|
# Token bucket operations (atomic simulation)
|
|
142
145
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
143
146
|
reclaim_capacity
|
|
@@ -160,7 +163,7 @@ module ThrottleMachines
|
|
|
160
163
|
|
|
161
164
|
{
|
|
162
165
|
allowed: true,
|
|
163
|
-
retry_after: 0,
|
|
166
|
+
retry_after: 0.0,
|
|
164
167
|
tokens_remaining: bucket[:tokens].floor
|
|
165
168
|
}
|
|
166
169
|
else
|
|
@@ -191,7 +194,7 @@ module ThrottleMachines
|
|
|
191
194
|
if token_available?(current_tokens, now, refill_rate)
|
|
192
195
|
{
|
|
193
196
|
allowed: true,
|
|
194
|
-
retry_after: 0,
|
|
197
|
+
retry_after: 0.0,
|
|
195
198
|
tokens_remaining: [current_tokens - 1, 0].max.floor
|
|
196
199
|
}
|
|
197
200
|
else
|
|
@@ -206,65 +209,58 @@ module ThrottleMachines
|
|
|
206
209
|
end
|
|
207
210
|
end
|
|
208
211
|
|
|
212
|
+
def reset_token_bucket(key)
|
|
213
|
+
with_write_lock(key) { delete_entry(@token_buckets, key) }
|
|
214
|
+
end
|
|
215
|
+
|
|
209
216
|
# No circuit breaker operations here: breaker state is owned by BreakerMachines
|
|
210
217
|
|
|
211
218
|
# Utility operations
|
|
212
219
|
def clear(pattern = nil)
|
|
213
220
|
if pattern
|
|
214
|
-
regex =
|
|
215
|
-
|
|
216
|
-
# Clear matching keys from all stores
|
|
217
|
-
[@counters, @gcra_states, @token_buckets].each do |store|
|
|
218
|
-
store.keys.each do |k|
|
|
219
|
-
with_write_lock(k) { delete_entry(store, k) } if k&.match?(regex)
|
|
220
|
-
end
|
|
221
|
-
end
|
|
221
|
+
regex = glob_to_regexp(pattern)
|
|
222
|
+
delete_where { |stored_key| stored_key.match?(regex) }
|
|
222
223
|
else
|
|
223
|
-
@
|
|
224
|
-
@counters.clear
|
|
225
|
-
@gcra_states.clear
|
|
226
|
-
@token_buckets.clear
|
|
227
|
-
@stored_keys = 0
|
|
228
|
-
end
|
|
224
|
+
@state.clear_all
|
|
229
225
|
end
|
|
230
226
|
end
|
|
231
227
|
|
|
228
|
+
def reset_key(key)
|
|
229
|
+
delete_where { |stored_key| owned_by?(stored_key, key) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
232
|
def healthy?
|
|
233
233
|
true
|
|
234
234
|
end
|
|
235
235
|
|
|
236
236
|
def shutdown
|
|
237
|
-
@shutdown
|
|
238
|
-
@cleanup_thread&.join(
|
|
239
|
-
@cleanup_thread&.kill
|
|
237
|
+
@state.shutdown!
|
|
238
|
+
@cleanup_thread&.join(SHUTDOWN_GRACE)
|
|
239
|
+
@cleanup_thread&.kill
|
|
240
240
|
@cleanup_thread = nil
|
|
241
|
+
ObjectSpace.undefine_finalizer(self)
|
|
241
242
|
end
|
|
242
243
|
|
|
243
244
|
private
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return false if new_key && @stored_keys >= @max_keys
|
|
251
|
-
|
|
252
|
-
store[key] = value
|
|
253
|
-
@stored_keys += 1 if new_key
|
|
254
|
-
true
|
|
246
|
+
def delete_where
|
|
247
|
+
@state.stores.each do |store|
|
|
248
|
+
store.keys.each do |stored_key|
|
|
249
|
+
with_write_lock(stored_key) { delete_entry(store, stored_key) } if yield(stored_key)
|
|
250
|
+
end
|
|
255
251
|
end
|
|
256
252
|
end
|
|
257
253
|
|
|
254
|
+
def store_entry(store, key, value)
|
|
255
|
+
@state.store_entry(store, key, value)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
258
|
def delete_entry(store, key)
|
|
259
|
-
@
|
|
260
|
-
value = store.delete(key)
|
|
261
|
-
@stored_keys -= 1 if value
|
|
262
|
-
value
|
|
263
|
-
end
|
|
259
|
+
@state.delete_entry(store, key)
|
|
264
260
|
end
|
|
265
261
|
|
|
266
262
|
def capacity_available?(store, key)
|
|
267
|
-
@
|
|
263
|
+
@state.capacity_available?(store, key)
|
|
268
264
|
end
|
|
269
265
|
|
|
270
266
|
def capacity_gcra_refusal(now, delay_tolerance)
|
|
@@ -279,7 +275,7 @@ module ThrottleMachines
|
|
|
279
275
|
# cleanup at most once per second, even with auto_cleanup disabled.
|
|
280
276
|
# Rejected identity sprays must not trigger a full scan on every call.
|
|
281
277
|
def reclaim_capacity
|
|
282
|
-
return unless @
|
|
278
|
+
return unless @state.saturated?
|
|
283
279
|
return unless @capacity_cleanup_lock.try_lock
|
|
284
280
|
|
|
285
281
|
begin
|
|
@@ -294,49 +290,133 @@ module ThrottleMachines
|
|
|
294
290
|
end
|
|
295
291
|
|
|
296
292
|
def with_read_lock(key, &block)
|
|
297
|
-
|
|
293
|
+
@state.with_read_lock(key, &block)
|
|
298
294
|
end
|
|
299
295
|
|
|
300
296
|
def with_write_lock(key, &block)
|
|
301
|
-
|
|
297
|
+
@state.with_write_lock(key, &block)
|
|
302
298
|
end
|
|
303
299
|
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
index = key.hash.abs % @lock_pool_size
|
|
307
|
-
@locks[index]
|
|
300
|
+
def clean_expired_entries
|
|
301
|
+
@state.clean_expired_entries
|
|
308
302
|
end
|
|
309
303
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
304
|
+
# Holds every map, lock and counter the cleanup worker sweeps.
|
|
305
|
+
class State
|
|
306
|
+
attr_reader :counters, :gcra_states, :token_buckets
|
|
307
|
+
|
|
308
|
+
def initialize(max_keys:, lock_pool_size:, cleanup_interval:)
|
|
309
|
+
@max_keys = max_keys
|
|
310
|
+
@cleanup_interval = cleanup_interval
|
|
311
|
+
@stored_keys = 0
|
|
312
|
+
@capacity_lock = Mutex.new
|
|
313
|
+
@counters = Concurrent::Hash.new
|
|
314
|
+
@gcra_states = Concurrent::Hash.new
|
|
315
|
+
@token_buckets = Concurrent::Hash.new
|
|
316
|
+
|
|
317
|
+
# Use a striped lock pattern - pool of locks for fine-grained concurrency
|
|
318
|
+
@lock_pool_size = lock_pool_size
|
|
319
|
+
@locks = Array.new(lock_pool_size) { Concurrent::ReadWriteLock.new }
|
|
320
|
+
@shutdown = false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def start_cleanup_thread
|
|
324
|
+
Thread.new do
|
|
325
|
+
loop do
|
|
326
|
+
break if @shutdown
|
|
314
327
|
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
sleep @cleanup_interval
|
|
329
|
+
break if @shutdown
|
|
317
330
|
|
|
318
|
-
|
|
331
|
+
clean_expired_entries
|
|
332
|
+
end
|
|
319
333
|
end
|
|
320
334
|
end
|
|
321
|
-
end
|
|
322
335
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
336
|
+
def shutdown!
|
|
337
|
+
@shutdown = true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def stores
|
|
341
|
+
[@counters, @gcra_states, @token_buckets]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def stored_keys
|
|
345
|
+
@capacity_lock.synchronize { @stored_keys }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def saturated?
|
|
349
|
+
@capacity_lock.synchronize { @stored_keys >= @max_keys }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def with_read_lock(key, &block)
|
|
353
|
+
lock_for(key).with_read_lock(&block)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def with_write_lock(key, &block)
|
|
357
|
+
lock_for(key).with_write_lock(&block)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Called with the key's write lock held. The short admission lock makes
|
|
361
|
+
# the cap atomic across stripes and all three stores.
|
|
362
|
+
def store_entry(store, key, value)
|
|
363
|
+
@capacity_lock.synchronize do
|
|
364
|
+
new_key = !store.key?(key)
|
|
365
|
+
return false if new_key && @stored_keys >= @max_keys
|
|
366
|
+
|
|
367
|
+
store[key] = value
|
|
368
|
+
@stored_keys += 1 if new_key
|
|
369
|
+
true
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def delete_entry(store, key)
|
|
374
|
+
@capacity_lock.synchronize do
|
|
375
|
+
value = store.delete(key)
|
|
376
|
+
@stored_keys -= 1 if value
|
|
377
|
+
value
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def capacity_available?(store, key)
|
|
382
|
+
@capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def clear_all
|
|
386
|
+
@capacity_lock.synchronize do
|
|
387
|
+
@counters.clear
|
|
388
|
+
@gcra_states.clear
|
|
389
|
+
@token_buckets.clear
|
|
390
|
+
@stored_keys = 0
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def clean_expired_entries
|
|
395
|
+
now = ThrottleMachines.monotonic_time
|
|
396
|
+
|
|
397
|
+
stores.each do |store|
|
|
398
|
+
store.keys.each do |key|
|
|
399
|
+
with_write_lock(key) do
|
|
400
|
+
# Re-read under the lock: a writer may have refreshed the TTL.
|
|
401
|
+
data = store[key]
|
|
402
|
+
delete_entry(store, key) if data && data[:expires_at] && data[:expires_at] <= now
|
|
403
|
+
end
|
|
332
404
|
end
|
|
333
405
|
end
|
|
406
|
+
|
|
407
|
+
# No breaker state cleanup: breaker state is managed by BreakerMachines
|
|
408
|
+
rescue StandardError => e
|
|
409
|
+
# Log error but don't crash cleanup thread
|
|
410
|
+
warn "ThrottleMachines: Cleanup error: #{e.message}"
|
|
334
411
|
end
|
|
335
412
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
413
|
+
private
|
|
414
|
+
|
|
415
|
+
def lock_for(key)
|
|
416
|
+
# Hash key to determine which lock to use
|
|
417
|
+
index = key.hash.abs % @lock_pool_size
|
|
418
|
+
@locks[index]
|
|
419
|
+
end
|
|
340
420
|
end
|
|
341
421
|
end
|
|
342
422
|
end
|
|
@@ -37,6 +37,10 @@ module ThrottleMachines
|
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def reset_gcra(_key)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
# Token bucket operations
|
|
41
45
|
def check_token_bucket(_key, capacity, _refill_rate, _ttl)
|
|
42
46
|
{
|
|
@@ -54,11 +58,19 @@ module ThrottleMachines
|
|
|
54
58
|
}
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def reset_token_bucket(_key)
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
# Utility operations
|
|
58
66
|
def clear(_pattern = nil)
|
|
59
67
|
true
|
|
60
68
|
end
|
|
61
69
|
|
|
70
|
+
def reset_key(_key)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
def healthy?
|
|
63
75
|
true
|
|
64
76
|
end
|
|
@@ -22,6 +22,9 @@ module ThrottleMachines
|
|
|
22
22
|
PEEK_GCRA_SHA = Digest::SHA1.hexdigest(PEEK_GCRA_SCRIPT)
|
|
23
23
|
PEEK_TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(PEEK_TOKEN_BUCKET_SCRIPT)
|
|
24
24
|
|
|
25
|
+
# Keys a single SCAN round trip asks for.
|
|
26
|
+
SCAN_BATCH = 100
|
|
27
|
+
|
|
25
28
|
def initialize(options = {})
|
|
26
29
|
super
|
|
27
30
|
@redis = options[:redis] || options[:client] || options[:pool]
|
|
@@ -74,6 +77,10 @@ module ThrottleMachines
|
|
|
74
77
|
}
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
def reset_gcra(key)
|
|
81
|
+
delete_state(key)
|
|
82
|
+
end
|
|
83
|
+
|
|
77
84
|
# Token bucket operations (atomic via Lua)
|
|
78
85
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
79
86
|
result = run_script(TOKEN_BUCKET_SCRIPT, TOKEN_BUCKET_SHA,
|
|
@@ -83,6 +90,10 @@ module ThrottleMachines
|
|
|
83
90
|
token_bucket_result(result, refill_rate)
|
|
84
91
|
end
|
|
85
92
|
|
|
93
|
+
def reset_token_bucket(key)
|
|
94
|
+
delete_state(key)
|
|
95
|
+
end
|
|
96
|
+
|
|
86
97
|
# Peek methods for non-consuming checks
|
|
87
98
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
88
99
|
now = current_time
|
|
@@ -112,17 +123,14 @@ module ThrottleMachines
|
|
|
112
123
|
|
|
113
124
|
# Utility operations
|
|
114
125
|
def clear(pattern = nil)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
scan_pattern = pattern ? prefixed(pattern) : "#{@prefix}*"
|
|
126
|
+
delete_matching(pattern ? prefixed(pattern) : "#{@prefix}#{GLOB_WILDCARD}")
|
|
127
|
+
end
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
125
|
-
end
|
|
129
|
+
# The window a counter was written under is unknown here, so the counters
|
|
130
|
+
# go by a glob over the key's own namespace; the key itself is a literal.
|
|
131
|
+
def reset_key(key)
|
|
132
|
+
delete_state(key)
|
|
133
|
+
delete_matching("#{escape_glob(prefixed(key))}#{KEY_SEPARATOR}#{GLOB_WILDCARD}")
|
|
126
134
|
end
|
|
127
135
|
|
|
128
136
|
def healthy?
|
|
@@ -133,6 +141,23 @@ module ThrottleMachines
|
|
|
133
141
|
|
|
134
142
|
private
|
|
135
143
|
|
|
144
|
+
def delete_state(key)
|
|
145
|
+
with_redis { |r| r.del(prefixed(key)) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# SCAN instead of KEYS, to avoid blocking in production.
|
|
149
|
+
def delete_matching(scan_pattern)
|
|
150
|
+
cursor = '0'
|
|
151
|
+
|
|
152
|
+
with_redis do |redis|
|
|
153
|
+
loop do
|
|
154
|
+
cursor, keys = redis.scan(cursor, match: scan_pattern, count: SCAN_BATCH)
|
|
155
|
+
redis.del(*keys) unless keys.empty?
|
|
156
|
+
break if cursor == '0'
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
136
161
|
# `result` is [allowed, tokens]: the balance after an admission, or the
|
|
137
162
|
# fractional balance a refusal waits on. Matches Storage::Memory.
|
|
138
163
|
def token_bucket_result(result, refill_rate)
|
data/lib/throttle_machines.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'active_support/core_ext/class/attribute'
|
|
|
7
7
|
|
|
8
8
|
# Ecosystem dependencies
|
|
9
9
|
require 'chrono_machines'
|
|
10
|
+
require 'deadline_machines'
|
|
10
11
|
require 'breaker_machines'
|
|
11
12
|
|
|
12
13
|
# Set up Zeitwerk loader
|
|
@@ -75,7 +76,7 @@ module ThrottleMachines
|
|
|
75
76
|
|
|
76
77
|
def reset!(key = nil)
|
|
77
78
|
if key
|
|
78
|
-
storage.
|
|
79
|
+
storage.reset_key(key)
|
|
79
80
|
else
|
|
80
81
|
storage.clear
|
|
81
82
|
# Reset storage instance to force recreation with defaults
|
|
@@ -98,19 +99,16 @@ module ThrottleMachines
|
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
def break_circuit(key, failures:, timeout:, &block)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
failure_threshold: failures,
|
|
105
|
-
reset_timeout: timeout
|
|
106
|
-
)
|
|
107
|
-
breaker.call(&block)
|
|
102
|
+
BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
|
|
103
|
+
key, self, { failure_threshold: failures, reset_timeout: timeout }
|
|
104
|
+
).call(&block)
|
|
108
105
|
end
|
|
109
106
|
|
|
110
107
|
def retry_with(max_attempts: 3, backoff: :exponential, &block)
|
|
111
108
|
# Delegate to chrono_machines
|
|
112
109
|
policy_options = {
|
|
113
110
|
max_attempts: max_attempts,
|
|
111
|
+
backoff_strategy: backoff,
|
|
114
112
|
jitter_factor: backoff == :exponential ? 1.0 : 0.0
|
|
115
113
|
}
|
|
116
114
|
ChronoMachines.retry(policy_options, &block)
|
|
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.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -72,28 +72,42 @@ dependencies:
|
|
|
72
72
|
requirements:
|
|
73
73
|
- - ">="
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: 0.
|
|
75
|
+
version: 0.24.0
|
|
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: 0.
|
|
82
|
+
version: 0.24.0
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
84
|
name: chrono_machines
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
86
86
|
requirements:
|
|
87
|
-
- - "
|
|
87
|
+
- - ">="
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
|
-
version:
|
|
89
|
+
version: 0.12.1
|
|
90
90
|
type: :runtime
|
|
91
91
|
prerelease: false
|
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
|
-
- - "
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: 0.12.1
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: deadline_machines
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.1'
|
|
104
|
+
type: :runtime
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
95
109
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: '0.
|
|
110
|
+
version: '0.1'
|
|
97
111
|
- !ruby/object:Gem::Dependency
|
|
98
112
|
name: minitest
|
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|