breaker_machines 0.13.2 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dab65770123fb6626a9112608ddeb784b1f623c6dd61ba59bd4f2094f8c73bb4
4
- data.tar.gz: 572686fc3febc21058ea20c300667d09b466d427c0bcab98af4230e5e14c7ee3
3
+ metadata.gz: 4116461b731bfc94e0773301a5ce27d8a118e4b8d8adf033e1b53b304f2538e5
4
+ data.tar.gz: 6cf9ed874a11f2f33db5c92f13875686260b65267c933dcb1d2cc1d994264a3f
5
5
  SHA512:
6
- metadata.gz: ba432f7aeda81bd88dd93ea8d1c0cb1c843cbc964bc3a31c6baf1359b4fb0a2c4873cd0e5d19ed0543ef5f3ba728f7e2de78a022f395abec3164465f8d562169
7
- data.tar.gz: 43f4c0da0445a1c7e91a9a1ba24e8892d4ba10cf1891ab75c44704834a1026b20e23fa5830c23029b3dee4a04ca9bba51c2319f069b0b92bbf7abd910a445ad1
6
+ metadata.gz: fb362d48c18c9706cdffc79634af898403d9cd349a4bef73da399cbe177eaab435f83a8143b33d7c420745b4f4969c682a184269b8849893123244b98b7b2eea
7
+ data.tar.gz: afd84f6bc4ac312d7bc7cbbcc1c9710fdb550694e0e32c4598bf04d0658af2fe5c23a399fbdfa2801e99dbfe610c3fd44458df8b33569e948b77757bb5fd7208
@@ -18,32 +18,36 @@ module BreakerMachines
18
18
  end
19
19
 
20
20
  # Execute a call with async support (fiber-safe mode)
21
- def execute_call_async(&)
21
+ def execute_call_async(admission, &)
22
+ completed = false
22
23
  start_time = BreakerMachines.monotonic_time
23
24
 
24
25
  begin
25
- # Execute with hedged requests if enabled
26
- result = if @config[:hedged_requests] || @config[:backends]
27
- execute_hedged(&)
28
- else
29
- execute_with_async_timeout(@config[:timeout], &)
30
- end
26
+ result = execute_async_operation(&)
31
27
 
32
- record_success(BreakerMachines.monotonic_time - start_time)
33
- handle_success
28
+ complete_call_success(admission, start_time)
29
+ completed = true
34
30
  result
35
31
  rescue StandardError => e
36
32
  # Re-raise if it's not an async timeout or configured exception
37
33
  raise unless e.is_a?(async_timeout_error_class) || @config[:exceptions].any? { |klass| e.is_a?(klass) }
38
34
 
39
- record_failure(BreakerMachines.monotonic_time - start_time, e)
40
- handle_failure
35
+ complete_call_failure(admission, start_time, e)
36
+ completed = true
41
37
  raise unless @config[:fallback]
42
38
 
43
39
  invoke_fallback_with_async(e)
40
+ ensure
41
+ release_abandoned_admission(admission) unless completed
44
42
  end
45
43
  end
46
44
 
45
+ def execute_async_operation(&)
46
+ return execute_hedged(&) if @config[:hedged_requests] || @config[:backends]
47
+
48
+ execute_with_async_timeout(@config[:timeout], &)
49
+ end
50
+
47
51
  # Execute a block with optional timeout using modern Async API
48
52
  def execute_with_async_timeout(timeout, &)
49
53
  if timeout
@@ -60,14 +64,7 @@ module BreakerMachines
60
64
  when BreakerMachines::DSL::ParallelFallbackWrapper
61
65
  invoke_parallel_fallbacks(@config[:fallback].fallbacks, error)
62
66
  when Proc
63
- result = if @config[:owner]
64
- @config[:owner].instance_exec(error, &@config[:fallback])
65
- else
66
- @config[:fallback].call(error)
67
- end
68
-
69
- # If the fallback returns an Async::Task, wait for it
70
- result.is_a?(::Async::Task) ? result.wait : result
67
+ invoke_proc_fallback_async(@config[:fallback], error)
71
68
  when Array
72
69
  # Try each fallback in order until one succeeds
73
70
  last_error = error
@@ -85,6 +82,16 @@ module BreakerMachines
85
82
 
86
83
  private
87
84
 
85
+ def invoke_proc_fallback_async(fallback, error)
86
+ result = if @config[:owner]
87
+ @config[:owner].instance_exec(error, &fallback)
88
+ else
89
+ fallback.call(error)
90
+ end
91
+
92
+ result.is_a?(::Async::Task) ? result.wait : result
93
+ end
94
+
88
95
  def invoke_single_fallback_async(fallback, error)
