breaker_machines 0.3.0 → 0.5.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -3
  3. data/lib/breaker_machines/async_circuit.rb +47 -0
  4. data/lib/breaker_machines/async_support.rb +7 -6
  5. data/lib/breaker_machines/cascading_circuit.rb +177 -0
  6. data/lib/breaker_machines/circuit/async_state_management.rb +71 -0
  7. data/lib/breaker_machines/circuit/base.rb +58 -0
  8. data/lib/breaker_machines/circuit/callbacks.rb +7 -12
  9. data/lib/breaker_machines/circuit/configuration.rb +6 -26
  10. data/lib/breaker_machines/circuit/coordinated_state_management.rb +117 -0
  11. data/lib/breaker_machines/circuit/execution.rb +4 -8
  12. data/lib/breaker_machines/circuit/hedged_execution.rb +115 -0
  13. data/lib/breaker_machines/circuit/introspection.rb +36 -20
  14. data/lib/breaker_machines/circuit/state_callbacks.rb +60 -0
  15. data/lib/breaker_machines/circuit/state_management.rb +15 -61
  16. data/lib/breaker_machines/circuit.rb +1 -8
  17. data/lib/breaker_machines/circuit_group.rb +153 -0
  18. data/lib/breaker_machines/console.rb +12 -12
  19. data/lib/breaker_machines/coordinated_circuit.rb +10 -0
  20. data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
  21. data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
  22. data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
  23. data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
  24. data/lib/breaker_machines/dsl.rb +28 -241
  25. data/lib/breaker_machines/errors.rb +20 -0
  26. data/lib/breaker_machines/hedged_async_support.rb +29 -36
  27. data/lib/breaker_machines/registry.rb +3 -3
  28. data/lib/breaker_machines/storage/backend_state.rb +69 -0
  29. data/lib/breaker_machines/storage/bucket_memory.rb +3 -3
  30. data/lib/breaker_machines/storage/cache.rb +3 -3
  31. data/lib/breaker_machines/storage/fallback_chain.rb +56 -70
  32. data/lib/breaker_machines/storage/memory.rb +3 -3
  33. data/lib/breaker_machines/types.rb +41 -0
  34. data/lib/breaker_machines/version.rb +1 -1
  35. data/lib/breaker_machines.rb +29 -0
  36. metadata +21 -7
  37. data/lib/breaker_machines/hedged_execution.rb +0 -113
@@ -102,13 +102,13 @@ module BreakerMachines
102
102
 
103
103
  {
104
104
  summary: stats_summary,
105
- circuits: circuits.map(&:stats),
105
+ circuits: circuits.map { |c| c.stats.to_h },
106
106
  health: {
107
107
  open_count: circuits.count(&:open?),
108
108
  closed_count: circuits.count(&:closed?),
109
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] }
110
+ total_failures: circuits.sum { |c| c.stats.failure_count },
111
+ total_successes: circuits.sum { |c| c.stats.success_count }
112
112
  }
113
113
  }
114
114
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module Storage
5
+ # Manages the health state of a single storage backend using a state machine.
6
+ class BackendState
7
+ attr_reader :name, :failure_count, :last_failure_at
8
+ attr_accessor :health
9
+
10
+ def initialize(name, threshold:, timeout:)
11
+ @name = name
12
+ @threshold = threshold
13
+ @timeout = timeout
14
+ @failure_count = 0
15
+ @last_failure_at = nil
16
+ @health = :healthy
17
+ end
18
+
19
+ state_machine :health, initial: :healthy do
20
+ event :trip do
21
+ transition healthy: :unhealthy, if: :threshold_reached?
22
+ end
23
+
24
+ event :recover do
25
+ transition unhealthy: :healthy
26
+ end
27
+
28
+ event :reset do
29
+ transition all => :healthy
30
+ end
31
+
32
+ before_transition to: :unhealthy do |backend, _transition|
33
+ backend.instance_variable_set(:@unhealthy_until,
34
+ BreakerMachines.monotonic_time + backend.instance_variable_get(:@timeout))
35
+ end
36
+
37
+ after_transition to: :healthy do |backend, _transition|
38
+ backend.instance_variable_set(:@failure_count, 0)
39
+ backend.instance_variable_set(:@last_failure_at, nil)
40
+ backend.instance_variable_set(:@unhealthy_until, nil)
41
+ end
42
+ end
43
+
44
+ def record_failure
45
+ @failure_count += 1
46
+ @last_failure_at = BreakerMachines.monotonic_time
47
+ trip
48
+ end
49
+
50
+ def threshold_reached?
51
+ @failure_count >= @threshold
52
+ end
53
+
54
+ def unhealthy_due_to_timeout?
55
+ return false unless unhealthy?
56
+
57
+ unhealthy_until = instance_variable_get(:@unhealthy_until)
58
+ return false unless unhealthy_until
59
+
60
+ if BreakerMachines.monotonic_time > unhealthy_until
61
+ recover
62
+ false
63
+ else
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -27,10 +27,10 @@ module BreakerMachines
27
27
  circuit_data = @circuits[circuit_name]
