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
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file contains all async-related functionality for fiber-safe mode
|
4
|
+
# It is only loaded when fiber_safe mode is enabled
|
5
|
+
|
6
|
+
require 'async'
|
7
|
+
require 'async/task'
|
8
|
+
|
9
|
+
module BreakerMachines
|
10
|
+
# AsyncSupport provides fiber-safe execution capabilities using the async gem
|
11
|
+
module AsyncSupport
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
|
14
|
+
# Returns the Async::TimeoutError class if available
|
15
|
+
def async_timeout_error_class
|
16
|
+
::Async::TimeoutError
|
17
|
+
end
|
18
|
+
|
19
|
+
# Execute a call with async support (fiber-safe mode)
|
20
|
+
def execute_call_async(&)
|
21
|
+
start_time = monotonic_time
|
22
|
+
|
23
|
+
begin
|
24
|
+
# Execute with hedged requests if enabled
|
25
|
+
result = if @config[:hedged_requests] || @config[:backends]
|
26
|
+
execute_hedged(&)
|
27
|
+
else
|
28
|
+
execute_with_async_timeout(@config[:timeout], &)
|
29
|
+
end
|
30
|
+
|
31
|
+
record_success(monotonic_time - start_time)
|
32
|
+
handle_success
|
33
|
+
result
|
34
|
+
rescue StandardError => e
|
35
|
+
# Re-raise if it's not an async timeout or configured exception
|
36
|
+
raise unless e.is_a?(async_timeout_error_class) || @config[:exceptions].any? { |klass| e.is_a?(klass) }
|
37
|
+
|
38
|
+
record_failure(monotonic_time - start_time, e)
|
39
|
+
handle_failure
|
40
|
+
raise unless @config[:fallback]
|
41
|
+
|
42
|
+
invoke_fallback_with_async(e)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Execute a block with optional timeout using Async
|
47
|
+
def execute_with_async_timeout(timeout, &)
|
48
|
+
if timeout
|
49
|
+
# Use safe, cooperative timeout from async gem
|
50
|
+
::Async::Task.current.with_timeout(timeout, &)
|
51
|
+
else
|
52
|
+
yield
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Invoke fallback in async context
|
57
|
+
def invoke_fallback_with_async(error)
|
58
|
+
case @config[:fallback]
|
59
|
+
when BreakerMachines::DSL::ParallelFallbackWrapper
|
60
|
+
invoke_parallel_fallbacks(@config[:fallback].fallbacks, error)
|
61
|
+
when Proc
|
62
|
+
result = if @config[:owner]
|
63
|
+
@config[:owner].instance_exec(error, &@config[:fallback])
|
64
|
+
else
|
65
|
+
@config[:fallback].call(error)
|
66
|
+
end
|
67
|
+
|
68
|
+
# If the fallback returns an Async::Task, wait for it
|
69
|
+
result.is_a?(::Async::Task) ? result.wait : result
|
70
|
+
when Array
|
71
|
+
# Try each fallback in order until one succeeds
|
72
|
+
last_error = error
|
73
|
+
@config[:fallback].each do |fallback|
|
74
|
+
return invoke_single_fallback_async(fallback, last_error)
|
75
|
+
rescue StandardError => e
|
76
|
+
last_error = e
|
77
|
+
end
|
78
|
+
raise last_error
|
79
|
+
else
|
80
|
+
# Static values (strings, hashes, etc.) or Symbol fallbacks
|
81
|
+
@config[:fallback]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def invoke_single_fallback_async(fallback, error)
|
88
|
+
case fallback
|
89
|
+
when Proc
|
90
|
+
result = if @config[:owner]
|
91
|
+
@config[:owner].instance_exec(error, &fallback)
|
92
|
+
else
|
93
|
+
fallback.call(error)
|
94
|
+
end
|
95
|
+
|
96
|
+
# If the fallback returns an Async::Task, wait for it
|
97
|
+
result.is_a?(::Async::Task) ? result.wait : result
|
98
|
+
else
|
99
|
+
fallback
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -16,7 +16,13 @@ module BreakerMachines
|
|
16
16
|
return unless callback.is_a?(Proc)
|
17
17
|
|
18
18
|
if @config[:owner]
|
19
|
-
|
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
|
20
26
|
else
|
21
27
|
callback.call
|
22
28
|
end
|
@@ -24,9 +30,17 @@ module BreakerMachines
|
|
24
30
|
|
25
31
|
def invoke_fallback(error)
|
26
32
|
case @config[:fallback]
|
33
|
+
when BreakerMachines::DSL::ParallelFallbackWrapper
|
34
|
+
invoke_parallel_fallbacks(@config[:fallback].fallbacks, error)
|
27
35
|
when Proc
|
28
36
|
if @config[:owner]
|
29
|
-
|
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
|
30
44
|
else
|
31
45
|
@config[:fallback].call(error)
|
32
46
|
end
|
@@ -49,7 +63,12 @@ module BreakerMachines
|
|
49
63
|
case fallback
|
50
64
|
when Proc
|
51
65
|
if @config[:owner]
|
52
|
-
|
66
|
+
owner = resolve_owner
|
67
|
+
if owner
|
68
|
+
owner.instance_exec(error, &fallback)
|
69
|
+
else
|
70
|
+
fallback.call(error)
|
71
|
+
end
|
53
72
|
else
|
54
73
|
fallback.call(error)
|
55
74
|
end
|
@@ -58,73 +77,62 @@ module BreakerMachines
|
|
58
77
|
end
|
59
78
|
end
|
60
79
|
|
61
|
-
#
|
62
|
-
def
|
63
|
-
|
64
|
-
return unless
|
65
|
-
|
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
|
66
92
|
|
67
|
-
|
68
|
-
|
69
|
-
else
|
70
|
-
callback.call
|
71
|
-
end
|
93
|
+
def invoke_parallel_fallbacks(fallbacks, error)
|
94
|
+
return fallbacks.first if fallbacks.size == 1
|
72
95
|
|
73
|
-
|
74
|
-
|
75
|
-
result.wait
|
96
|
+
if @config[:fiber_safe] && respond_to?(:execute_parallel_fallbacks_async)
|
97
|
+
execute_parallel_fallbacks_async(fallbacks)
|
76
98
|
else
|
77
|
-
|
99
|
+
execute_parallel_fallbacks_sync(fallbacks, error)
|
78
100
|
end
|
79
101
|
end
|
80
102
|
|
81
|
-
def
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
when Array
|
97
|
-
# Try each fallback in order until one succeeds
|
98
|
-
last_error = error
|
99
|
-
@config[:fallback].each do |fallback|
|
100
|
-
return invoke_single_fallback_async(fallback, last_error)
|
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
|
101
118
|
rescue StandardError => e
|
102
|
-
|
119
|
+
error_queue << e
|
103
120
|
end
|
104
|
-
raise last_error
|
105
|
-
else
|
106
|
-
# Static values (strings, hashes, etc.) or Symbol fallbacks
|
107
|
-
@config[:fallback]
|
108
121
|
end
|
109
|
-
end
|
110
122
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
else
|
117
|
-
fallback.call(error)
|
118
|
-
end
|
123
|
+
# Wait for first successful result
|
124
|
+
begin
|
125
|
+
Timeout.timeout(5) do # reasonable timeout for fallbacks
|
126
|
+
loop do
|
127
|
+
return result_queue.pop unless result_queue.empty?
|
119
128
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
result
|
129
|
+
raise error_queue.pop if error_queue.size >= fallbacks.size
|
130
|
+
|
131
|
+
sleep 0.001
|
132
|
+
end
|
125
133
|
end
|
126
|
-
|
127
|
-
|
134
|
+
ensure
|
135
|
+
threads.each(&:kill)
|
128
136
|
end
|
129
137
|
end
|
130
138
|
end
|
@@ -25,11 +25,14 @@ module BreakerMachines
|
|
25
25
|
@last_failure_at = Concurrent::AtomicReference.new(nil)
|
26
26
|
@last_error = Concurrent::AtomicReference.new(nil)
|
27
27
|
|
28
|
+
# Initialize semaphore for bulkheading if max_concurrent is set
|
29
|
+
@semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
|
30
|
+
|
28
31
|
super() # Initialize state machine
|
29
32
|
restore_status_from_storage if @storage
|
30
33
|
|
31
|
-
# Register with global registry
|
32
|
-
BreakerMachines::Registry.instance.register(self)
|
34
|
+
# Register with global registry unless auto_register is disabled
|
35
|
+
BreakerMachines::Registry.instance.register(self) unless @config[:auto_register] == false
|
33
36
|
end
|
34
37
|
|
35
38
|
private
|
@@ -51,7 +54,18 @@ module BreakerMachines
|
|
51
54
|
on_half_open: nil,
|
52
55
|
on_reject: nil,
|
53
56
|
exceptions: [StandardError],
|
54
|
-
fiber_safe: BreakerMachines.config.fiber_safe
|
57
|
+
fiber_safe: BreakerMachines.config.fiber_safe,
|
58
|
+
# Rate-based threshold options
|
59
|
+
use_rate_threshold: false,
|
60
|
+
failure_rate: nil,
|
61
|
+
minimum_calls: 5,
|
62
|
+
# Bulkheading options
|
63
|
+
max_concurrent: nil,
|
64
|
+
# Hedged request options
|
65
|
+
hedged_requests: false,
|
66
|
+
hedging_delay: 50, # milliseconds
|
67
|
+
max_hedged_requests: 2,
|
68
|
+
backends: nil
|
55
69
|
}
|
56
70
|
end
|
57
71
|
|
@@ -9,39 +9,58 @@ module BreakerMachines
|
|
9
9
|
|
10
10
|
# Lazy load async support only when needed
|
11
11
|
def self.load_async_support
|
12
|
-
require '
|
13
|
-
require '
|
14
|
-
|
15
|
-
|
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
|
16
22
|
end
|
17
23
|
|
18
24
|
def call(&)
|
19
25
|
wrap(&)
|
20
26
|
end
|
21
27
|
|
22
|
-
def wrap(&
|
23
|
-
|
24
|
-
case status_name
|
25
|
-
when :open
|
26
|
-
handle_open_status(&block)
|
27
|
-
when :half_open
|
28
|
-
handle_half_open_status(&block)
|
29
|
-
when :closed
|
30
|
-
handle_closed_status(&block)
|
31
|
-
end
|
32
|
-
end
|
28
|
+
def wrap(&)
|
29
|
+
execute_with_state_check(&)
|
33
30
|
end
|
34
31
|
|
35
32
|
private
|
36
33
|
|
37
|
-
def
|
38
|
-
if
|
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?
|
39
37
|
@mutex.with_write_lock do
|
40
|
-
attempt_recovery if open?
|
38
|
+
attempt_recovery if open? # Double-check after acquiring lock
|
41
39
|
end
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
45
64
|
end
|
46
65
|
end
|
47
66
|
|
@@ -64,7 +83,11 @@ module BreakerMachines
|
|
64
83
|
|
65
84
|
def execute_call(&block)
|
66
85
|
# Use async version if fiber_safe is enabled
|
67
|
-
|
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
|
68
91
|
|
69
92
|
start_time = monotonic_time
|
70
93
|
|
@@ -83,51 +106,22 @@ module BreakerMachines
|
|
83
106
|
)
|
84
107
|
end
|
85
108
|
|
86
|
-
# Execute
|
87
|
-
result =
|
88
|
-
|
89
|
-
record_success(monotonic_time - start_time)
|
90
|
-
handle_success
|
91
|
-
result
|
92
|
-
rescue *@config[:exceptions] => e
|
93
|
-
record_failure(monotonic_time - start_time, e)
|
94
|
-
handle_failure
|
95
|
-
raise unless @config[:fallback]
|
96
|
-
|
97
|
-
invoke_fallback(e)
|
98
|
-
end
|
99
|
-
end
|
100
|
-
|
101
|
-
def execute_call_async(&)
|
102
|
-
# Ensure async is loaded
|
103
|
-
Execution.load_async_support unless defined?(::Async)
|
104
|
-
|
105
|
-
start_time = monotonic_time
|
106
|
-
|
107
|
-
begin
|
108
|
-
result = if @config[:timeout]
|
109
|
-
# Use safe, cooperative timeout from async gem
|
110
|
-
::Async::Task.current.with_timeout(@config[:timeout], &)
|
109
|
+
# Execute with hedged requests if enabled
|
110
|
+
result = if @config[:hedged_requests] || @config[:backends]
|
111
|
+
execute_hedged(&block)
|
111
112
|
else
|
112
|
-
|
113
|
+
block.call
|
113
114
|
end
|
114
115
|
|
115
116
|
record_success(monotonic_time - start_time)
|
116
117
|
handle_success
|
117
118
|
result
|
118
|
-
rescue ::Async::TimeoutError => e
|
119
|
-
# Handle async timeout as a failure
|
120
|
-
record_failure(monotonic_time - start_time, e)
|
121
|
-
handle_failure
|
122
|
-
raise unless @config[:fallback]
|
123
|
-
|
124
|
-
invoke_fallback_async(e)
|
125
119
|
rescue *@config[:exceptions] => e
|
126
120
|
record_failure(monotonic_time - start_time, e)
|
127
121
|
handle_failure
|
128
122
|
raise unless @config[:fallback]
|
129
123
|
|
130
|
-
|
124
|
+
invoke_fallback(e)
|
131
125
|
end
|
132
126
|
end
|
133
127
|
|
@@ -140,7 +134,19 @@ module BreakerMachines
|
|
140
134
|
invoke_fallback(BreakerMachines::CircuitOpenError.new(@name, @opened_at.value))
|
141
135
|
end
|
142
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
|
+
|
143
147
|
def handle_success
|
148
|
+
return unless half_open?
|
149
|
+
|
144
150
|
@mutex.with_write_lock do
|
145
151
|
if half_open?
|
146
152
|
# Check if all allowed half-open calls have succeeded
|
@@ -162,6 +168,8 @@ module BreakerMachines
|
|
162
168
|
end
|
163
169
|
|
164
170
|
def handle_failure
|
171
|
+
return unless closed? || half_open?
|
172
|
+
|
165
173
|
@mutex.with_write_lock do
|
166
174
|
if closed? && failure_threshold_exceeded?
|
167
175
|
trip
|
@@ -174,8 +182,24 @@ module BreakerMachines
|
|
174
182
|
end
|
175
183
|
|
176
184
|
def failure_threshold_exceeded?
|
177
|
-
|
178
|
-
|
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
|
179
203
|
end
|
180
204
|
|
181
205
|
def success_threshold_reached?
|