throttle_machines 0.1.1 → 0.2.4

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -0
  3. data/lib/throttle_machines/async_breaker.rb +12 -0
  4. data/lib/throttle_machines/async_limiter.rb +7 -117
  5. data/lib/throttle_machines/async_support.rb +75 -0
  6. data/lib/throttle_machines/controller_helpers.rb +2 -2
  7. data/lib/throttle_machines/engine.rb +1 -6
  8. data/lib/throttle_machines/hedged_breaker.rb +2 -2
  9. data/lib/throttle_machines/hedged_request.rb +2 -2
  10. data/lib/throttle_machines/instrumentation.rb +19 -19
  11. data/lib/throttle_machines/middleware.rb +1 -2
  12. data/lib/throttle_machines/native_speedup.rb +98 -0
  13. data/lib/throttle_machines/rack_middleware/allow2_ban.rb +11 -22
  14. data/lib/throttle_machines/rack_middleware/ban_filter.rb +29 -0
  15. data/lib/throttle_machines/rack_middleware/blocklist.rb +5 -18
  16. data/lib/throttle_machines/rack_middleware/configuration.rb +14 -14
  17. data/lib/throttle_machines/rack_middleware/fail2_ban.rb +20 -29
  18. data/lib/throttle_machines/rack_middleware/list_filter.rb +39 -0
  19. data/lib/throttle_machines/rack_middleware/safelist.rb +5 -18
  20. data/lib/throttle_machines/rack_middleware.rb +2 -4
  21. data/lib/throttle_machines/storage/base.rb +2 -23
  22. data/lib/throttle_machines/storage/memory.rb +7 -113
  23. data/lib/throttle_machines/storage/null.rb +0 -21
  24. data/lib/throttle_machines/storage/redis/gcra.lua +1 -1
  25. data/lib/throttle_machines/storage/redis/increment_counter.lua +1 -1
  26. data/lib/throttle_machines/storage/redis/peek_gcra.lua +1 -1
  27. data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +1 -1
  28. data/lib/throttle_machines/storage/redis/token_bucket.lua +1 -1
  29. data/lib/throttle_machines/storage/redis.rb +5 -68
  30. data/lib/throttle_machines/version.rb +1 -1
  31. data/lib/throttle_machines.rb +87 -21
  32. metadata +74 -15
  33. data/Rakefile +0 -12
  34. data/lib/throttle_machines/storage/redis/get_breaker_state.lua +0 -23
  35. data/lib/throttle_machines/storage/redis/record_breaker_failure.lua +0 -24
  36. data/lib/throttle_machines/storage/redis/record_breaker_success.lua +0 -16
@@ -2,36 +2,32 @@
2
2
 
3
3
  module ThrottleMachines
4
4
  class RackMiddleware
5
- class Fail2Ban
6
- attr_reader :name, :maxretry, :findtime, :bantime, :block
7
-
8
- def initialize(name, options, &block)
9
- @name = name
10
- @block = block
11
-
12
- @maxretry = options[:maxretry] || 5
13
- @findtime = options[:findtime] || 60
14
- @bantime = options[:bantime] || 300
15
- end
16
-
5
+ class Fail2Ban < BanFilter
17
6
  def banned?(request)
18
7
  discriminator = discriminator_for(request)
19
8
  return false unless discriminator
20
9
 
21
10
  key = "fail2ban:#{@name}:#{discriminator}"
22
11
 
23
- # Use circuit breaker to track failures
24
- breaker = ThrottleMachines::Breaker.new(
12
+ # Use a globally managed BreakerMachines circuit as the ban mechanism
13
+ breaker = BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
25
14
  key,
15
+ self,
26
16
  failure_threshold: @maxretry,
27
- timeout: @bantime,
28
- storage: ThrottleMachines.storage
17
+ failure_window: @findtime,
18
+ reset_timeout: @bantime
29
19
  )
30
20
 
31
21
  # Check if circuit is open (banned)
32
22
  if breaker.open?
33
- # Get breaker state for instrumentation
34
- state = breaker.to_h
23
+ stats = breaker.stats
24
+ now = BreakerMachines.monotonic_time
25
+ time_until_unban = if stats.opened_at
26
+ remaining = @bantime - (now - stats.opened_at)
27
+ remaining.positive? ? remaining : 0
28
+ else
29
+ @bantime
30
+ end
35
31
 
36
32
  request.env['rack.attack.matched'] = @name
