throttle_machines 0.3.3-x86_64-linux → 0.4.0-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8094e1210ce074e1bec5f730d483a1fd3968ffcc3b3cb10b2c1705313a6c3fee
4
- data.tar.gz: 415ff2e1267150c4c466545cfeefe01e9ea30ba6502ddc7e510526bfcbe2679f
3
+ metadata.gz: 4cd3673ad46daed3a5bb1e6f7805ecd170e80e36ed8372dfc792e0e3a1ce1d80
4
+ data.tar.gz: 8eb9240cd5cd3f04c259b6f6e0fbe76083e035a73d7d30a4f8a619ec17e21990
5
5
  SHA512:
6
- metadata.gz: b1024d836469d3500560e115232ed349e72b584e734f1ee2d5c39c399c2a760274662383bb7d20f82b104cd2742216f6634e4c590bb7f4335158fa5bf1711220
7
- data.tar.gz: 1ea545b6198221294e78abeb5240741d26d3d36f61581610fe508a21e4a32413a7596079d9cbd4973028bfa696262918faff497b6c0eaecfdcb90d4414c80056
6
+ metadata.gz: b115106ee0e3c4a59957c98db6f432176fc6f965c0422476035c4f68145c2938821679c4a58a32704c3087111ca562fca22e87e166b134cb76b66fedb1ec4b84
7
+ data.tar.gz: 2569edf7730cff78baca023d3313049257dc358df2376193ec578bd15f4904482a4fc2eb98aba01f1dabd77168191789c66acabd4d452ae8cfc335593afa26bb
data/README.md CHANGED
@@ -126,6 +126,34 @@ memory limits separately. Evicting active Redis keys can reset their limits.
126
126
 
127
127
  ---
128
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
+
129
157
  ## 🌠 The Ultra-Thin Philosophy
130
158
 
131
159
  Like the best spacecraft, ThrottleMachines follows the principle of **ultra-thin design**:
@@ -22,26 +22,25 @@ module ThrottleMachines
22
22
  end
23
23
 
24
24
  def throttle_async(max_wait: nil, &block)
25
- deadline = max_wait.nil? ? nil : ThrottleMachines.monotonic_time + max_wait.to_f
26
-
27
- loop do
28
- begin
29
- throttle!
30
- rescue ThrottledError => e
31
- wait_time = [e.retry_after.to_f, 0.0].max
32
-
33
- if deadline
34
- remaining_wait = deadline - ThrottleMachines.monotonic_time
35
- raise if remaining_wait <= 0 || wait_time > remaining_wait
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
- sleep_for(wait_time)
39
- next
40
- end
40
+ return block.call if block
41
41
 
42
- return block.call if block
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
- jitter: backoff,
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
- jitter_factor: @rules[:retry][:jitter] == :exponential ? 1.0 : 0.0
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.future { yield(attempt) }
59
+ Concurrent::Promises.future_on(@executor) { yield(attempt) }
60
60
  else
61
- Concurrent::Promises.schedule(@delay * attempt) { yield(attempt) }
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
@@ -10,6 +10,8 @@ module ThrottleMachines
10
10
  @period = period
11
11
  @algorithm = algorithm
12
12
  @storage = storage || ThrottleMachines.storage
13
+ validate_algorithm!
14
+ validate_rate!
13
15
  end
14
16
 
15
17
  def allow?
@@ -93,8 +95,10 @@ module ThrottleMachines
93
95
  case @algorithm
94
96
  when :fixed_window
95
97
  @storage.reset_counter(@key, @period)
96
- else
97
- @storage.clear("#{@key}*")
98
+ when :gcra
99
+ @storage.reset_gcra(@key)
100
+ when :token_bucket
101
+ @storage.reset_token_bucket(@key)
98
102
  end
99
103
  end
100
104
 
@@ -163,5 +167,22 @@ module ThrottleMachines
163
167
  retry_after: retry_after
164
168
  }
165
169
  end
170
+
171
+ private
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
+
178
+ def validate_algorithm!
179
+ unless Storage::Base::ALGORITHM_OPERATIONS.key?(@algorithm)
180
+ raise ArgumentError, "Unknown algorithm: #{@algorithm.inspect}"
181
+ end
182
+ return unless @storage.respond_to?(:supports?)
183
+ return if @storage.supports?(@algorithm)
184
+
185
+ raise ArgumentError, "#{@storage.class} does not support the #{@algorithm.inspect} algorithm"
186
+ end
166
187
  end
167
188
  end
@@ -3,58 +3,94 @@
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 reset_gcra],
9
+ token_bucket: %i[check_token_bucket peek_token_bucket reset_token_bucket]
10
+ }.freeze
11
+
12
+ HEALTH_KEY = 'throttle_machines:health'
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
+
6
20
  def initialize(options = {})
7
21
  @options = options
8
22
  end
9
23
 