28
28
  return nil unless circuit_data
29
29
 
30
- {
30
+ BreakerMachines::Status.new(
31
31
  status: circuit_data[:status],
32
32
  opened_at: circuit_data[:opened_at]
33
- }
33
+ )
34
34
  end
35
35
 
36
36
  def set_status(circuit_name, status, opened_at = nil)
@@ -160,7 +160,7 @@ module BreakerMachines
160
160
  end
161
161
 
162
162
  def monotonic_time
163
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
163
+ BreakerMachines.monotonic_time
164
164
  end
165
165
 
166
166
  def with_timeout(_timeout_ms)
@@ -16,10 +16,10 @@ module BreakerMachines
16
16
  data = @cache.read(status_key(circuit_name))
17
17
  return nil unless data
18
18
 
19
- {
19
+ BreakerMachines::Status.new(
20
20
  status: data[:status].to_sym,
21
21
  opened_at: data[:opened_at]
22
- }
22
+ )
23
23
  end
24
24
 
25
25
  def set_status(circuit_name, status, opened_at = nil)
@@ -162,7 +162,7 @@ module BreakerMachines
162
162
  end
163
163
 
164
164
  def monotonic_time
165
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ BreakerMachines.monotonic_time
166
166
  end
167
167
  end
168
168
  end
@@ -9,15 +9,18 @@ module BreakerMachines
9
9
  # cache stores (Redis, Memcached) will work properly. Memory-based backends (:memory,
10
10
  # :bucket_memory) are incompatible with DRb as they don't share state between processes.
11
11
  class FallbackChain < Base
12
- attr_reader :storage_configs, :storage_instances, :unhealthy_until, :circuit_breaker_threshold, :circuit_breaker_timeout
12
+ attr_reader :storage_configs, :storage_instances, :backend_states
13
13
 
14
- def initialize(storage_configs, **)
14
+ def initialize(storage_configs, circuit_breaker_threshold: 3, circuit_breaker_timeout: 30, **)
15
15
  super(**)
16
16
  @storage_configs = normalize_storage_configs(storage_configs)
17
17
  @storage_instances = {}
18
- @unhealthy_until = {}
19
- @circuit_breaker_threshold = 3 # After 3 failures, mark backend as unhealthy
20
- @circuit_breaker_timeout = 30 # Keep marked as unhealthy for 30 seconds
18
+ @circuit_breaker_threshold = circuit_breaker_threshold
19
+ @circuit_breaker_timeout = circuit_breaker_timeout
20
+ @backend_states = @storage_configs.to_h do |config|
21
+ [config[:backend],
22
+ BackendState.new(config[:backend], threshold: @circuit_breaker_threshold, timeout: @circuit_breaker_timeout)]
23
+ end
21
24
  validate_configs!
22
25
  end
23
26
 
@@ -72,27 +75,28 @@ module BreakerMachines
72
75
  instance.clear_all if instance.respond_to?(:clear_all)
73
76
  end
74
77
  storage_instances.clear
75
- @backend_failures&.clear
76
- unhealthy_until.clear
78
+ backend_states.each_value(&:reset)
77
79
  end
78
80
 
79
81
  private
80
82
 
81
83
  def execute_with_fallback(method, *args, **kwargs)
82
- chain_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ chain_started_at = BreakerMachines.monotonic_time
83
85
  attempted_backends = []
84
86
 
85
87
  storage_configs.each_with_index do |config, index|
86
- attempted_backends << config[:backend]
88
+ backend_type = config[:backend]
89
+ attempted_backends << backend_type
90
+ backend_state = backend_states[backend_type]
87
91
 
88
- if backend_unhealthy?(config[:backend])
89
- emit_backend_skipped_notification(config[:backend], method, index)
92
+ if backend_state.unhealthy_due_to_timeout?
93
+ emit_backend_skipped_notification(backend_type, method, index)
90
94
  next
91
95
  end
92
96
 
93
97
  begin
94
- backend = get_backend_instance(config[:backend])
95
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ backend = get_backend_instance(backend_type)
99
+ started_at = BreakerMachines.monotonic_time
96
100
 
97
101
  result = backend.with_timeout(config[:timeout]) do