37
33
  request.env['rack.attack.match_type'] = :fail2ban
@@ -41,8 +37,8 @@ module ThrottleMachines
41
37
  maxretry: @maxretry,
42
38
  findtime: @findtime,
43
39
  bantime: @bantime,
44
- failures: state[:failure_count],
45
- time_until_unban: state[:time_until_retry]
40
+ failures: stats.failure_count,
41
+ time_until_unban: time_until_unban
46
42
  }
47
43
 
48
44
  ThrottleMachines::RackMiddleware.instrument(request)
@@ -61,11 +57,12 @@ module ThrottleMachines
61
57
  # Use the breaker to record a failure if block returns true
62
58
  return unless yield
63
59
 
64
- breaker = ThrottleMachines::Breaker.new(
60
+ breaker = BreakerMachines::Registry.instance.get_or_create_dynamic_circuit(
65
61
  key,
62
+ self,
66
63
  failure_threshold: @maxretry,
67
- timeout: @bantime,
68
- storage: ThrottleMachines.storage
64
+ failure_window: @findtime,
65
+ reset_timeout: @bantime
69
66
  )
70
67
 
71
68
  # Record failure by trying to call through the breaker
@@ -76,12 +73,6 @@ module ThrottleMachines
76
73
  # Expected - this records the failure
77
74
  end
78
75
  end
79
-
80
- private
81
-
82
- def discriminator_for(request)
83
- @block.call(request)
84
- end
85
76
  end
86
77
  end
87
78
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThrottleMachines
4
+ class RackMiddleware
5
+ # Base class for Safelist and Blocklist filters.
6
+ #
7
+ # Both match a request against a user-supplied block and, on a match, tag the
8
+ # rack env and emit instrumentation. They differ only in the +match_type+
9
+ # recorded on the env, which subclasses provide.
10
+ class ListFilter
11
+ attr_reader :name, :block
12
+
13
+ def initialize(name, &block)
14
+ @name = name
15
+ @block = block
16
+ end
17
+
18
+ def matched_by?(request)
19
+ return false unless @block
20
+
21
+ if @block.call(request)
22
+ request.env['rack.attack.matched'] = @name
23
+ request.env['rack.attack.match_type'] = match_type
24
+ ThrottleMachines::RackMiddleware.instrument(request)
25
+ true
26
+ else
27
+ false
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @return [Symbol] the rack.attack match type recorded for this filter
34
+ def match_type
35
+ raise NotImplementedError, "#{self.class} must implement #match_type"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,25 +2,12 @@
2
2
 
3
3
  module ThrottleMachines
4
4
  class RackMiddleware
5
- class Safelist
6
- attr_reader :name, :block
5
+ # Allows (short-circuits) requests matching the configured block.
6
+ class Safelist < ListFilter
7
+ private
7
8
 
8
- def initialize(name, &block)
9
- @name = name
10
- @block = block
11
- end
12
-
13
- def matched_by?(request)
14
- return false unless @block
15
-
16
- if @block.call(request)
17
- request.env['rack.attack.matched'] = @name
18
- request.env['rack.attack.match_type'] = :safelist
19
- ThrottleMachines::RackMiddleware.instrument(request)
20
- true
21
- else
22
- false
23
- end
9
+ def match_type
10
+ :safelist
24
11
  end
25
12
  end
26
13
  end
@@ -30,16 +30,14 @@ module ThrottleMachines
30
30
  :safelists,
31
31
  :blocklists
32
32
 
33
- def configure(&)
33
+ def configure(&block)
34
34
  @configuration ||= Configuration.new
35
- @configuration.instance_eval(&) if block
35
+ @configuration.instance_eval(&block) if block
36
36
  end
37
37
 
38
- # rubocop:disable Rails/Delegate -- Ruby 3.4 compatibility issue with delegate
39
38
  def reset!
40
39
  ThrottleMachines.reset!
41
40
  end
42
- # rubocop:enable Rails/Delegate
43
41
 
44
42
  def clear!
45
43
  @configuration = Configuration.new
@@ -42,27 +42,6 @@ module ThrottleMachines
42
42
  raise NotImplementedError
43
43
  end
44
44
 
45
- # Circuit breaker operations
46
- def get_breaker_state(key)
47
- raise NotImplementedError
48
- end
49
-
50
- def record_breaker_success(key, timeout, half_open_requests = 1)
51
- raise NotImplementedError
52
- end
53
-
54
- def record_breaker_failure(key, threshold, timeout)
55
- raise NotImplementedError
56
- end
57
-
58
- def trip_breaker(key, timeout)
59
- raise NotImplementedError
60
- end
61
-
62
- def reset_breaker(key)
63
- raise NotImplementedError
64
- end
65
-
66
45
  # Utility operations
67
46
  def clear(pattern = nil)
68
47
  raise NotImplementedError
@@ -72,8 +51,8 @@ module ThrottleMachines
72
51
  raise NotImplementedError
73
52
  end
74
53
 
75
- def with_timeout(timeout, &)
76
- Timeout.timeout(timeout, &)
54
+ def with_timeout(timeout, &block)
55
+ Timeout.timeout(timeout, &block)
77
56
  rescue Timeout::Error
78
57
  nil
79
58
  end
@@ -10,7 +10,6 @@ module ThrottleMachines
10
10
  @counters = Concurrent::Hash.new
11
11
  @gcra_states = Concurrent::Hash.new
12
12
  @token_buckets = Concurrent::Hash.new
13
- @breaker_states = Concurrent::Hash.new
14
13
 
15
14
  # Use a striped lock pattern - pool of locks for fine-grained concurrency
16
15
  @lock_pool_size = options[:lock_pool_size] || 32
@@ -180,98 +179,7 @@ module ThrottleMachines
180
179
  end
181
180
  end
182
181
 
183
- # Circuit breaker operations
184
- def get_breaker_state(key)
185
- # First try with read lock
186
- state = with_read_lock("breaker:#{key}") do
187
- @breaker_states[key] || { state: :closed, failures: 0, last_failure: nil }
188
- end
189
-
190
- # Check if we need to transition from open to half-open
191
- if state[:state] == :open && state[:opens_at] && current_time >= state[:opens_at]
192
- # Release read lock and acquire write lock
193
- with_write_lock("breaker:#{key}") do
194
- # Re-check condition after acquiring write lock
195
- current_state = @breaker_states[key]
196
- if current_state && current_state[:state] == :open && current_state[:opens_at] && current_time >= current_state[:opens_at]
197
- @breaker_states[key] = current_state.merge(
198
- state: :half_open,
199
- half_open_attempts: 0
200
- )
201
- end
202
- @breaker_states[key] || { state: :closed, failures: 0, last_failure: nil }
203
- end
204
- else
205
- state
206
- end
207
- end
208
-
209
- def record_breaker_success(key, _timeout, half_open_requests = 1)
210
- with_write_lock("breaker:#{key}") do
211
- state = @breaker_states[key]
212
- return unless state
213
-
214
- case state[:state]
215
- when :half_open
216
- attempts = (state[:half_open_attempts] || 0) + 1
217
- if attempts >= half_open_requests
218
- @breaker_states.delete(key)
219
- else
220
- @breaker_states[key] = state.merge(half_open_attempts: attempts)
221
- end
222
- when :closed
223
- # Reset failure count on success
224
- @breaker_states[key] = state.merge(failures: 0) if state[:failures].positive?
225
- end
226
- end
227
- end
228
-
229
- def record_breaker_failure(key, threshold, timeout)
230
- with_write_lock("breaker:#{key}") do
231
- state = @breaker_states[key] || { state: :closed, failures: 0 }
232
- now = current_time
233
-
234
- case state[:state]
235
- when :closed
236
- failures = state[:failures] + 1
237
- @breaker_states[key] = if failures >= threshold
238
- {
239
- state: :open,
240
- failures: failures,
241
- last_failure: now,
242
- opens_at: now + timeout
243
- }
244
- else
245
- state.merge(failures: failures, last_failure: now)
246
- end
247
- when :half_open
248
- @breaker_states[key] = {
249
- state: :open,
250
- failures: state[:failures],
251
- last_failure: now,
252
- opens_at: now + timeout
253
- }
254
- end
255
-
256
- @breaker_states[key]
257
- end
258
- end
259
-
260
- def trip_breaker(key, timeout)
261
- with_write_lock("breaker:#{key}") do
262
- now = current_time
263
- @breaker_states[key] = {
264
- state: :open,
265
- failures: 0,
266
- last_failure: now,
267
- opens_at: now + timeout
268
- }
269
- end
270
- end
271
-
272
- def reset_breaker(key)
273
- with_write_lock("breaker:#{key}") { @breaker_states.delete(key) }
274
- end
182
+ # No circuit breaker operations here: breaker state is owned by BreakerMachines
275
183
 
276
184
  # Utility operations
277
185
  def clear(pattern = nil)
@@ -279,7 +187,7 @@ module ThrottleMachines
279
187
  regex = Regexp.new(pattern.gsub('*', '.*'))
280
188
 
281
189
  # Clear matching keys from all stores
282
- [@counters, @gcra_states, @token_buckets, @breaker_states].each do |store|
190
+ [@counters, @gcra_states, @token_buckets].each do |store|
283
191
  store.each_key do |k|
284
192
  store.delete(k) if k&.match?(regex)
285
193
  end
@@ -288,7 +196,6 @@ module ThrottleMachines
288
196
  @counters.clear
289
197
  @gcra_states.clear
290
198
  @token_buckets.clear
291
- @breaker_states.clear
292
199
  end
293
200
  end
294
201
 
@@ -305,12 +212,12 @@ module ThrottleMachines
305
212
 
306
213
  private
307
214
 
308
- def with_read_lock(key, &)
309
- lock_for(key).with_read_lock(&)
215
+ def with_read_lock(key, &block)
216
+ lock_for(key).with_read_lock(&block)
310
217
  end
311
218
 
312
- def with_write_lock(key, &)
313
- lock_for(key).with_write_lock(&)
219
+ def with_write_lock(key, &block)
220
+ lock_for(key).with_write_lock(&block)
314
221
  end
315
222
 
316
223
  def lock_for(key)
@@ -350,20 +257,7 @@ module ThrottleMachines
350
257
  with_write_lock(key) { @token_buckets.delete(key) } if data[:expires_at] && data[:expires_at] <= now
351
258
  end
352
259
 
353
- # Clean closed breaker states and expired open states
354
- @breaker_states.each_pair do |key, data|
355
- should_delete = false
356
-
357
- # Clean closed states that have been idle
358
- should_delete = true if data[:state] == :closed && data[:failures].zero?
359
-
360
- # Clean expired open states (older than 2x timeout)
361
- if data[:opens_at] && now > data[:opens_at] + ((data[:opens_at] - (data[:last_failure] || now)) * 2)
362
- should_delete = true
363
- end
364
-
365
- with_write_lock("breaker:#{key}") { @breaker_states.delete(key) } if should_delete
366
- end
260
+ # No breaker state cleanup: breaker state is managed by BreakerMachines
367
261
  rescue StandardError => e
368
262
  # Log error but don't crash cleanup thread
369
263
  warn "ThrottleMachines: Cleanup error: #{e.message}"
@@ -54,27 +54,6 @@ module ThrottleMachines
54
54
  }
