breaker_machines 0.9.2-aarch64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/ext/breaker_machines_native/extconf.rb +3 -0
- data/lib/breaker_machines/async_circuit.rb +47 -0
- data/lib/breaker_machines/async_support.rb +104 -0
- data/lib/breaker_machines/cascading_circuit.rb +177 -0
- data/lib/breaker_machines/circuit/async_state_management.rb +71 -0
- data/lib/breaker_machines/circuit/base.rb +59 -0
- data/lib/breaker_machines/circuit/callbacks.rb +135 -0
- data/lib/breaker_machines/circuit/configuration.rb +67 -0
- data/lib/breaker_machines/circuit/coordinated_state_management.rb +117 -0
- data/lib/breaker_machines/circuit/execution.rb +231 -0
- data/lib/breaker_machines/circuit/hedged_execution.rb +115 -0
- data/lib/breaker_machines/circuit/introspection.rb +93 -0
- data/lib/breaker_machines/circuit/native.rb +127 -0
- data/lib/breaker_machines/circuit/state_callbacks.rb +72 -0
- data/lib/breaker_machines/circuit/state_management.rb +59 -0
- data/lib/breaker_machines/circuit.rb +8 -0
- data/lib/breaker_machines/circuit_group.rb +153 -0
- data/lib/breaker_machines/console.rb +345 -0
- data/lib/breaker_machines/coordinated_circuit.rb +10 -0
- data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
- data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
- data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
- data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
- data/lib/breaker_machines/dsl.rb +283 -0
- data/lib/breaker_machines/errors.rb +71 -0
- data/lib/breaker_machines/hedged_async_support.rb +88 -0
- data/lib/breaker_machines/native_extension.rb +81 -0
- data/lib/breaker_machines/native_speedup.rb +10 -0
- data/lib/breaker_machines/registry.rb +243 -0
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/base.rb +52 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +176 -0
- data/lib/breaker_machines/storage/cache.rb +169 -0
- data/lib/breaker_machines/storage/fallback_chain.rb +294 -0
- data/lib/breaker_machines/storage/memory.rb +140 -0
- data/lib/breaker_machines/storage/native.rb +93 -0
- data/lib/breaker_machines/storage/null.rb +54 -0
- data/lib/breaker_machines/storage.rb +8 -0
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +5 -0
- data/lib/breaker_machines.rb +200 -0
- data/lib/breaker_machines_native/breaker_machines_native.so +0 -0
- data/sig/README.md +74 -0
- data/sig/all.rbs +25 -0
- data/sig/breaker_machines/circuit.rbs +154 -0
- data/sig/breaker_machines/console.rbs +32 -0
- data/sig/breaker_machines/dsl.rbs +50 -0
- data/sig/breaker_machines/errors.rbs +24 -0
- data/sig/breaker_machines/interfaces.rbs +46 -0
- data/sig/breaker_machines/registry.rbs +30 -0
- data/sig/breaker_machines/storage.rbs +65 -0
- data/sig/breaker_machines/types.rbs +97 -0
- data/sig/breaker_machines.rbs +42 -0
- data/sig/manifest.yaml +5 -0
- metadata +224 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
class Circuit
|
|
5
|
+
# Callbacks handles the invocation of user-defined callbacks and fallback mechanisms
|
|
6
|
+
# when circuit state changes occur or calls are rejected.
|
|
7
|
+
module Callbacks
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def invoke_callback(callback_name)
|
|
13
|
+
callback = @config[callback_name]
|
|
14
|
+
return unless callback
|
|
15
|
+
|
|
16
|
+
return unless callback.is_a?(Proc)
|
|
17
|
+
|
|
18
|
+
if @config[:owner]
|
|
19
|
+
owner = resolve_owner
|
|
20
|
+
if owner
|
|
21
|
+
owner.instance_exec(&callback)
|
|
22
|
+
else
|
|
23
|
+
# Owner has been garbage collected, execute callback without context
|
|
24
|
+
callback.call
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
callback.call
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def invoke_fallback(error)
|
|
32
|
+
case @config[:fallback]
|
|
33
|
+
when BreakerMachines::DSL::ParallelFallbackWrapper
|
|
34
|
+
invoke_parallel_fallbacks(@config[:fallback].fallbacks, error)
|
|
35
|
+
when Proc
|
|
36
|
+
if @config[:owner]
|
|
37
|
+
owner = resolve_owner
|
|
38
|
+
if owner
|
|
39
|
+
owner.instance_exec(error, &@config[:fallback])
|
|
40
|
+
else
|
|
41
|
+
# Owner has been garbage collected, execute fallback without context
|
|
42
|
+
@config[:fallback].call(error)
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
@config[:fallback].call(error)
|
|
46
|
+
end
|
|
47
|
+
when Array
|
|
48
|
+
# Try each fallback in order until one succeeds
|
|
49
|
+
last_error = error
|
|
50
|
+
@config[:fallback].each do |fallback|
|
|
51
|
+
return invoke_single_fallback(fallback, last_error)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
last_error = e
|
|
54
|
+
end
|
|
55
|
+
raise last_error
|
|
56
|
+
else
|
|
57
|
+
# Static values (strings, hashes, etc.) or Symbol fallbacks
|
|
58
|
+
@config[:fallback]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def invoke_single_fallback(fallback, error)
|
|
63
|
+
case fallback
|
|
64
|
+
when Proc
|
|
65
|
+
if @config[:owner]
|
|
66
|
+
owner = resolve_owner
|
|
67
|
+
if owner
|
|
68
|
+
owner.instance_exec(error, &fallback)
|
|
69
|
+
else
|
|
70
|
+
fallback.call(error)
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
fallback.call(error)
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
fallback
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Safely resolve owner from WeakRef if applicable
|
|
81
|
+
def resolve_owner
|
|
82
|
+
owner = @config[:owner]
|
|
83
|
+
return owner unless owner.is_a?(WeakRef)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
owner.__getobj__
|
|
87
|
+
rescue WeakRef::RefError
|
|
88
|
+
# Owner has been garbage collected
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def invoke_parallel_fallbacks(fallbacks, error)
|
|
94
|
+
return fallbacks.first if fallbacks.size == 1
|
|
95
|
+
|
|
96
|
+
if @config[:fiber_safe] && respond_to?(:execute_parallel_fallbacks_async)
|
|
97
|
+
execute_parallel_fallbacks_async(fallbacks)
|
|
98
|
+
else
|
|
99
|
+
execute_parallel_fallbacks_sync(fallbacks, error)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def execute_parallel_fallbacks_sync(fallbacks, error)
|
|
104
|
+
result_queue = Queue.new
|
|
105
|
+
error_queue = Queue.new
|
|
106
|
+
threads = fallbacks.map do |fallback|
|
|
107
|
+
Thread.new do
|
|
108
|
+
result = if fallback.is_a?(Proc)
|
|
109
|
+
if fallback.arity == 1
|
|
110
|
+
fallback.call(error)
|
|
111
|
+
else
|
|
112
|
+
fallback.call
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
fallback
|
|
116
|
+
end
|
|
117
|
+
result_queue << result
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
error_queue << e
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
threads.each(&:join)
|
|
124
|
+
|
|
125
|
+
if result_queue.empty?
|
|
126
|
+
errors = []
|
|
127
|
+
errors << error_queue.pop until error_queue.empty?
|
|
128
|
+
raise BreakerMachines::ParallelFallbackError.new('All parallel fallbacks failed', errors)
|
|
129
|
+
else
|
|
130
|
+
result_queue.pop
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
class Circuit
|
|
5
|
+
# Configuration manages circuit initialization and default settings,
|
|
6
|
+
# including thresholds, timeouts, storage backends, and callbacks.
|
|
7
|
+
module Configuration
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
attr_reader :name, :config, :opened_at
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def default_config
|
|
17
|
+
{
|
|
18
|
+
failure_threshold: 5,
|
|
19
|
+
failure_window: 60, # seconds
|
|
20
|
+
success_threshold: 1,
|
|
21
|
+
timeout: nil,
|
|
22
|
+
reset_timeout: 60, # seconds
|
|
23
|
+
reset_timeout_jitter: 0.25, # +/- 25% by default
|
|
24
|
+
half_open_calls: 1,
|
|
25
|
+
storage: nil, # Will default to Memory storage if nil
|
|
26
|
+
metrics: nil,
|
|
27
|
+
fallback: nil,
|
|
28
|
+
on_open: nil,
|
|
29
|
+
on_close: nil,
|
|
30
|
+
on_half_open: nil,
|
|
31
|
+
on_reject: nil,
|
|
32
|
+
exceptions: [StandardError],
|
|
33
|
+
fiber_safe: BreakerMachines.config.fiber_safe,
|
|
34
|
+
# Rate-based threshold options
|
|
35
|
+
use_rate_threshold: false,
|
|
36
|
+
failure_rate: nil,
|
|
37
|
+
minimum_calls: 5,
|
|
38
|
+
# Bulkheading options
|
|
39
|
+
max_concurrent: nil,
|
|
40
|
+
# Hedged request options
|
|
41
|
+
hedged_requests: false,
|
|
42
|
+
hedging_delay: 50, # milliseconds
|
|
43
|
+
max_hedged_requests: 2,
|
|
44
|
+
backends: nil
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_default_storage
|
|
49
|
+
case BreakerMachines.config.default_storage
|
|
50
|
+
when :memory
|
|
51
|
+
BreakerMachines::Storage::Memory.new
|
|
52
|
+
when :bucket_memory
|
|
53
|
+
BreakerMachines::Storage::BucketMemory.new
|
|
54
|
+
when :null
|
|
55
|
+
BreakerMachines::Storage::Null.new
|
|
56
|
+
else
|
|
57
|
+
# Allow for custom storage class names or instances
|
|
58
|
+
if BreakerMachines.config.default_storage.respond_to?(:new)
|
|
59
|
+
BreakerMachines.config.default_storage.new
|
|
60
|
+
else
|
|
61
|
+
BreakerMachines.config.default_storage
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
class Circuit
|
|
5
|
+
# CoordinatedStateManagement extends the base state machine with coordinated guards
|
|
6
|
+
# that allow circuits to make transitions based on the state of other circuits.
|
|
7
|
+
module CoordinatedStateManagement
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
# Override the state machine to add coordinated guards
|
|
12
|
+
state_machine :status, initial: :closed do
|
|
13
|
+
event :trip do
|
|
14
|
+
transition closed: :open
|
|
15
|
+
transition half_open: :open
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
event :attempt_recovery do
|
|
19
|
+
transition open: :half_open,
|
|
20
|
+
if: lambda(&:recovery_allowed?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
event :reset do
|
|
24
|
+
transition %i[open half_open] => :closed,
|
|
25
|
+
if: lambda(&:reset_allowed?)
|
|
26
|
+
transition closed: :closed
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
event :force_open do
|
|
30
|
+
transition any => :open
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
event :force_close do
|
|
34
|
+
transition any => :closed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
event :hard_reset do
|
|
38
|
+
transition any => :closed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
before_transition on: :hard_reset do |circuit|
|
|
42
|
+
circuit.storage&.clear(circuit.name)
|
|
43
|
+
circuit.half_open_attempts.value = 0
|
|
44
|
+
circuit.half_open_successes.value = 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
after_transition to: :open do |circuit|
|
|
48
|
+
circuit.send(:on_circuit_open)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
after_transition to: :closed do |circuit|
|
|
52
|
+
circuit.send(:on_circuit_close)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after_transition from: :open, to: :half_open do |circuit|
|
|
56
|
+
circuit.send(:on_circuit_half_open)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if this circuit can attempt recovery
|
|
62
|
+
# For cascading circuits, this checks if dependent circuits allow it
|
|
63
|
+
def recovery_allowed?
|
|
64
|
+
return true unless respond_to?(:dependent_circuits) && dependent_circuits.any?
|
|
65
|
+
|
|
66
|
+
# Don't attempt recovery if any critical dependencies are still down
|
|
67
|
+
!has_critical_dependencies_down?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if this circuit can reset to closed
|
|
71
|
+
# For cascading circuits, ensures dependencies are healthy
|
|
72
|
+
def reset_allowed?
|
|
73
|
+
return true unless respond_to?(:dependent_circuits) && dependent_circuits.any?
|
|
74
|
+
|
|
75
|
+
# Only reset if all dependencies are in acceptable states
|
|
76
|
+
all_dependencies_healthy?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Check if any critical dependencies are down
|
|
82
|
+
def has_critical_dependencies_down?
|
|
83
|
+
return false unless respond_to?(:dependent_circuits)
|
|
84
|
+
|
|
85
|
+
dependent_circuits.any? do |circuit_name|
|
|
86
|
+
circuit = find_dependent_circuit(circuit_name)
|
|
87
|
+
circuit&.open?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if all dependencies are in healthy states
|
|
92
|
+
def all_dependencies_healthy?
|
|
93
|
+
return true unless respond_to?(:dependent_circuits)
|
|
94
|
+
|
|
95
|
+
dependent_circuits.all? do |circuit_name|
|
|
96
|
+
circuit = find_dependent_circuit(circuit_name)
|
|
97
|
+
circuit.nil? || circuit.closed? || circuit.half_open?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Find a dependent circuit by name
|
|
102
|
+
def find_dependent_circuit(circuit_name)
|
|
103
|
+
# First try registry
|
|
104
|
+
circuit = BreakerMachines.registry.find(circuit_name)
|
|
105
|
+
|
|
106
|
+
# If not found and we have an owner, try to get it from the owner
|
|
107
|
+
if !circuit && @config[:owner]
|
|
108
|
+
owner = @config[:owner]
|
|
109
|
+
owner = owner.__getobj__ if owner.is_a?(WeakRef)
|
|
110
|
+
circuit = owner.circuit(circuit_name) if owner.respond_to?(:circuit)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
circuit
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
class Circuit
|
|
5
|
+
# Execution handles the core circuit breaker logic including call wrapping,
|
|
6
|
+
# state-based request handling, and failure/success tracking.
|
|
7
|
+
module Execution
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Lazy load async support only when needed
|
|
11
|
+
def self.load_async_support
|
|
12
|
+
require 'breaker_machines/async_support'
|
|
13
|
+
require 'breaker_machines/hedged_async_support'
|
|
14
|
+
Circuit.include(BreakerMachines::AsyncSupport)
|
|
15
|
+
Circuit.include(BreakerMachines::HedgedAsyncSupport)
|
|
16
|
+
rescue LoadError => e
|
|
17
|
+
if e.message.include?('async')
|
|
18
|
+
raise LoadError, "The 'async' gem is required for fiber_safe mode. Add `gem 'async'` to your Gemfile."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(&)
|
|
25
|
+
wrap(&)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def wrap(&)
|
|
29
|
+
execute_with_state_check(&)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def execute_with_state_check(&block)
|
|
35
|
+
# Check if we need to transition from open to half-open first
|
|
36
|
+
if open? && reset_timeout_elapsed?
|
|
37
|
+
@mutex.with_write_lock do
|
|
38
|
+
attempt_recovery if open? # Double-check after acquiring lock
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Apply bulkheading first, outside of any locks
|
|
43
|
+
if @semaphore
|
|
44
|
+
acquired = @semaphore.try_acquire
|
|
45
|
+
unless acquired
|
|
46
|
+
# Reject immediately if we can't acquire semaphore
|
|
47
|
+
return reject_call_bulkhead
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
@mutex.with_read_lock do
|
|
53
|
+
case status_name
|
|
54
|
+
when :open
|
|
55
|
+
reject_call
|
|
56
|
+
when :half_open
|
|
57
|
+
handle_half_open_status(&block)
|
|
58
|
+
when :closed
|
|
59
|
+
handle_closed_status(&block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
ensure
|
|
63
|
+
@semaphore&.release if @semaphore && acquired
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_half_open_status(&)
|
|
68
|
+
# Atomically increment and get the new value
|
|
69
|
+
new_attempts = @half_open_attempts.increment
|
|
70
|
+
|
|
71
|
+
if new_attempts <= @config[:half_open_calls]
|
|
72
|
+
execute_call(&)
|
|
73
|
+
else
|
|
74
|
+
# This thread lost the race, decrement back and reject
|
|
75
|
+
@half_open_attempts.decrement
|
|
76
|
+
reject_call
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_closed_status(&)
|
|
81
|
+
execute_call(&)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute_call(&block)
|
|
85
|
+
# Use async version if fiber_safe is enabled
|
|
86
|
+
if @config[:fiber_safe]
|
|
87
|
+
# Ensure async is loaded and included
|
|
88
|
+
Execution.load_async_support unless respond_to?(:execute_call_async)
|
|
89
|
+
return execute_call_async(&block)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
start_time = BreakerMachines.monotonic_time
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
# IMPORTANT: We do NOT implement forceful timeouts as they are inherently unsafe
|
|
96
|
+
# The timeout configuration is provided for documentation/intent purposes
|
|
97
|
+
# Users should implement timeouts in their own code using safe mechanisms
|
|
98
|
+
# (e.g., HTTP client timeouts, database statement timeouts, etc.)
|
|
99
|
+
# Log a warning if timeout is configured
|
|
100
|
+
if @config[:timeout] && BreakerMachines.logger && BreakerMachines.config.log_events
|
|
101
|
+
BreakerMachines.logger.warn(
|
|
102
|
+
"[BreakerMachines] Circuit '#{@name}' has timeout configured but " \
|
|
103
|
+
'forceful timeouts are not implemented for safety. ' \
|
|
104
|
+
'Please use timeout mechanisms provided by your libraries ' \
|
|
105
|
+
'(e.g., Net::HTTP read_timeout, ActiveRecord statement_timeout).'
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Execute with hedged requests if enabled
|
|
110
|
+
result = if @config[:hedged_requests] || @config[:backends]
|
|
111
|
+
execute_hedged(&block)
|
|
112
|
+
else
|
|
113
|
+
block.call
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
record_success(BreakerMachines.monotonic_time - start_time)
|
|
117
|
+
handle_success
|
|
118
|
+
result
|
|
119
|
+
rescue *@config[:exceptions] => e
|
|
120
|
+
record_failure(BreakerMachines.monotonic_time - start_time, e)
|
|
121
|
+
handle_failure
|
|
122
|
+
raise unless @config[:fallback]
|
|
123
|
+
|
|
124
|
+
invoke_fallback(e)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reject_call
|
|
129
|
+
@metrics&.record_rejection(@name)
|
|
130
|
+
invoke_callback(:on_reject)
|
|
131
|
+
|
|
132
|
+
raise BreakerMachines::CircuitOpenError.new(@name, @opened_at.value) unless @config[:fallback]
|
|
133
|
+
|
|
134
|
+
invoke_fallback(BreakerMachines::CircuitOpenError.new(@name, @opened_at.value))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reject_call_bulkhead
|
|
138
|
+
@metrics&.record_rejection(@name)
|
|
139
|
+
invoke_callback(:on_reject)
|
|
140
|
+
|
|
141
|
+
error = BreakerMachines::CircuitBulkheadError.new(@name, @config[:max_concurrent])
|
|
142
|
+
raise error unless @config[:fallback]
|
|
143
|
+
|
|
144
|
+
invoke_fallback(error)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def handle_success
|
|
148
|
+
return unless half_open?
|
|
149
|
+
|
|
150
|
+
@mutex.with_write_lock do
|
|
151
|
+
if half_open?
|
|
152
|
+
# Check if all allowed half-open calls have succeeded
|
|
153
|
+
# This ensures the circuit can close even if success_threshold > half_open_calls
|
|
154
|
+
successful_attempts = @half_open_successes.increment
|
|
155
|
+
|
|
156
|
+
# Fast-close logic: Circuit closes if EITHER:
|
|
157
|
+
# 1. All allowed half-open calls succeeded (conservative approach)
|
|
158
|
+
# 2. Success threshold is reached (aggressive approach for quick recovery)
|
|
159
|
+
# This allows flexible configuration - set success_threshold=1 for fast recovery
|
|
160
|
+
# or success_threshold=half_open_calls for cautious recovery
|
|
161
|
+
if successful_attempts >= @config[:half_open_calls] || success_threshold_reached?
|
|
162
|
+
@half_open_attempts.value = 0
|
|
163
|
+
@half_open_successes.value = 0
|
|
164
|
+
reset
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_failure
|
|
171
|
+
return unless closed? || half_open?
|
|
172
|
+
|
|
173
|
+
@mutex.with_write_lock do
|
|
174
|
+
if closed? && failure_threshold_exceeded?
|
|
175
|
+
trip
|
|
176
|
+
elsif half_open?
|
|
177
|
+
@half_open_attempts.value = 0
|
|
178
|
+
@half_open_successes.value = 0
|
|
179
|
+
trip
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def failure_threshold_exceeded?
|
|
185
|
+
if @config[:use_rate_threshold]
|
|
186
|
+
# Rate-based threshold
|
|
187
|
+
window = @config[:failure_window]
|
|
188
|
+
failures = @storage.failure_count(@name, window)
|
|
189
|
+
successes = @storage.success_count(@name, window)
|
|
190
|
+
total_calls = failures + successes
|
|
191
|
+
|
|
192
|
+
# Check minimum calls requirement
|
|
193
|
+
return false if total_calls < @config[:minimum_calls]
|
|
194
|
+
|
|
195
|
+
# Calculate failure rate
|
|
196
|
+
failure_rate = failures.to_f / total_calls
|
|
197
|
+
failure_rate >= @config[:failure_rate]
|
|
198
|
+
else
|
|
199
|
+
# Absolute count threshold (existing behavior)
|
|
200
|
+
recent_failures = @storage.failure_count(@name, @config[:failure_window])
|
|
201
|
+
recent_failures >= @config[:failure_threshold]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def success_threshold_reached?
|
|
206
|
+
recent_successes = @storage.success_count(@name, @config[:failure_window])
|
|
207
|
+
recent_successes >= @config[:success_threshold]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def record_success(duration)
|
|
211
|
+
@metrics&.record_success(@name, duration)
|
|
212
|
+
@storage&.record_success(@name, duration)
|
|
213
|
+
return unless @storage.respond_to?(:record_event_with_details)
|
|
214
|
+
|
|
215
|
+
@storage.record_event_with_details(@name, :success,
|
|
216
|
+
duration)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def record_failure(duration, error = nil)
|
|
220
|
+
@last_failure_at.value = BreakerMachines.monotonic_time
|
|
221
|
+
@last_error.value = error if error
|
|
222
|
+
@metrics&.record_failure(@name, duration)
|
|
223
|
+
@storage&.record_failure(@name, duration)
|
|
224
|
+
return unless @storage.respond_to?(:record_event_with_details)
|
|
225
|
+
|
|
226
|
+
@storage.record_event_with_details(@name, :failure, duration,
|
|
227
|
+
error: error)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module BreakerMachines
|
|
7
|
+
class Circuit
|
|
8
|
+
# HedgedExecution provides hedged request functionality for circuit breakers
|
|
9
|
+
# Hedged requests improve latency by sending duplicate requests to multiple backends
|
|
10
|
+
# and returning the first successful response
|
|
11
|
+
module HedgedExecution
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
# Execute a hedged request pattern
|
|
15
|
+
def execute_hedged(&)
|
|
16
|
+
return execute_single_hedged(&) unless @config[:backends]&.any?
|
|
17
|
+
|
|
18
|
+
execute_multi_backend_hedged
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Execute hedged request with a single backend (original block)
|
|
24
|
+
def execute_single_hedged(&block)
|
|
25
|
+
return yield unless hedged_requests_enabled?
|
|
26
|
+
|
|
27
|
+
max_requests = @config[:max_hedged_requests] || 2
|
|
28
|
+
delay_ms = @config[:hedging_delay] || 50
|
|
29
|
+
|
|
30
|
+
if @config[:fiber_safe]
|
|
31
|
+
execute_hedged_async(Array.new(max_requests) { block }, delay_ms)
|
|
32
|
+
else
|
|
33
|
+
execute_hedged_sync(Array.new(max_requests) { block }, delay_ms)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute hedged requests across multiple backends
|
|
38
|
+
def execute_multi_backend_hedged
|
|
39
|
+
backends = @config[:backends]
|
|
40
|
+
return backends.first.call if backends.size == 1
|
|
41
|
+
|
|
42
|
+
if @config[:fiber_safe]
|
|
43
|
+
execute_hedged_async(backends, @config[:hedging_delay] || 0)
|
|
44
|
+
else
|
|
45
|
+
execute_hedged_sync(backends, @config[:hedging_delay] || 0)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Synchronous hedged execution using threads
|
|
50
|
+
def execute_hedged_sync(callables, delay_ms)
|
|
51
|
+
result_queue = Queue.new
|
|
52
|
+
error_queue = Queue.new
|
|
53
|
+
threads = []
|
|
54
|
+
cancelled = Concurrent::AtomicBoolean.new(false)
|
|
55
|
+
|
|
56
|
+
callables.each_with_index do |callable, index|
|
|
57
|
+
# Add delay for hedge requests (not the first one)
|
|
58
|
+
sleep(delay_ms / 1000.0) if index.positive? && delay_ms.positive?
|
|
59
|
+
|
|
60
|
+
# Skip if already got a result
|
|
61
|
+
break if cancelled.value
|
|
62
|
+
|
|
63
|
+
threads << Thread.new do
|
|
64
|
+
unless cancelled.value
|
|
65
|
+
begin
|
|
66
|
+
result = callable.call
|
|
67
|
+
result_queue << result unless cancelled.value
|
|
68
|
+
cancelled.value = true
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
error_queue << e unless cancelled.value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Wait for first result or all errors
|
|
77
|
+
begin
|
|
78
|
+
Timeout.timeout(@config[:timeout] || 30) do
|
|
79
|
+
# Check for successful result
|
|
80
|
+
loop do
|
|
81
|
+
unless result_queue.empty?
|
|
82
|
+
result = result_queue.pop
|
|
83
|
+
cancelled.value = true
|
|
84
|
+
return result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if all requests failed
|
|
88
|
+
raise error_queue.pop if error_queue.size >= callables.size
|
|
89
|
+
|
|
90
|
+
# Small sleep to prevent busy waiting
|
|
91
|
+
sleep 0.001
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
ensure
|
|
95
|
+
# Cancel remaining threads
|
|
96
|
+
cancelled.value = true
|
|
97
|
+
threads.each(&:kill)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Async hedged execution (requires async support)
|
|
102
|
+
def execute_hedged_async(callables, delay_ms)
|
|
103
|
+
# This will be implemented when async support is loaded
|
|
104
|
+
# For now, fall back to sync implementation
|
|
105
|
+
return execute_hedged_sync(callables, delay_ms) unless respond_to?(:execute_hedged_with_async)
|
|
106
|
+
|
|
107
|
+
execute_hedged_with_async(callables, delay_ms)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def hedged_requests_enabled?
|
|
111
|
+
@config[:hedged_requests] == true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|