98
102
  if kwargs.any?
@@ -102,35 +106,30 @@ module BreakerMachines
102
106
  end
103
107
  end
104
108
 
105
- # Success - emit success notification and reset failure count
106
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
107
- emit_operation_success_notification(config[:backend], method, duration_ms, index)
108
- reset_backend_failures(config[:backend])
109
+ duration_ms = ((BreakerMachines.monotonic_time - started_at) * 1000).round(2)
110
+ emit_operation_success_notification(backend_type, method, duration_ms, index)
111
+ reset_backend_failures(backend_type)
109
112
 
110
- # Emit chain success notification
111
- chain_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - chain_started_at) * 1000).round(2)
112
- emit_chain_success_notification(method, attempted_backends, config[:backend], chain_duration_ms)
113
+ chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
114
+ emit_chain_success_notification(method, attempted_backends, backend_type, chain_duration_ms)
113
115
 
114
116
  return result
115
117
  rescue BreakerMachines::StorageTimeoutError, BreakerMachines::StorageError, StandardError => e
116
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
117
-
118
- # Record the failure
119
- record_backend_failure(config[:backend], e, duration_ms)
120
-
121
- # Emit notification about the fallback
122
- emit_fallback_notification(config[:backend], e, duration_ms, index)
123
-
124
- # If this is the last backend, re-raise the error
125
- raise e if index == storage_configs.size - 1
118
+ duration_ms = ((BreakerMachines.monotonic_time - started_at) * 1000).round(2)
119
+ record_backend_failure(backend_type, e, duration_ms)
120
+ emit_fallback_notification(backend_type, e, duration_ms, index)
121
+
122
+ if index == storage_configs.size - 1
123
+ chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
124
+ emit_chain_failure_notification(method, attempted_backends, chain_duration_ms)
125
+ raise e
126
+ end
126
127
 
127
- # Continue to next backend
128
128
  next
129
129
  end
130
130
  end
131
131
 
132
- # If we get here, all backends were unhealthy
133
- chain_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - chain_started_at) * 1000).round(2)
132
+ chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
134
133
  emit_chain_failure_notification(method, attempted_backends, chain_duration_ms)
135
134
  raise BreakerMachines::StorageError, 'All storage backends are unhealthy'
136
135
  end
@@ -158,49 +157,34 @@ module BreakerMachines
158
157
  end
159
158
  end
160
159
 
161
- def backend_unhealthy?(backend_type)
162
- unhealthy_until_time = unhealthy_until[backend_type]
163
- return false unless unhealthy_until_time
164
-
165
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) > unhealthy_until_time
166
- unhealthy_until.delete(backend_type)
167
- false
168
- else
169
- true
170
- end
171
- end
172
-
173
- def record_backend_failure(backend_type, error, duration_ms)
174
- @backend_failures ||= {}
175
- @backend_failures[backend_type] ||= []
176
- @backend_failures[backend_type] << {
177
- error: error,
178
- duration_ms: duration_ms,
179
- timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
- }
181
-
182
- # Keep only recent failures (last 60 seconds)
183
- cutoff = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 60
184
- @backend_failures[backend_type].reject! { |f| f[:timestamp] < cutoff }
160
+ def record_backend_failure(backend_type, _error, _duration_ms)
161
+ backend_state = backend_states[backend_type]
162
+ return unless backend_state
185
163
 
186
- # Mark as unhealthy if too many failures
187
- return unless @backend_failures[backend_type].size >= circuit_breaker_threshold
164
+ previous_health = backend_state.health_name
165
+ backend_state.record_failure
166
+ new_health = backend_state.health_name
188
167
 
189
- unhealthy_until[backend_type] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + circuit_breaker_timeout
190
- emit_backend_health_change_notification(backend_type, :healthy, :unhealthy, @backend_failures[backend_type].size)
168
+ if new_health != previous_health
169
+ emit_backend_health_change_notification(backend_type, previous_health, new_health,
170
+ backend_state.failure_count)
171
+ end
191
172
  rescue StandardError => e
192
173
  # Don't let failure recording cause the whole chain to hang
193
174
  Rails.logger&.error("FallbackChain: Failed to record backend failure: #{e.message}")
194
175
  end
195
176
 
196
177
  def reset_backend_failures(backend_type)
197
- was_unhealthy = unhealthy_until.key?(backend_type)
198
- @backend_failures&.delete(backend_type)
199
- unhealthy_until.delete(backend_type)
178
+ backend_state = backend_states[backend_type]
179
+ return unless backend_state&.unhealthy?
200
180
 