89
96
  case fallback
90
97
  when Proc
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # Admission reserves calls under short locks and tracks their state generation.
6
+ module Admission
7
+ extend ActiveSupport::Concern
8
+
9
+ Call = Data.define(:state, :state_epoch) do
10
+ def closed?
11
+ state == :closed
12
+ end
13
+
14
+ def half_open?
15
+ state == :half_open
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def attempt_recovery_if_ready
22
+ return unless open? && reset_timeout_elapsed?
23
+
24
+ @mutex.with_write_lock do
25
+ attempt_recovery if open? && reset_timeout_elapsed?
26
+ end
27
+ end
28
+
29
+ def admit_call
30
+ @mutex.with_read_lock do
31
+ case status_name
32
+ when :closed
33
+ Call.new(state: :closed, state_epoch: @state_epoch.value)
34
+ when :half_open
35
+ admit_half_open_call
36
+ end
37
+ end
38
+ end
39
+
40
+ def admit_half_open_call
41
+ state_epoch = @state_epoch.value
42
+ new_attempts = @half_open_attempts.increment
43
+
44
+ return Call.new(state: :half_open, state_epoch:) if new_attempts <= @config[:half_open_calls]
45
+
46
+ # This caller lost the race for a probe slot.
47
+ @half_open_attempts.decrement
48
+ nil
49
+ end
50
+
51
+ def admission_current?(admission)
52
+ admission.state_epoch == @state_epoch.value
53
+ end
54
+
55
+ def handle_success(admission)
56
+ return unless admission.half_open?
57
+
58
+ @mutex.with_write_lock do
59
+ return unless admission_current?(admission) && half_open?
60
+
61
+ successful_attempts = @half_open_successes.increment
62
+ next unless successful_attempts >= @config[:half_open_calls] || success_threshold_reached?
63
+
64
+ @half_open_attempts.value = 0
65
+ @half_open_successes.value = 0
66
+ reset
67
+ end
68
+ end
69
+
70
+ def handle_failure(admission)
71
+ @mutex.with_write_lock do
72
+ return unless admission_current?(admission)
73
+
74
+ if admission.closed? && closed? && failure_threshold_exceeded?
75
+ trip
76
+ elsif admission.half_open? && half_open?
77
+ @half_open_attempts.value = 0
78
+ @half_open_successes.value = 0
79
+ trip
80
+ end
81
+ end
82
+ end
83
+
84
+ def release_abandoned_admission(admission)
85
+ return unless admission.half_open?
86
+
87
+ @mutex.with_write_lock do
88
+ next unless admission_current?(admission) && half_open?
89
+ next unless @half_open_attempts.value.positive?
90
+
91
+ @half_open_attempts.decrement
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -10,6 +10,7 @@ module BreakerMachines
10
10
 
11
11
  included do
12
12
  include Circuit::Configuration
13
+ include Circuit::Admission
13
14
  include Circuit::Execution
14
15
  include Circuit::HedgedExecution
15
16
  include Circuit::Introspection
@@ -29,24 +30,29 @@ module BreakerMachines
29
30
  # Use global default storage if not specified
30
31
  @storage = @config[:storage] || create_default_storage
31
32
  @metrics = @config[:metrics]
33
+ initialize_runtime_state
34
+
35
+ restore_status_from_storage if @storage
36
+
37
+ # Register with global registry unless auto_register is disabled
38
+ BreakerMachines::Registry.instance.register(self) unless @config[:auto_register] == false
39
+ end
40
+
41
+ private
42
+
43
+ def initialize_runtime_state
32
44
  @opened_at = Concurrent::AtomicReference.new(nil)
33
45
  @half_open_attempts = Concurrent::AtomicFixnum.new(0)
34
46
  @half_open_successes = Concurrent::AtomicFixnum.new(0)
47
+ @state_epoch = Concurrent::AtomicFixnum.new(0)
35
48
  @mutex = Concurrent::ReentrantReadWriteLock.new
36
49
  @last_failure_at = Concurrent::AtomicReference.new(nil)
37
50
  @last_error = Concurrent::AtomicReference.new(nil)
38
51
 
39
52
  # Initialize semaphore for bulkheading if max_concurrent is set
40
53
  @semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
41
-
42
- restore_status_from_storage if @storage
43
-
44
- # Register with global registry unless auto_register is disabled
45
- BreakerMachines::Registry.instance.register(self) unless @config[:auto_register] == false
46
54
  end