55
55
  end
56
56
 
57
- # Circuit breaker operations
58
- def get_breaker_state(_key)
59
- { state: :closed, failures: 0, last_failure: nil }
60
- end
61
-
62
- def record_breaker_success(_key, _timeout, _half_open_requests = 1)
63
- true
64
- end
65
-
66
- def record_breaker_failure(_key, _threshold, _timeout)
67
- { state: :closed, failures: 0, last_failure: nil }
68
- end
69
-
70
- def trip_breaker(_key, _timeout)
71
- true
72
- end
73
-
74
- def reset_breaker(_key)
75
- true
76
- end
77
-
78
57
  # Utility operations
79
58
  def clear(_pattern = nil)
80
59
  true
@@ -19,4 +19,4 @@ if allow then
19
19
  redis.call('SET', key, new_tat, 'EX', ttl)
20
20
  end
21
21
 
22
- return { allow and 1 or 0, tat }
22
+ return { allow and 1 or 0, tat }
@@ -6,4 +6,4 @@ if ttl <= 0 then
6
6
  redis.call('EXPIRE', KEYS[1], ARGV[2])
7
7
  end
8
8
 
9
- return count
9
+ return count
@@ -13,4 +13,4 @@ end
13
13
  tat = math.max(tat, now)
14
14
  local allow = (tat - now) <= delay_tolerance