24
+ def supports?(algorithm)
25
+ operations = ALGORITHM_OPERATIONS[algorithm]
26
+ return false unless operations
27
+
28
+ operations.all? { |operation| self.class.instance_method(operation).owner != Base }
29
+ end
30
+
10
31
  # Rate limiting operations
11
32
  def increment_counter(key, window, amount = 1)
12
- raise NotImplementedError
33
+ raise NotImplementedError, "#{self.class} does not implement #increment_counter"
13
34
  end
14
35
 
15
36
  def get_counter(key, window)
16
- raise NotImplementedError
37
+ raise NotImplementedError, "#{self.class} does not implement #get_counter"
17
38
  end
18
39
 
19
40
  def get_counter_ttl(key, window)
20
- raise NotImplementedError
41
+ raise NotImplementedError, "#{self.class} does not implement #get_counter_ttl"
21
42
  end
22
43
 
23
44
  def reset_counter(key, window)
24
- raise NotImplementedError
45
+ raise NotImplementedError, "#{self.class} does not implement #reset_counter"
25
46
  end
26
47
 
27
48
  # GCRA operations (atomic)
28
49
  def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
29
- raise NotImplementedError
50
+ raise NotImplementedError, "#{self.class} does not implement #check_gcra_limit"
30
51
  end
31
52
 
32
53
  def peek_gcra_limit(key, emission_interval, delay_tolerance)
33
- raise NotImplementedError
54
+ raise NotImplementedError, "#{self.class} does not implement #peek_gcra_limit"
55
+ end
56
+
57
+ def reset_gcra(key)
58
+ raise NotImplementedError, "#{self.class} does not implement #reset_gcra"
34
59
  end
35
60
 
36
61
  # Token bucket operations (atomic)
37
62
  def check_token_bucket(key, capacity, refill_rate, ttl)
38
- raise NotImplementedError
63
+ raise NotImplementedError, "#{self.class} does not implement #check_token_bucket"
39
64
  end
40
65
 
41
66
  def peek_token_bucket(key, capacity, refill_rate)
42
- raise NotImplementedError
67
+ raise NotImplementedError, "#{self.class} does not implement #peek_token_bucket"
68
+ end
69
+
70
+ def reset_token_bucket(key)
71
+ raise NotImplementedError, "#{self.class} does not implement #reset_token_bucket"
43
72
  end
44
73
 
45
74
  # Utility operations
46
75
  def clear(pattern = nil)
47
- raise NotImplementedError
76
+ raise NotImplementedError, "#{self.class} does not implement #clear"
77
+ end
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"
48
82
  end
49
83
 
50
84
  def healthy?
51
- raise NotImplementedError
85
+ get_counter(HEALTH_KEY, 1)
86
+ true
87
+ rescue StandardError
88
+ false
52
89
  end
53
90
 
54
91
  def with_timeout(timeout, &block)
55
- Timeout.timeout(timeout, &block)
56
- rescue Timeout::Error
57
- nil
92
+ DeadlineMachines.check!
93
+ Timeout.timeout(DeadlineMachines.clamp_sleep(timeout), &block)
58
94
  end
59
95
 
60
96
  # Rounding slack for time comparisons, in units of the clock's precision.
@@ -63,6 +99,18 @@ module ThrottleMachines
63
99
 
64
100
  protected
65
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
+
66
114
  # Times are running sums and differences of float seconds, so an
67
115
  # on-schedule arrival can miss an exact boundary by a few ulps. The slack
68
116
  # scales with the clock's magnitude.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ module Storage
5
+ class Cache < Base
6
+ # The wildcard a caller's pattern keeps; everything else is a literal.
7
+ PATTERN_SPECIALS = /[?\[\]\\]/
8
+
9
+ def initialize(options = {})
10
+ super
11
+ @cache = options[:cache_store] || (defined?(Rails) && Rails.cache)
12
+ raise ArgumentError, 'Cache storage requires a cache_store outside Rails' unless @cache
13
+
14
+ @prefix = options[:prefix] || 'throttle_machines'
15
+ @fail_closed = options.fetch(:fail_closed, false)
16
+ end
17
+
18
+ def increment_counter(key, window, amount = 1)
19
+ count = @cache.increment(window_key(key, window), amount, expires_in: window.to_f.ceil + 1)
20
+ count || (@fail_closed ? Float::INFINITY : 0)
21
+ end
22
+
23
+ def get_counter(key, window)
24
+ @cache.read(window_key(key, window), raw: true).to_i
25
+ end
26
+
27
+ def get_counter_ttl(_key, window)
28
+ window = positive_window(window)
29
+ window - (wall_time % window)
30
+ end
31
+
32
+ def reset_counter(key, window)
33
+ @cache.delete(window_key(key, window))
34
+ end
35
+
36
+ def clear(pattern = nil)
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}"))
44
+ end
45
+
46
+ def healthy?
47
+ !@cache.increment("#{@prefix}:health", 1, expires_in: 60).nil?
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ private
53
+
54
+ def window_key(key, window)
55
+ window = positive_window(window)
56
+ "#{@prefix}:#{key}:#{window}:#{(wall_time / window).floor}"
57
+ end
58
+
59
+ def positive_window(window)
60
+ window = window.to_f
61
+ raise ArgumentError, "window must be positive, got #{window}" unless window.positive?
62
+
63
+ window
64
+ end
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
+
73
+ def matcher_for(glob)
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)
81
+
82
+ # ActiveSupport only prepends a store's namespace to a `^` source.
83
+ Regexp.new("^#{Regexp.escape(literal)}")
84
+ end
85
+
86
+ def wall_time
87
+ Time.now.to_f
88
+ end
89
+ end
90
+ end
91
+ end
@@ -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
- @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
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
- @counters = Concurrent::Hash.new
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
- # Background cleanup thread
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
- # Ensure cleanup on garbage collection
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&.kill }
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 = Regexp.new(pattern.gsub('*', '.*'))
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
- @capacity_lock.synchronize do
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 = true
238
- @cleanup_thread&.join(1) # Wait up to 1 second for graceful shutdown
239
- @cleanup_thread&.kill if @cleanup_thread&.alive?
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
- # 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
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
- @capacity_lock.synchronize do
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
- @capacity_lock.synchronize { store.key?(key) || @stored_keys < @max_keys }
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 @capacity_lock.synchronize { @stored_keys >= @max_keys }
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
- lock_for(key).with_read_lock(&block)
293
+ @state.with_read_lock(key, &block)
298
294
  end