47
55
 
48
- private
49
-
50
56
  def restore_status_from_storage
51
57
  stored_status = @storage.get_status(@name)
52
58
  return unless stored_status
@@ -31,100 +31,92 @@ module BreakerMachines
31
31
 
32
32
  private
33
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
34
+ def execute_with_state_check(&)
35
+ attempt_recovery_if_ready
41
36
 
42
37
  # Apply bulkheading first, outside of any locks
38
+ acquired = false
43
39
  if @semaphore
44
40
  acquired = @semaphore.try_acquire
45
- unless acquired
46
- # Reject immediately if we can't acquire semaphore
47
- return reject_call_bulkhead
48
- end
41
+ return reject_call_bulkhead unless acquired
49
42
  end
50
43
 
51
44
  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
45
+ admission = admit_call
46
+ unless admission
47
+ # An open-circuit fallback is user code and may yield. It must not
48
+ # retain a bulkhead permit or circuit lock while it runs.
49
+ @semaphore&.release if acquired
50
+ acquired = false
51
+ return reject_call
61
52
  end
53
+
54
+ execute_call(admission, &)
62
55
  ensure
63
56
  @semaphore&.release if @semaphore && acquired
64
57
  end
65
58
  end
66
59
 
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)
60
+ def execute_call(admission, &)
85
61
  # Use async version if fiber_safe is enabled
86
62
  if @config[:fiber_safe]
87
63
  # Ensure async is loaded and included
88
64
  Execution.load_async_support unless respond_to?(:execute_call_async)
89
- return execute_call_async(&block)
65
+ return execute_call_async(admission, &)
90
66
  end
91
67
 
68
+ execute_call_sync(admission, &)
69
+ end
70
+
71
+ def execute_call_sync(admission, &)
72
+ completed = false
92
73
  start_time = BreakerMachines.monotonic_time
93
74
 
94
75
  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
76
+ result = execute_sync_operation(&)
77
+ complete_call_success(admission, start_time)
78
+ completed = true
118
79
  result
119
80
  rescue *@config[:exceptions] => e
120
- record_failure(BreakerMachines.monotonic_time - start_time, e)
121
- handle_failure
81
+ complete_call_failure(admission, start_time, e)
82
+ completed = true
122
83
  raise unless @config[:fallback]
123
84
 
124
85
  invoke_fallback(e)
86
+ ensure
87
+ release_abandoned_admission(admission) unless completed
125
88
  end
126
89
  end
127
90
 
91
+ def execute_sync_operation(&)
92
+ warn_about_sync_timeout
93
+ return execute_hedged(&) if @config[:hedged_requests] || @config[:backends]
94
+
95
+ yield
96
+ end
97
+
98
+ def warn_about_sync_timeout
99
+ return unless @config[:timeout] && BreakerMachines.logger && BreakerMachines.config.log_events
100
+
101
+ # Forceful Ruby timeouts can interrupt code while it holds resources.
102
+ BreakerMachines.logger.warn(
103
+ "[BreakerMachines] Circuit '#{@name}' has timeout configured but " \
104
+ 'forceful timeouts are not implemented for safety. ' \
105
+ 'Please use timeout mechanisms provided by your libraries ' \
106
+ '(e.g., Net::HTTP read_timeout, ActiveRecord statement_timeout).'
107
+ )
108
+ end
109
+
110
+ def complete_call_success(admission, start_time)
111
+ record_success(BreakerMachines.monotonic_time - start_time)
112
+ handle_success(admission)
113
+ end
114
+
115
+ def complete_call_failure(admission, start_time, error)
116
+ record_failure(BreakerMachines.monotonic_time - start_time, error)
117
+ handle_failure(admission)
118
+ end
119
+
128
120
  def reject_call
129
121
  @metrics&.record_rejection(@name)
130
122
  invoke_callback(:on_reject)
@@ -144,43 +136,6 @@ module BreakerMachines
144
136
  invoke_fallback(error)
145
137
  end
146
138
 
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
139
  def failure_threshold_exceeded?
185
140
  if @config[:use_rate_threshold]
186
141
  # Rate-based threshold
@@ -9,6 +9,7 @@ module BreakerMachines
9
9
  private
10
10
 
11
11
  def on_circuit_open
12
+ @state_epoch.increment
12
13
  @opened_at.value = BreakerMachines.monotonic_time
13
14
  @storage&.set_status(@name, :open, @opened_at.value)