15
15
 
16
- return { allow and 1 or 0, tat }
16
+ return { allow and 1 or 0, tat }
@@ -15,4 +15,4 @@ tokens = math.min(tokens + tokens_to_add, capacity)
15
15
  local allow = tokens >= 1
16
16
  local tokens_after = allow and (tokens - 1) or 0
17
17
 
18
- return { allow and 1 or 0, tokens_after }
18
+ return { allow and 1 or 0, tokens_after }
@@ -20,4 +20,4 @@ if allow then
20
20
  redis.call('EXPIRE', key, ttl)
21
21
  end
22
22
 
23
- return { allow and 1 or 0, tokens }
23
+ return { allow and 1 or 0, tokens }
@@ -11,9 +11,7 @@ module ThrottleMachines
11
11
  PEEK_GCRA_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'peek_gcra.lua'))
12
12
  PEEK_TOKEN_BUCKET_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'peek_token_bucket.lua'))
13
13
  INCREMENT_COUNTER_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'increment_counter.lua'))
14
- GET_BREAKER_STATE_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'get_breaker_state.lua'))
15
- RECORD_BREAKER_SUCCESS_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'record_breaker_success.lua'))
16
- RECORD_BREAKER_FAILURE_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'record_breaker_failure.lua'))
14
+ # Breaker scripts removed: breaker state is owned by BreakerMachines
17
15
 