201
- if was_unhealthy
202
- emit_backend_health_change_notification(backend_type, :unhealthy, :healthy, 0)
203
- end
181
+ previous_health = backend_state.health_name
182
+ backend_state.reset
183
+ new_health = backend_state.health_name
184
+
185
+ return unless new_health != previous_health
186
+
187
+ emit_backend_health_change_notification(backend_type, previous_health, new_health, 0)
204
188
  end
205
189
 
206
190
  def emit_fallback_notification(backend_type, error, duration_ms, backend_index)
@@ -227,25 +211,27 @@ module BreakerMachines
227
211
  end
228
212
 
229
213
  def emit_backend_skipped_notification(backend_type, method, backend_index)
214
+ backend_state = backend_states[backend_type]
230
215
  ActiveSupport::Notifications.instrument(
231
216
  'storage_backend_skipped.breaker_machines',
232
217
  backend: backend_type,
233
218
  operation: method,
234
219
  backend_index: backend_index,
235
220
  reason: 'unhealthy',
236
- unhealthy_until: unhealthy_until[backend_type]
221
+ unhealthy_until: backend_state&.instance_variable_get(:@unhealthy_until)
237
222
  )
238
223
  end
239
224
 
240
225
  def emit_backend_health_change_notification(backend_type, previous_state, new_state, failure_count)
226
+ backend_state = backend_states[backend_type]
241
227
  ActiveSupport::Notifications.instrument(
242
228
  'storage_backend_health.breaker_machines',
243
229
  backend: backend_type,
244
230
  previous_state: previous_state,
245
231
  new_state: new_state,
246
232
  failure_count: failure_count,
247
- threshold: circuit_breaker_threshold,
248
- recovery_time: new_state == :unhealthy ? unhealthy_until[backend_type] : nil
233
+ threshold: backend_state&.instance_variable_get(:@threshold),
234
+ recovery_time: new_state == :unhealthy ? backend_state&.instance_variable_get(:@unhealthy_until) : nil
249
235
  )
250
236
  end
251
237
 
@@ -23,10 +23,10 @@ module BreakerMachines
23
23
  circuit_data = @circuits[circuit_name]
24
24
  return nil unless circuit_data
25
25
 
26
- {
26
+ BreakerMachines::Status.new(
27
27
  status: circuit_data[:status],
28
28
  opened_at: circuit_data[:opened_at]
29
- }
29
+ )
30
30
  end
31
31
 
32
32
  def set_status(circuit_name, status, opened_at = nil)
@@ -130,7 +130,7 @@ module BreakerMachines
130
130
  end
131
131
 
132
132
  def monotonic_time
133
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ BreakerMachines.monotonic_time
134
134
  end
135
135
  end
136
136
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ # Represents the status of a circuit from storage
5
+ # @return [Symbol] status - the circuit status (:open, :closed, :half_open)
6
+ # @return [Float, nil] opened_at - the monotonic time when the circuit was opened
7
+ Status = Data.define(:status, :opened_at)
8
+
9
+ # Represents statistical information about a circuit
10
+ Stats = Data.define(
11
+ :state,
12
+ :failure_count,
13
+ :success_count,
14
+ :last_failure_at,
15
+ :opened_at,
16
+ :half_open_attempts,
17
+ :half_open_successes
18
+ )
19
+
20
+ # Represents information about the last error that occurred
21
+ # @return [String] error_class - the error class name
22
+ # @return [String] message - the error message
23
+ # @return [Float, nil] occurred_at - the monotonic time when the error occurred
24
+ ErrorInfo = Data.define(:error_class, :message, :occurred_at)
25
+
26
+ # Represents cascade information for cascading circuits
27
+ CascadeInfo = Data.define(
28
+ :dependent_circuits,
29
+ :emergency_protocol,
30
+ :cascade_triggered_at,
31
+ :dependent_status
32
+ )
33
+
34
+ # Represents an event in the circuit's event log
35
+ # @return [Symbol] type - the event type (:success, :failure, :state_change)
36
+ # @return [Float] timestamp - the monotonic timestamp
37
+ # @return [Float] duration - the duration in milliseconds
38
+ # @return [String, nil] error - the error message if applicable
39
+ # @return [Symbol, nil] new_state - the new state if this was a state change
40
+ Event = Data.define(:type, :timestamp, :duration, :error, :new_state)
41
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BreakerMachines
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -3,14 +3,18 @@
3
3
  require 'zeitwerk'
4
4
  require 'active_support'
5
5
  require 'active_support/core_ext'
6
+ require 'state_machines'
6
7
  require_relative 'breaker_machines/errors'