14
15
  if @storage.respond_to?(:record_event_with_details)
@@ -20,6 +21,7 @@ module BreakerMachines
20
21
  end
21
22
 
22
23
  def on_circuit_close
24
+ @state_epoch.increment
23
25
  @opened_at.value = nil
24
26
  @last_error.value = nil
25
27
  @last_failure_at.value = nil
@@ -33,6 +35,7 @@ module BreakerMachines
33
35
  end
34
36
 
35
37
  def on_circuit_half_open
38
+ @state_epoch.increment
36
39
  @half_open_attempts.value = 0
37
40
  @half_open_successes.value = 0
38
41
  @storage&.set_status(@name, :half_open)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BreakerMachines
4
- VERSION = '0.13.2'
4
+ VERSION = '0.17.0'
5
5
  end
@@ -2,6 +2,7 @@ module BreakerMachines
2
2
  class Circuit
3
3
  include StateManagement
4
4
  include Configuration
5
+ include Admission
5
6
  include Execution
6
7
  include Introspection
7
8
  include Callbacks
@@ -14,6 +15,7 @@ module BreakerMachines
14
15
  @opened_at: Concurrent::AtomicReference[Float?]
15
16
  @half_open_attempts: Concurrent::AtomicFixnum
16
17
  @half_open_successes: Concurrent::AtomicFixnum
18
+ @state_epoch: Concurrent::AtomicFixnum
17
19
  @mutex: Concurrent::ReentrantReadWriteLock
18
20
  @last_failure_at: Concurrent::AtomicReference[Float?]
19
21
  @last_error: Concurrent::AtomicReference[StandardError?]
@@ -22,6 +24,27 @@ module BreakerMachines
22
24
  attr_reader status: (:open | :closed | :half_open)
23
25
  end
24
26
 
27
+ module Admission
28
+ class Call
29
+ attr_reader state: (:closed | :half_open)
30
+ attr_reader state_epoch: Integer
31
+
32
+ def self.new: (state: (:closed | :half_open), state_epoch: Integer) -> Call
33
+ def closed?: () -> bool
34
+ def half_open?: () -> bool
35
+ end
36
+
37
+ private
38
+
39
+ def attempt_recovery_if_ready: () -> void
40
+ def admit_call: () -> Call?
41
+ def admit_half_open_call: () -> Call?
42
+ def admission_current?: (Call admission) -> bool
43
+ def handle_success: (Call admission) -> void
44
+ def handle_failure: (Call admission) -> void
45
+ def release_abandoned_admission: (Call admission) -> void
46
+ end
47
+
25
48
  module StateManagement
26
49
  interface _StateManagementState
27
50
  def status: () -> (:open | :closed | :half_open)
@@ -96,13 +119,15 @@ module BreakerMachines
96
119
 
97
120
  private
98
121
 
99
- def handle_open_status: () { () -> untyped } -> untyped
100
- def handle_half_open_status: () { () -> untyped } -> untyped
101
- def handle_closed_status: () { () -> untyped } -> untyped
102
- def execute_call: () { () -> untyped } -> untyped
122
+ def execute_with_state_check: [T] () { () -> T } -> untyped
123
+ def execute_call: [T] (Admission::Call admission) { () -> T } -> untyped
124
+ def execute_call_sync: [T] (Admission::Call admission) { () -> T } -> untyped
125
+ def execute_sync_operation: [T] () { () -> T } -> T
126
+ def warn_about_sync_timeout: () -> void
127
+ def complete_call_success: (Admission::Call admission, Float start_time) -> void
128
+ def complete_call_failure: (Admission::Call admission, Float start_time, Exception error) -> void
103
129
  def reject_call: () -> untyped
104
- def handle_success: () -> void
105
- def handle_failure: () -> void
130
+ def reject_call_bulkhead: () -> untyped
106
131
  def failure_threshold_exceeded?: () -> bool
107
132
  def success_threshold_reached?: () -> bool
108
133
  def record_success: (Float duration) -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breaker_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.2
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -154,6 +154,7 @@ files:
154
154
  - lib/breaker_machines/async_support.rb
155
155
  - lib/breaker_machines/cascading_circuit.rb
156
156
  - lib/breaker_machines/circuit.rb
157
+ - lib/breaker_machines/circuit/admission.rb
157
158
  - lib/breaker_machines/circuit/async_state_management.rb
158
159
  - lib/breaker_machines/circuit/base.rb
159
160
  - lib/breaker_machines/circuit/callbacks.rb