299
295
 
300
296
  def with_write_lock(key, &block)
301
- lock_for(key).with_write_lock(&block)
297
+ @state.with_write_lock(key, &block)
302
298
  end
303
299
 
304
- def lock_for(key)
305
- # Hash key to determine which lock to use
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
- def start_cleanup_thread
311
- Thread.new do
312
- loop do
313
- break if @shutdown
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
- sleep @cleanup_interval
316
- break if @shutdown
328
+ sleep @cleanup_interval
329
+ break if @shutdown
317
330
 
318
- clean_expired_entries
331
+ clean_expired_entries
332
+ end
319
333
  end
320
334
  end
321
- end
322
335
 
323
- def clean_expired_entries
324
- now = current_time
325
-
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
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
- # No breaker state cleanup: breaker state is managed by BreakerMachines
337
- rescue StandardError => e
338
- # Log error but don't crash cleanup thread
339
- warn "ThrottleMachines: Cleanup error: #{e.message}"
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
- # Use SCAN instead of KEYS to avoid blocking in production
116
- cursor = '0'
117
- scan_pattern = pattern ? prefixed(pattern) : "#{@prefix}*"
126
+ delete_matching(pattern ? prefixed(pattern) : "#{@prefix}#{GLOB_WILDCARD}")
127
+ end
118
128
 
119
- with_redis do |redis|
120
- loop do
121
- cursor, keys = redis.scan(cursor, match: scan_pattern, count: 100)
122
- redis.del(*keys) unless keys.empty?
123
- break if cursor == '0'
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)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class UnsupportedOperationError < StandardError; end
5
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.3.3'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -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.clear("#{key}*")
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
- # Delegate to BreakerMachines; use reset_timeout for open duration
102
- breaker = BreakerMachines::Circuit.new(
103
- key,
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)
@@ -159,6 +157,8 @@ module ThrottleMachines
159
157
  case symbol
160
158
  when :memory
161
159
  Storage::Memory.new
160
+ when :cache
161
+ Storage::Cache.new
162
162
  when :redis
163
163
  raise ArgumentError, 'Redis storage requires redis gem' unless defined?(Redis)
164
164
 
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.3
4
+ version: 0.4.0
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-10-27 00:00:00.000000000 Z
11
+ date: 2026-11-01 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: '0.20'
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: '0.20'
82
+ version: 0.22.1
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: chrono_machines
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0.10'
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
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0.1'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: minitest
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -172,6 +186,7 @@ files:
172
186
  - lib/throttle_machines/rack_middleware/throttle.rb
173
187
  - lib/throttle_machines/rack_middleware/track.rb
174
188
  - lib/throttle_machines/storage/base.rb
189
+ - lib/throttle_machines/storage/cache.rb
175
190
  - lib/throttle_machines/storage/memory.rb
176
191
  - lib/throttle_machines/storage/null.rb
177
192
  - lib/throttle_machines/storage/redis.rb
@@ -181,6 +196,7 @@ files:
181
196
  - lib/throttle_machines/storage/redis/peek_token_bucket.lua
182
197
  - lib/throttle_machines/storage/redis/token_bucket.lua
183
198
  - lib/throttle_machines/throttled_error.rb
199
+ - lib/throttle_machines/unsupported_operation_error.rb
184
200
  - lib/throttle_machines/version.rb
185
201
  - lib/throttle_machines_native/throttle_machines_native.so
186
202
  homepage: https://github.com/seuros/throttle_machines