8
+ require_relative 'breaker_machines/types'
7
9
 
8
10
  loader = Zeitwerk::Loader.for_gem
9
11
  loader.inflector.inflect('dsl' => 'DSL')
10
12
  loader.ignore("#{__dir__}/breaker_machines/errors.rb")
13
+ loader.ignore("#{__dir__}/breaker_machines/types.rb")
11
14
  loader.ignore("#{__dir__}/breaker_machines/console.rb")
12
15
  loader.ignore("#{__dir__}/breaker_machines/async_support.rb")
13
16
  loader.ignore("#{__dir__}/breaker_machines/hedged_async_support.rb")
17
+ loader.ignore("#{__dir__}/breaker_machines/circuit/async_state_management.rb")
14
18
  loader.setup
15
19
 
16
20
  # BreakerMachines provides a thread-safe implementation of the Circuit Breaker pattern
@@ -77,6 +81,31 @@ module BreakerMachines
77
81
  def registry
78
82
  Registry.instance
79
83
  end
84
+
85
+ # Register a circuit with the global registry
86
+ def register(circuit)
87
+ registry.register(circuit)
88
+ end
89
+
90
+ # Reset the registry and configurations (useful for testing)
91
+ def reset!
92
+ registry.clear
93
+ config.default_storage = :bucket_memory
94
+ config.default_timeout = nil
95
+ config.default_reset_timeout = 60.seconds
96
+ config.default_failure_threshold = 5
97
+ config.log_events = true
98
+ config.fiber_safe = false
99
+ end
100
+
101
+ # Returns the current monotonic time in seconds.
102
+ # Monotonic time is guaranteed to always increase and is not affected
103
+ # by system clock adjustments, making it ideal for measuring durations.
104
+ #
105
+ # @return [Float] current monotonic time in seconds
106
+ def monotonic_time
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
80
109
  end
81
110
 
82
111
  # Set up notifications on first use
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breaker_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: concurrent-ruby
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.31.0
46
+ version: 0.100.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.31.0
53
+ version: 0.100.0
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -108,26 +108,40 @@ files:
108
108
  - LICENSE.txt
109
109
  - README.md
110
110
  - lib/breaker_machines.rb
111
+ - lib/breaker_machines/async_circuit.rb
111
112
  - lib/breaker_machines/async_support.rb
113
+ - lib/breaker_machines/cascading_circuit.rb
112
114
  - lib/breaker_machines/circuit.rb
115
+ - lib/breaker_machines/circuit/async_state_management.rb
116
+ - lib/breaker_machines/circuit/base.rb
113
117
  - lib/breaker_machines/circuit/callbacks.rb
114
118
  - lib/breaker_machines/circuit/configuration.rb
119
+ - lib/breaker_machines/circuit/coordinated_state_management.rb
115
120
  - lib/breaker_machines/circuit/execution.rb
121
+ - lib/breaker_machines/circuit/hedged_execution.rb
116
122
  - lib/breaker_machines/circuit/introspection.rb
123
+ - lib/breaker_machines/circuit/state_callbacks.rb
117
124
  - lib/breaker_machines/circuit/state_management.rb
125
+ - lib/breaker_machines/circuit_group.rb
118
126
  - lib/breaker_machines/console.rb
127
+ - lib/breaker_machines/coordinated_circuit.rb
119
128
  - lib/breaker_machines/dsl.rb
129
+ - lib/breaker_machines/dsl/cascading_circuit_builder.rb
130
+ - lib/breaker_machines/dsl/circuit_builder.rb
131
+ - lib/breaker_machines/dsl/hedged_builder.rb
132
+ - lib/breaker_machines/dsl/parallel_fallback_wrapper.rb
120
133
  - lib/breaker_machines/errors.rb
121
134
  - lib/breaker_machines/hedged_async_support.rb
122
- - lib/breaker_machines/hedged_execution.rb
123
135
  - lib/breaker_machines/registry.rb
124
136
  - lib/breaker_machines/storage.rb
137
+ - lib/breaker_machines/storage/backend_state.rb
125
138
  - lib/breaker_machines/storage/base.rb
126
139
  - lib/breaker_machines/storage/bucket_memory.rb
127
140
  - lib/breaker_machines/storage/cache.rb
128
141
  - lib/breaker_machines/storage/fallback_chain.rb
129
142
  - lib/breaker_machines/storage/memory.rb
130
143
  - lib/breaker_machines/storage/null.rb
144
+ - lib/breaker_machines/types.rb
131
145
  - lib/breaker_machines/version.rb
132
146
  - sig/README.md
133
147
  - sig/all.rbs
@@ -1,113 +0,0 @@
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