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.
@@ -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
- @config[:owner].instance_exec(&callback)
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
- @config[:owner].instance_exec(error, &@config[:fallback])
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
- @config[:owner].instance_exec(error, &fallback)
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
- # Async versions for fiber_safe mode
62
- def invoke_callback_async(callback_name)
63
- callback = @config[callback_name]
64
- return unless callback
65
- return unless callback.is_a?(Proc)
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
- result = if @config[:owner]
68
- @config[:owner].instance_exec(&callback)
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
- # If the callback returns an Async::Task, wait for it
74
- if defined?(::Async::Task) && result.is_a?(::Async::Task)
75
- result.wait
96
+ if @config[:fiber_safe] && respond_to?(:execute_parallel_fallbacks_async)
97
+ execute_parallel_fallbacks_async(fallbacks)
76
98
  else
77
- result
99
+ execute_parallel_fallbacks_sync(fallbacks, error)
78
100
  end
79
101
  end
80
102
 
81
- def invoke_fallback_async(error)
82
- case @config[:fallback]
83
- when Proc
84
- result = if @config[:owner]
85
- @config[:owner].instance_exec(error, &@config[:fallback])
86
- else
87
- @config[:fallback].call(error)
88
- end
89
-
90
- # If the fallback returns an Async::Task, wait for it
91
- if defined?(::Async::Task) && result.is_a?(::Async::Task)
92
- result.wait
93
- else
94
- result
95
- end
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
- last_error = e
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
- def invoke_single_fallback_async(fallback, error)
112
- case fallback
113
- when Proc
114
- result = if @config[:owner]
115
- @config[:owner].instance_exec(error, &fallback)
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
- # If the fallback returns an Async::Task, wait for it
121
- if defined?(::Async::Task) && result.is_a?(::Async::Task)
122
- result.wait
123
- else
124
- result
129
+ raise error_queue.pop if error_queue.size >= fallbacks.size
130
+
131
+ sleep 0.001
132
+ end
125
133
  end
126
- else
127
- fallback
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 'async'
13
- require 'async/task'
14
- rescue LoadError
15
- raise LoadError, "The 'async' gem is required for fiber_safe mode. Add `gem 'async'` to your Gemfile."
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(&block)
23
- @mutex.with_read_lock do
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 handle_open_status(&)
38
- if reset_timeout_elapsed?
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
- handle_half_open_status(&)
43
- else
44
- reject_call
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
- return execute_call_async(&block) if @config[:fiber_safe]
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 normally without forceful timeout
87
- result = block.call
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
- yield
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
- invoke_fallback_async(e)
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
- recent_failures = @storage.failure_count(@name, @config[:failure_window])
178
- recent_failures >= @config[:failure_threshold]
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?
@@ -8,6 +8,7 @@ module BreakerMachines
8
8
  include StateManagement
9
9
  include Configuration
10
10
  include Execution
11
+ include HedgedExecution
11
12
  include Introspection
12
13
  include Callbacks
13
14
  end