18
16
  def initialize(options = {})
19
17
  super
@@ -165,68 +163,7 @@ module ThrottleMachines
165
163
  retry
166
164
  end
167
165
 
168
- # Circuit breaker operations
169
- def get_breaker_state(key)
170
- breaker_key = prefixed("breaker:#{key}")
171
-
172
- # Use Lua script for atomic read and potential state transition
173
- result = with_redis do |redis|
174
- redis.eval(GET_BREAKER_STATE_SCRIPT, keys: [breaker_key], argv: [current_time])
175
- end
176
-
177
- return { state: :closed, failures: 0, last_failure: nil } if result.empty?
178
-
179
- # Convert hash from Lua to Ruby format
180
- state = {}
181
- result.each_slice(2) { |k, v| state[k] = v }
182
-
183
- {
184
- state: state['state'].to_sym,
185
- failures: state['failures'].to_i,
186
- last_failure: state['last_failure']&.to_f,
187
- opens_at: state['opens_at']&.to_f,
188
- half_open_attempts: state['half_open_attempts']&.to_i
189
- }
190
- end
191
-
192
- def record_breaker_success(key, _timeout, half_open_requests = 1)
193
- breaker_key = prefixed("breaker:#{key}")
194
-
195
- # Use Lua script for atomic success recording
196
- with_redis do |redis|
197
- redis.eval(RECORD_BREAKER_SUCCESS_SCRIPT, keys: [breaker_key], argv: [half_open_requests])
198
- end
199
- end
200
-
201
- def record_breaker_failure(key, threshold, timeout)
202
- breaker_key = prefixed("breaker:#{key}")
203
- now = current_time
204
-
205
- # Use Lua script for atomic failure recording
206
- with_redis do |redis|
207
- redis.eval(RECORD_BREAKER_FAILURE_SCRIPT, keys: [breaker_key], argv: [threshold, timeout, now])
208
- end
209
-
210
- get_breaker_state(key)
211
- end
212
-
213
- def trip_breaker(key, timeout)
214
- breaker_key = prefixed("breaker:#{key}")
215
- now = current_time
216
-
217
- with_redis do |redis|
218
- redis.hmset(breaker_key,
219
- 'state', 'open',
220
- 'failures', 0,
221
- 'last_failure', now,
222
- 'opens_at', now + timeout)
223
- redis.expire(breaker_key, (timeout * 2).to_i)
224
- end
225
- end
226
-
227
- def reset_breaker(key)
228
- with_redis { |r| r.del(prefixed("breaker:#{key}")) }
229
- end
166
+ # No circuit breaker operations: breaker state is owned by BreakerMachines
230
167
 
231
168
  # Utility operations
232
169
  def clear(pattern = nil)
@@ -280,13 +217,13 @@ module ThrottleMachines
280
217
  raise ArgumentError, "Invalid Redis connection: #{e.message}"
281
218
  end
282
219
 
283
- def with_redis(&)
220
+ def with_redis(&block)
284
221
  if @redis.respond_to?(:with)
285
222
  # Connection pool
286
- @redis.with(&)
223
+ @redis.with(&block)
287
224
  else
288
225
  # Regular Redis client
289
- yield @redis
226
+ block.call(@redis)
290
227
  end
291
228
  end
292
229
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThrottleMachines
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.4'
5
5
  end