breaker_machines 0.1.0 → 0.2.0
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 +77 -1826
- data/lib/breaker_machines/async_support.rb +103 -0
- data/lib/breaker_machines/circuit/callbacks.rb +66 -58
- data/lib/breaker_machines/circuit/configuration.rb +17 -3
- data/lib/breaker_machines/circuit/execution.rb +82 -58
- data/lib/breaker_machines/circuit.rb +1 -0
- data/lib/breaker_machines/dsl.rb +229 -10
- data/lib/breaker_machines/errors.rb +11 -0
- data/lib/breaker_machines/hedged_async_support.rb +95 -0
- data/lib/breaker_machines/hedged_execution.rb +113 -0
- data/lib/breaker_machines/registry.rb +144 -0
- data/lib/breaker_machines/storage/cache.rb +162 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +3 -1
- metadata +5 -1
data/lib/breaker_machines/dsl.rb
CHANGED
@@ -67,6 +67,36 @@ module BreakerMachines
|
|
67
67
|
end
|
68
68
|
end
|
69
69
|
|
70
|
+
# Define reusable circuit templates
|
71
|
+
def circuit_template(name, &block)
|
72
|
+
@circuit_templates ||= {}
|
73
|
+
|
74
|
+
if block_given?
|
75
|
+
builder = CircuitBuilder.new
|
76
|
+
builder.instance_eval(&block)
|
77
|
+
@circuit_templates[name] = builder.config
|
78
|
+
end
|
79
|
+
|
80
|
+
@circuit_templates[name]
|
81
|
+
end
|
82
|
+
|
83
|
+
# Get all circuit templates
|
84
|
+
def circuit_templates
|
85
|
+
# Start with parent templates if available
|
86
|
+
base_templates = if superclass.respond_to?(:circuit_templates)
|
87
|
+
superclass.circuit_templates.deep_dup
|
88
|
+
else
|
89
|
+
{}
|
90
|
+
end
|
91
|
+
|
92
|
+
# Merge with our own templates
|
93
|
+
if @circuit_templates
|
94
|
+
base_templates.merge(@circuit_templates)
|
95
|
+
else
|
96
|
+
base_templates
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
70
100
|
# Get circuit definitions without sensitive data
|
71
101
|
def circuit_definitions
|
72
102
|
circuits.transform_values { |config| config.except(:owner, :storage, :metrics) }
|
@@ -129,6 +159,70 @@ module BreakerMachines
|
|
129
159
|
@circuit_instances[name] ||= Circuit.new(name, self.class.circuits[name].merge(owner: self))
|
130
160
|
end
|
131
161
|
|
162
|
+
# Create a dynamic circuit breaker with inline configuration
|
163
|
+
# Options:
|
164
|
+
# global: true - Store circuit globally, preventing memory leaks in long-lived objects
|
165
|
+
# global: false - Store circuit locally in this instance (default, backward compatible)
|
166
|
+
def dynamic_circuit(name, template: nil, global: false, &config_block)
|
167
|
+
# Start with template config if provided
|
168
|
+
base_config = if template && self.class.circuit_templates[template]
|
169
|
+
self.class.circuit_templates[template].deep_dup
|
170
|
+
else
|
171
|
+
default_circuit_config
|
172
|
+
end
|
173
|
+
|
174
|
+
# Apply additional configuration if block provided
|
175
|
+
if config_block
|
176
|
+
builder = CircuitBuilder.new
|
177
|
+
builder.instance_variable_set(:@config, base_config.deep_dup)
|
178
|
+
builder.instance_eval(&config_block)
|
179
|
+
base_config = builder.config
|
180
|
+
end
|
181
|
+
|
182
|
+
if global
|
183
|
+
# Use global registry to prevent memory leaks
|
184
|
+
BreakerMachines.registry.get_or_create_dynamic_circuit(name, self, base_config)
|
185
|
+
else
|
186
|
+
# Local storage (backward compatible)
|
187
|
+
@circuit_instances ||= {}
|
188
|
+
@circuit_instances[name] ||= Circuit.new(name, base_config.merge(owner: self))
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Apply a template to an existing or new circuit
|
193
|
+
def apply_template(circuit_name, template_name)
|
194
|
+
template_config = self.class.circuit_templates[template_name]
|
195
|
+
raise ArgumentError, "Template '#{template_name}' not found" unless template_config
|
196
|
+
|
197
|
+
@circuit_instances ||= {}
|
198
|
+
@circuit_instances[circuit_name] = Circuit.new(circuit_name, template_config.merge(owner: self))
|
199
|
+
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
def default_circuit_config
|
204
|
+
{
|
205
|
+
failure_threshold: 5,
|
206
|
+
failure_window: 60,
|
207
|
+
success_threshold: 1,
|
208
|
+
timeout: nil,
|
209
|
+
reset_timeout: 60,
|
210
|
+
half_open_calls: 1,
|
211
|
+
exceptions: [StandardError],
|
212
|
+
storage: nil,
|
213
|
+
metrics: nil,
|
214
|
+
fallback: nil,
|
215
|
+
on_open: nil,
|
216
|
+
on_close: nil,
|
217
|
+
on_half_open: nil,
|
218
|
+
on_reject: nil,
|
219
|
+
notifications: [],
|
220
|
+
fiber_safe: BreakerMachines.config.fiber_safe
|
221
|
+
}
|
222
|
+
end
|
223
|
+
|
224
|
+
public
|
225
|
+
|
132
226
|
# Get all circuit instances for this object
|
133
227
|
def circuit_instances
|
134
228
|
@circuit_instances || {}
|
@@ -149,6 +243,21 @@ module BreakerMachines
|
|
149
243
|
circuit_instances.each_value(&:reset)
|
150
244
|
end
|
151
245
|
|
246
|
+
# Remove a global dynamic circuit by name
|
247
|
+
def remove_dynamic_circuit(name)
|
248
|
+
BreakerMachines.registry.remove_dynamic_circuit(name)
|
249
|
+
end
|
250
|
+
|
251
|
+
# Get all dynamic circuit names from global registry
|
252
|
+
def dynamic_circuit_names
|
253
|
+
BreakerMachines.registry.dynamic_circuit_names
|
254
|
+
end
|
255
|
+
|
256
|
+
# Cleanup stale dynamic circuits (global)
|
257
|
+
def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
|
258
|
+
BreakerMachines.registry.cleanup_stale_dynamic_circuits(max_age_seconds)
|
259
|
+
end
|
260
|
+
|
152
261
|
# DSL builder for configuring circuit breakers with a fluent interface
|
153
262
|
class CircuitBuilder
|
154
263
|
attr_reader :config
|
@@ -156,10 +265,10 @@ module BreakerMachines
|
|
156
265
|
def initialize
|
157
266
|
@config = {
|
158
267
|
failure_threshold: 5,
|
159
|
-
failure_window: 60,
|
268
|
+
failure_window: 60.seconds,
|
160
269
|
success_threshold: 1,
|
161
270
|
timeout: nil,
|
162
|
-
reset_timeout: 60,
|
271
|
+
reset_timeout: 60.seconds,
|
163
272
|
half_open_calls: 1,
|
164
273
|
exceptions: [StandardError],
|
165
274
|
storage: nil,
|
@@ -174,22 +283,48 @@ module BreakerMachines
|
|
174
283
|
}
|
175
284
|
end
|
176
285
|
|
177
|
-
def threshold(failures: nil, within: 60, successes: nil)
|
178
|
-
|
286
|
+
def threshold(failures: nil, failure_rate: nil, minimum_calls: nil, within: 60.seconds, successes: nil)
|
287
|
+
if failure_rate
|
288
|
+
# Rate-based threshold
|
289
|
+
validate_failure_rate!(failure_rate)
|
290
|
+
validate_positive_integer!(:minimum_calls, minimum_calls) if minimum_calls
|
291
|
+
|
292
|
+
@config[:failure_rate] = failure_rate
|
293
|
+
@config[:minimum_calls] = minimum_calls || 5
|
294
|
+
@config[:use_rate_threshold] = true
|
295
|
+
elsif failures
|
296
|
+
# Absolute count threshold (existing behavior)
|
297
|
+
validate_positive_integer!(:failures, failures)
|
298
|
+
@config[:failure_threshold] = failures
|
299
|
+
@config[:use_rate_threshold] = false
|
300
|
+
end
|
301
|
+
|
302
|
+
validate_positive_integer!(:within, within.to_i)
|
179
303
|
@config[:failure_window] = within.to_i
|
180
|
-
|
304
|
+
|
305
|
+
return unless successes
|
306
|
+
|
307
|
+
validate_positive_integer!(:successes, successes)
|
308
|
+
@config[:success_threshold] = successes
|
181
309
|
end
|
182
310
|
|
183
311
|
def reset_after(duration, jitter: nil)
|
312
|
+
validate_positive_integer!(:duration, duration.to_i)
|
184
313
|
@config[:reset_timeout] = duration.to_i
|
185
|
-
|
314
|
+
|
315
|
+
return unless jitter
|
316
|
+
|
317
|
+
validate_jitter!(jitter)
|
318
|
+
@config[:reset_timeout_jitter] = jitter
|
186
319
|
end
|
187
320
|
|
188
321
|
def timeout(duration)
|
322
|
+
validate_non_negative_integer!(:timeout, duration.to_i)
|
189
323
|
@config[:timeout] = duration.to_i
|
190
324
|
end
|
191
325
|
|
192
326
|
def half_open_requests(count)
|
327
|
+
validate_positive_integer!(:half_open_requests, count)
|
193
328
|
@config[:half_open_calls] = count
|
194
329
|
end
|
195
330
|
|
@@ -199,6 +334,8 @@ module BreakerMachines
|
|
199
334
|
Storage::Memory.new(**)
|
200
335
|
when :bucket_memory
|
201
336
|
Storage::BucketMemory.new(**)
|
337
|
+
when :cache
|
338
|
+
Storage::Cache.new(**)
|
202
339
|
when :redis
|
203
340
|
Storage::Redis.new(**)
|
204
341
|
when Class
|
@@ -242,6 +379,26 @@ module BreakerMachines
|
|
242
379
|
@config[:on_reject] = block
|
243
380
|
end
|
244
381
|
|
382
|
+
# Configure hedged requests
|
383
|
+
def hedged(&)
|
384
|
+
if block_given?
|
385
|
+
hedged_builder = HedgedBuilder.new(@config)
|
386
|
+
hedged_builder.instance_eval(&)
|
387
|
+
else
|
388
|
+
@config[:hedged_requests] = true
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# Configure multiple backends
|
393
|
+
def backends(*backend_list)
|
394
|
+
@config[:backends] = backend_list.flatten
|
395
|
+
end
|
396
|
+
|
397
|
+
# Configure parallel fallback execution
|
398
|
+
def parallel_fallback(fallback_list)
|
399
|
+
@config[:fallback] = ParallelFallbackWrapper.new(fallback_list)
|
400
|
+
end
|
401
|
+
|
245
402
|
def notify(service, url = nil, events: %i[open close], **options)
|
246
403
|
notification = {
|
247
404
|
via: service,
|
@@ -256,19 +413,81 @@ module BreakerMachines
|
|
256
413
|
@config[:exceptions] = exceptions
|
257
414
|
end
|
258
415
|
|
259
|
-
def fiber_safe(enabled
|
416
|
+
def fiber_safe(enabled: true)
|
260
417
|
@config[:fiber_safe] = enabled
|
261
418
|
end
|
262
419
|
|
263
|
-
|
264
|
-
|
265
|
-
@config[:
|
420
|
+
def max_concurrent(limit)
|
421
|
+
validate_positive_integer!(:max_concurrent, limit)
|
422
|
+
@config[:max_concurrent] = limit
|
266
423
|
end
|
267
424
|
|
425
|
+
# Advanced features
|
268
426
|
def parallel_calls(count, timeout: nil)
|
269
427
|
@config[:parallel_calls] = count
|
270
428
|
@config[:parallel_timeout] = timeout
|
271
429
|
end
|
430
|
+
|
431
|
+
private
|
432
|
+
|
433
|
+
def validate_positive_integer!(name, value)
|
434
|
+
return if value.is_a?(Integer) && value.positive?
|
435
|
+
|
436
|
+
raise BreakerMachines::ConfigurationError,
|
437
|
+
"#{name} must be a positive integer, got: #{value.inspect}"
|
438
|
+
end
|
439
|
+
|
440
|
+
def validate_non_negative_integer!(name, value)
|
441
|
+
return if value.is_a?(Integer) && value >= 0
|
442
|
+
|
443
|
+
raise BreakerMachines::ConfigurationError,
|
444
|
+
"#{name} must be a non-negative integer, got: #{value.inspect}"
|
445
|
+
end
|
446
|
+
|
447
|
+
def validate_failure_rate!(rate)
|
448
|
+
return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
|
449
|
+
|
450
|
+
raise BreakerMachines::ConfigurationError,
|
451
|
+
"failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
|
452
|
+
end
|
453
|
+
|
454
|
+
def validate_jitter!(jitter)
|
455
|
+
return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
|
456
|
+
|
457
|
+
raise BreakerMachines::ConfigurationError,
|
458
|
+
"jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
|
459
|
+
end
|
460
|
+
end
|
461
|
+
|
462
|
+
# Builder for hedged request configuration
|
463
|
+
class HedgedBuilder
|
464
|
+
def initialize(config)
|
465
|
+
@config = config
|
466
|
+
@config[:hedged_requests] = true
|
467
|
+
end
|
468
|
+
|
469
|
+
def delay(milliseconds)
|
470
|
+
@config[:hedging_delay] = milliseconds
|
471
|
+
end
|
472
|
+
|
473
|
+
def max_requests(count)
|
474
|
+
@config[:max_hedged_requests] = count
|
475
|
+
end
|
476
|
+
end
|
477
|
+
|
478
|
+
# Wrapper to indicate parallel execution for fallbacks
|
479
|
+
class ParallelFallbackWrapper
|
480
|
+
attr_reader :fallbacks
|
481
|
+
|
482
|
+
def initialize(fallbacks)
|
483
|
+
@fallbacks = fallbacks
|
484
|
+
end
|
485
|
+
|
486
|
+
def call(error)
|
487
|
+
# This will be handled by the circuit's fallback mechanism
|
488
|
+
# to execute fallbacks in parallel
|
489
|
+
raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
|
490
|
+
end
|
272
491
|
end
|
273
492
|
end
|
274
493
|
end
|
@@ -27,4 +27,15 @@ module BreakerMachines
|
|
27
27
|
|
28
28
|
class ConfigurationError < Error; end
|
29
29
|
class StorageError < Error; end
|
30
|
+
|
31
|
+
# Raised when circuit rejects call due to bulkhead limit
|
32
|
+
class CircuitBulkheadError < Error
|
33
|
+
attr_reader :circuit_name, :max_concurrent
|
34
|
+
|
35
|
+
def initialize(circuit_name, max_concurrent)
|
36
|
+
@circuit_name = circuit_name
|
37
|
+
@max_concurrent = max_concurrent
|
38
|
+
super("Circuit '#{circuit_name}' rejected call: max concurrent limit of #{max_concurrent} reached")
|
39
|
+
end
|
40
|
+
end
|
30
41
|
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file contains async support for hedged execution
|
4
|
+
# It is only loaded when fiber_safe mode is enabled
|
5
|
+
|
6
|
+
require 'async'
|
7
|
+
require 'async/task'
|
8
|
+
require 'async/condition'
|
9
|
+
require 'concurrent'
|
10
|
+
|
11
|
+
module BreakerMachines
|
12
|
+
# AsyncSupport for HedgedExecution
|
13
|
+
module HedgedAsyncSupport
|
14
|
+
# Execute hedged requests with configurable delay between attempts
|
15
|
+
# @param callables [Array<Proc>] Array of callables to execute
|
16
|
+
# @param delay_ms [Integer] Milliseconds to wait before starting hedged requests
|
17
|
+
# @return [Object] Result from the first successful callable
|
18
|
+
# @raise [StandardError] If all callables fail
|
19
|
+
def execute_hedged_with_async(callables, delay_ms)
|
20
|
+
race_tasks(callables, delay_ms: delay_ms)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Execute parallel fallbacks without delay
|
24
|
+
# @param fallbacks [Array<Proc,Object>] Array of fallback values or callables
|
25
|
+
# @return [Object] Result from the first successful fallback
|
26
|
+
# @raise [StandardError] If all fallbacks fail
|
27
|
+
def execute_parallel_fallbacks_async(fallbacks)
|
28
|
+
# Normalize fallbacks to callables
|
29
|
+
callables = fallbacks.map do |fallback|
|
30
|
+
case fallback
|
31
|
+
when Proc
|
32
|
+
# Handle procs with different arities
|
33
|
+
-> { fallback.arity == 1 ? fallback.call(nil) : fallback.call }
|
34
|
+
else
|
35
|
+
# Wrap static values in callables
|
36
|
+
-> { fallback }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
race_tasks(callables, delay_ms: 0)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# Race callables; return first result or raise if it was an Exception
|
46
|
+
# Uses Async::Condition to signal the winner instead of an Async::Channel.
|
47
|
+
# @param callables [Array<Proc>] Tasks to race
|
48
|
+
# @param delay_ms [Integer] Delay in milliseconds between task starts
|
49
|
+
# @return [Object] First successful result
|
50
|
+
# @raise [Exception] The first exception received
|
51
|
+
def race_tasks(callables, delay_ms: 0)
|
52
|
+
Async do |parent|
|
53
|
+
mutex = Mutex.new
|
54
|
+
condition = Async::Condition.new
|
55
|
+
winner = nil
|
56
|
+
exception = nil
|
57
|
+
|
58
|
+
tasks = callables.map.with_index do |callable, idx|
|
59
|
+
parent.async do |task|
|
60
|
+
# stagger hedged attempts
|
61
|
+
task.sleep(delay_ms / 1000.0) if idx.positive? && delay_ms.positive?
|
62
|
+
|
63
|
+
begin
|
64
|
+
res = callable.call
|
65
|
+
mutex.synchronize do
|
66
|
+
next if winner || exception
|
67
|
+
|
68
|
+
winner = res
|
69
|
+
condition.signal
|
70
|
+
end
|
71
|
+
rescue StandardError => e
|
72
|
+
mutex.synchronize do
|
73
|
+
next if winner || exception
|
74
|
+
|
75
|
+
exception = e
|
76
|
+
condition.signal
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# block until first signal
|
83
|
+
condition.wait
|
84
|
+
|
85
|
+
# tear down
|
86
|
+
tasks.each(&:stop)
|
87
|
+
|
88
|
+
# propagate
|
89
|
+
raise(exception) if exception
|
90
|
+
|
91
|
+
winner
|
92
|
+
end.wait
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
module BreakerMachines
|
7
|
+
# HedgedExecution provides hedged request functionality for circuit breakers
|
8
|
+
# Hedged requests improve latency by sending duplicate requests to multiple backends
|
9
|
+
# and returning the first successful response
|
10
|
+
module HedgedExecution
|
11
|
+
extend ActiveSupport::Concern
|
12
|
+
|
13
|
+
# Execute a hedged request pattern
|
14
|
+
def execute_hedged(&)
|
15
|
+
return execute_single_hedged(&) unless @config[:backends]&.any?
|
16
|
+
|
17
|
+
execute_multi_backend_hedged
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# Execute hedged request with a single backend (original block)
|
23
|
+
def execute_single_hedged(&block)
|
24
|
+
return yield unless hedged_requests_enabled?
|
25
|
+
|
26
|
+
max_requests = @config[:max_hedged_requests] || 2
|
27
|
+
delay_ms = @config[:hedging_delay] || 50
|
28
|
+
|
29
|
+
if @config[:fiber_safe]
|
30
|
+
execute_hedged_async(Array.new(max_requests) { block }, delay_ms)
|
31
|
+
else
|
32
|
+
execute_hedged_sync(Array.new(max_requests) { block }, delay_ms)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Execute hedged requests across multiple backends
|
37
|
+
def execute_multi_backend_hedged
|
38
|
+
backends = @config[:backends]
|
39
|
+
return backends.first.call if backends.size == 1
|
40
|
+
|
41
|
+
if @config[:fiber_safe]
|
42
|
+
execute_hedged_async(backends, @config[:hedging_delay] || 0)
|
43
|
+
else
|
44
|
+
execute_hedged_sync(backends, @config[:hedging_delay] || 0)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Synchronous hedged execution using threads
|
49
|
+
def execute_hedged_sync(callables, delay_ms)
|
50
|
+
result_queue = Queue.new
|
51
|
+
error_queue = Queue.new
|
52
|
+
threads = []
|
53
|
+
cancelled = Concurrent::AtomicBoolean.new(false)
|
54
|
+
|
55
|
+
callables.each_with_index do |callable, index|
|
56
|
+
# Add delay for hedge requests (not the first one)
|
57
|
+
sleep(delay_ms / 1000.0) if index.positive? && delay_ms.positive?
|
58
|
+
|
59
|
+
# Skip if already got a result
|
60
|
+
break if cancelled.value
|
61
|
+
|
62
|
+
threads << Thread.new do
|
63
|
+
unless cancelled.value
|
64
|
+
begin
|
65
|
+
result = callable.call
|
66
|
+
result_queue << result unless cancelled.value
|
67
|
+
cancelled.value = true
|
68
|
+
rescue StandardError => e
|
69
|
+
error_queue << e unless cancelled.value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Wait for first result or all errors
|
76
|
+
begin
|
77
|
+
Timeout.timeout(@config[:timeout] || 30) do
|
78
|
+
# Check for successful result
|
79
|
+
loop do
|
80
|
+
unless result_queue.empty?
|
81
|
+
result = result_queue.pop
|
82
|
+
cancelled.value = true
|
83
|
+
return result
|
84
|
+
end
|
85
|
+
|
86
|
+
# Check if all requests failed
|
87
|
+
raise error_queue.pop if error_queue.size >= callables.size
|
88
|
+
|
89
|
+
# Small sleep to prevent busy waiting
|
90
|
+
sleep 0.001
|
91
|
+
end
|
92
|
+
end
|
93
|
+
ensure
|
94
|
+
# Cancel remaining threads
|
95
|
+
cancelled.value = true
|
96
|
+
threads.each(&:kill)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Async hedged execution (requires async support)
|
101
|
+
def execute_hedged_async(callables, delay_ms)
|
102
|
+
# This will be implemented when async support is loaded
|
103
|
+
# For now, fall back to sync implementation
|
104
|
+
return execute_hedged_sync(callables, delay_ms) unless respond_to?(:execute_hedged_with_async)
|
105
|
+
|
106
|
+
execute_hedged_with_async(callables, delay_ms)
|
107
|
+
end
|
108
|
+
|
109
|
+
def hedged_requests_enabled?
|
110
|
+
@config[:hedged_requests] == true
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -10,6 +10,7 @@ module BreakerMachines
|
|
10
10
|
|
11
11
|
def initialize
|
12
12
|
@circuits = Concurrent::Map.new
|
13
|
+
@named_circuits = Concurrent::Map.new # For dynamic circuits by name
|
13
14
|
@mutex = Mutex.new
|
14
15
|
@registration_count = 0
|
15
16
|
@cleanup_interval = 100 # Clean up every N registrations
|
@@ -53,6 +54,38 @@ module BreakerMachines
|
|
53
54
|
all_circuits.select { |circuit| circuit.name == name }
|
54
55
|
end
|
55
56
|
|
57
|
+
# Find first circuit by name
|
58
|
+
def find(name)
|
59
|
+
find_by_name(name).first
|
60
|
+
end
|
61
|
+
|
62
|
+
# Force open a circuit by name
|
63
|
+
def force_open(name) # rubocop:disable Naming/PredicateMethod
|
64
|
+
circuits = find_by_name(name)
|
65
|
+
return false if circuits.empty?
|
66
|
+
|
67
|
+
circuits.each(&:force_open)
|
68
|
+
true
|
69
|
+
end
|
70
|
+
|
71
|
+
# Force close a circuit by name
|
72
|
+
def force_close(name) # rubocop:disable Naming/PredicateMethod
|
73
|
+
circuits = find_by_name(name)
|
74
|
+
return false if circuits.empty?
|
75
|
+
|
76
|
+
circuits.each(&:force_close)
|
77
|
+
true
|
78
|
+
end
|
79
|
+
|
80
|
+
# Reset a circuit by name
|
81
|
+
def reset(name) # rubocop:disable Naming/PredicateMethod
|
82
|
+
circuits = find_by_name(name)
|
83
|
+
return false if circuits.empty?
|
84
|
+
|
85
|
+
circuits.each(&:reset)
|
86
|
+
true
|
87
|
+
end
|
88
|
+
|
56
89
|
# Get summary statistics
|
57
90
|
def stats_summary
|
58
91
|
circuits = all_circuits
|
@@ -63,15 +96,126 @@ module BreakerMachines
|
|
63
96
|
}
|
64
97
|
end
|
65
98
|
|
99
|
+
# Get all stats with detailed metrics
|
100
|
+
def all_stats
|
101
|
+
circuits = all_circuits
|
102
|
+
|
103
|
+
{
|
104
|
+
summary: stats_summary,
|
105
|
+
circuits: circuits.map(&:stats),
|
106
|
+
health: {
|
107
|
+
open_count: circuits.count(&:open?),
|
108
|
+
closed_count: circuits.count(&:closed?),
|
109
|
+
half_open_count: circuits.count(&:half_open?),
|
110
|
+
total_failures: circuits.sum { |c| c.stats[:failure_count] },
|
111
|
+
total_successes: circuits.sum { |c| c.stats[:success_count] }
|
112
|
+
}
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
66
116
|
# Get detailed information for all circuits
|
67
117
|
def detailed_report
|
68
118
|
all_circuits.map(&:to_h)
|
69
119
|
end
|
70
120
|
|
121
|
+
# Get or create a globally managed dynamic circuit
|
122
|
+
def get_or_create_dynamic_circuit(name, owner, config)
|
123
|
+
@mutex.synchronize do
|
124
|
+
# Check if circuit already exists and is still alive
|
125
|
+
if @named_circuits.key?(name)
|
126
|
+
weak_ref = @named_circuits[name]
|
127
|
+
begin
|
128
|
+
existing_circuit = weak_ref.__getobj__
|
129
|
+
return existing_circuit if existing_circuit
|
130
|
+
rescue WeakRef::RefError
|
131
|
+
# Circuit was garbage collected, remove the stale reference
|
132
|
+
@named_circuits.delete(name)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Create new circuit with weak owner reference
|
137
|
+
# Don't auto-register to avoid deadlock
|
138
|
+
weak_owner = owner.is_a?(WeakRef) ? owner : WeakRef.new(owner)
|
139
|
+
circuit_config = config.merge(owner: weak_owner, auto_register: false)
|
140
|
+
new_circuit = Circuit.new(name, circuit_config)
|
141
|
+
|
142
|
+
# Manually register the circuit (we're already in sync block)
|
143
|
+
@circuits[new_circuit] = WeakRef.new(new_circuit)
|
144
|
+
@named_circuits[name] = WeakRef.new(new_circuit)
|
145
|
+
|
146
|
+
new_circuit
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Remove a dynamic circuit by name
|
151
|
+
def remove_dynamic_circuit(name)
|
152
|
+
@mutex.synchronize do
|
153
|
+
if @named_circuits.key?(name)
|
154
|
+
weak_ref = @named_circuits.delete(name)
|
155
|
+
begin
|
156
|
+
circuit = weak_ref.__getobj__
|
157
|
+
@circuits.delete(circuit) if circuit
|
158
|
+
true
|
159
|
+
rescue WeakRef::RefError
|
160
|
+
false
|
161
|
+
end
|
162
|
+
else
|
163
|
+
false
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get all dynamic circuit names
|
169
|
+
def dynamic_circuit_names
|
170
|
+
@mutex.synchronize do
|
171
|
+
alive_names = []
|
172
|
+
@named_circuits.each_pair do |name, weak_ref|
|
173
|
+
weak_ref.__getobj__
|
174
|
+
alive_names << name
|
175
|
+
rescue WeakRef::RefError
|
176
|
+
@named_circuits.delete(name)
|
177
|
+
end
|
178
|
+
alive_names
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Cleanup stale dynamic circuits older than given age
|
183
|
+
def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
|
184
|
+
@mutex.synchronize do
|
185
|
+
cutoff_time = Time.now - max_age_seconds
|
186
|
+
stale_names = []
|
187
|
+
|
188
|
+
@named_circuits.each_pair do |name, weak_ref|
|
189
|
+
circuit = weak_ref.__getobj__
|
190
|
+
# Check if circuit has a last_activity_time and it's stale
|
191
|
+
if circuit.respond_to?(:last_activity_time) &&
|
192
|
+
circuit.last_activity_time &&
|
193
|
+
circuit.last_activity_time < cutoff_time
|
194
|
+
stale_names << name
|
195
|
+
end
|
196
|
+
rescue WeakRef::RefError
|
197
|
+
stale_names << name
|
198
|
+
end
|
199
|
+
|
200
|
+
stale_names.each do |name|
|
201
|
+
weak_ref = @named_circuits.delete(name)
|
202
|
+
begin
|
203
|
+
circuit = weak_ref.__getobj__
|
204
|
+
@circuits.delete(circuit) if circuit
|
205
|
+
rescue WeakRef::RefError
|
206
|
+
# Already gone
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
stale_names.size
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
71
214
|
# Clear all circuits (useful for testing)
|
72
215
|
def clear
|
73
216
|
@mutex.synchronize do
|
74
217
|
@circuits.clear
|
218
|
+
@named_circuits.clear
|
75
219
|
end
|
76
220
|
end
|
77
221
|
|