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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e76d6f4335010b14f4ee48ac1366c060bd6b5feaad379896d857856c3dc6a2d
4
- data.tar.gz: d7f48bec133630d584387aaa56df9c3cd3884e8f9771353e8df7e214b8df7431
3
+ metadata.gz: 97bc515f00b4402af03e813a121969745726a3635a8475bd3066877294f535a0
4
+ data.tar.gz: 20370133d03443108818133a98790194a4669a5b9daafdb1808d537b3915bb32
5
5
  SHA512:
6
- metadata.gz: f98f52400de806bb0df4784d0635560764600ba812b5bc7e99c5eebc8f4ab1d884890d08ca399e458d1f5ca4a1b9192be46562e9b3c66aa311923f0a59f58604
7
- data.tar.gz: c5349911ce027af37b0c41557feb9c0126a1d6d11539650e48f8151855bd7512a859711f0335e9934c18abd879ce28b39adde778bc85d751cdd89728a3c768af
6
+ metadata.gz: 365d62466c1dae963c4f26e4f7920868358d9a39cd627bbc017ee7f8d36807e03ae808d1d4057dfee54b2edfc3e1dadaf36bf687b33aed8201bafac1a38f168f
7
+ data.tar.gz: 54fe1284d6710fab9a347b05821c41ea98f04212b224752d6bfc4f7d3fd995febb431acfee57f83a30859b2a7c7a138da59bb1c16ebf15032f018530c72fe397
data/README.md CHANGED
@@ -44,6 +44,10 @@ Built on the battle-tested `state_machines` gem, because I don't reinvent wheels
44
44
 
45
45
  - **Thread-safe** circuit breaker implementation
46
46
  - **Fiber-safe mode** for async Ruby (Falcon, async gem)
47
+ - **AsyncCircuit** class with mutex-protected state transitions
48
+ - **Circuit Groups** for managing related circuits with dependencies
49
+ - **Coordinated State Management** for dependency-aware transitions
50
+ - **Cascading Circuit Breakers** for modeling system dependencies
47
51
  - **Hedged requests** for latency reduction
48
52
  - **Multiple backends** with automatic failover
49
53
  - **Bulkheading** to limit concurrent requests
@@ -52,21 +56,39 @@ Built on the battle-tested `state_machines` gem, because I don't reinvent wheels
52
56
  - **Pluggable storage** (Memory, Redis, Custom)
53
57
  - **Rich callbacks** and instrumentation
54
58
  - **ActiveSupport::Notifications** integration
59
+ - **Cross-platform support** - Optimized for MRI, JRuby, and TruffleRuby
55
60
 
56
61
  ## Documentation
57
62
 
63
+ ### Core Features
58
64
  - **Getting Started Guide** (docs/GETTING_STARTED.md) - Installation and basic usage
59
65
  - **Configuration Reference** (docs/CONFIGURATION.md) - All configuration options
60
66
  - **Advanced Patterns** (docs/ADVANCED_PATTERNS.md) - Complex scenarios and patterns
67
+
68
+ ### Advanced Features
69
+ - **Circuit Groups** (docs/CIRCUIT_GROUPS.md) - Managing related circuits with dependencies
70
+ - **Coordinated State Management** (docs/COORDINATED_STATE_MANAGEMENT.md) - Dependency-aware state transitions
71
+ - **Cascading Circuit Breakers** (docs/CASCADING_CIRCUITS.md) - Modeling system dependencies
72
+
73
+ ### Async & Concurrency
74
+ - **Async Mode** (docs/ASYNC.md) - Fiber-safe operations and AsyncCircuit
75
+ - **Async Storage Examples** (docs/ASYNC_STORAGE_EXAMPLES.md) - Non-blocking storage backends
76
+
77
+ ### Storage & Persistence
61
78
  - **Persistence Options** (docs/PERSISTENCE.md) - Storage backends and distributed state
62
- - **Observability Guide** (docs/OBSERVABILITY.md) - Monitoring and metrics
63
- - **Async Mode** (docs/ASYNC.md) - Fiber-safe operations
79
+
80
+ ### Testing
64
81
  - **Testing Guide** (docs/TESTING.md) - Testing strategies
65
82
  - [RSpec Testing](docs/TESTING_RSPEC.md)
66
83
  - [ActiveSupport Testing](docs/TESTING_ACTIVESUPPORT.md)
84
+
85
+ ### Integration & Monitoring
67
86
  - **Rails Integration** (docs/RAILS_INTEGRATION.md) - Rails-specific patterns
68
- - **Horror Stories** (docs/HORROR_STORIES.md) - Real production failures and lessons learned
87
+ - **Observability Guide** (docs/OBSERVABILITY.md) - Monitoring and metrics
88
+
89
+ ### Reference
69
90
  - **API Reference** (docs/API_REFERENCE.md) - Complete API documentation
91
+ - **Horror Stories** (docs/HORROR_STORIES.md) - Real production failures and lessons learned
70
92
 
71
93
  ## Why BreakerMachines?
72
94
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Requires async gem ~> 2.31.0 for modern async patterns
4
+
5
+ require_relative 'circuit/async_state_management'
6
+
7
+ module BreakerMachines
8
+ # AsyncCircuit provides a circuit breaker with async-enabled state machine
9
+ # for thread-safe, fiber-safe concurrent operations
10
+ class AsyncCircuit < Circuit
11
+ include Circuit::AsyncStateManagement
12
+
13
+ # Additional async-specific methods
14
+ def call_async(&block)
15
+ require 'async' unless defined?(::Async)
16
+
17
+ Async do
18
+ call(&block)
19
+ end
20
+ end
21
+
22
+ # Fire state transition events asynchronously
23
+ # @param event_name [Symbol] The event to fire
24
+ # @return [Async::Task] The async task
25
+ def fire_async(event_name)
26
+ require 'async' unless defined?(::Async)
27
+
28
+ fire_event_async(event_name)
29
+ end
30
+
31
+ # Check circuit health asynchronously
32
+ # Useful for monitoring multiple circuits concurrently
33
+ def health_check_async
34
+ require 'async' unless defined?(::Async)
35
+
36
+ Async do
37
+ {
38
+ name: @name,
39
+ status: status_name,
40
+ open: open?,
41
+ stats: stats.to_h,
42
+ can_recover: open? && reset_timeout_elapsed?
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # This file contains all async-related functionality for fiber-safe mode
4
4
  # It is only loaded when fiber_safe mode is enabled
5
+ # Requires async gem ~> 2.31.0 for modern timeout API and Promise features
5
6
 
6
7
  require 'async'
7
8
  require 'async/task'
@@ -18,7 +19,7 @@ module BreakerMachines
18
19
 
19
20
  # Execute a call with async support (fiber-safe mode)
20
21
  def execute_call_async(&)
21
- start_time = monotonic_time
22
+ start_time = BreakerMachines.monotonic_time
22
23
 
23
24
  begin
24
25
  # Execute with hedged requests if enabled
@@ -28,14 +29,14 @@ module BreakerMachines
28
29
  execute_with_async_timeout(@config[:timeout], &)
29
30
  end
30
31
 
31
- record_success(monotonic_time - start_time)
32
+ record_success(BreakerMachines.monotonic_time - start_time)
32
33
  handle_success
33
34
  result
34
35
  rescue StandardError => e
35
36
  # Re-raise if it's not an async timeout or configured exception
36
37
  raise unless e.is_a?(async_timeout_error_class) || @config[:exceptions].any? { |klass| e.is_a?(klass) }
37
38
 
38
- record_failure(monotonic_time - start_time, e)
39
+ record_failure(BreakerMachines.monotonic_time - start_time, e)
39
40
  handle_failure
40
41
  raise unless @config[:fallback]
41
42
 
@@ -43,11 +44,11 @@ module BreakerMachines
43
44
  end
44
45
  end
45
46
 
46
- # Execute a block with optional timeout using Async
47
+ # Execute a block with optional timeout using modern Async API
47
48
  def execute_with_async_timeout(timeout, &)
48
49
  if timeout
49
- # Use safe, cooperative timeout from async gem
50
- ::Async::Task.current.with_timeout(timeout, &)
50
+ # Use modern timeout API - the flexible with_timeout API is on the task level
51
+ Async::Task.current.with_timeout(timeout, &)
51
52
  else
52
53
  yield
53
54
  end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'coordinated_circuit'
4
+
5
+ module BreakerMachines
6
+ # CascadingCircuit extends the CoordinatedCircuit class with the ability to automatically
7
+ # trip dependent circuits when this circuit opens. This enables sophisticated
8
+ # failure cascade modeling, similar to how critical system failures in a starship
9
+ # would cascade to dependent subsystems.
10
+ #
11
+ # @example Starship network dependency
12
+ # # Network circuit that cascades to dependent systems
13
+ # network_circuit = BreakerMachines::CascadingCircuit.new('subspace_network', {
14
+ # failure_threshold: 1,
15
+ # cascades_to: ['weapons_targeting', 'navigation_sensors', 'communications'],
16
+ # emergency_protocol: :red_alert
17
+ # })
18
+ #
19
+ # # When network fails, all dependent systems are automatically tripped
20
+ # network_circuit.call { raise 'Subspace relay offline!' }
21
+ # # => All dependent circuits are now open
22
+ #
23
+ class CascadingCircuit < CoordinatedCircuit
24
+ attr_reader :dependent_circuits, :emergency_protocol
25
+
26
+ def initialize(name, config = {})
27
+ @dependent_circuits = Array(config.delete(:cascades_to))
28
+ @emergency_protocol = config.delete(:emergency_protocol)
29
+
30
+ super
31
+ end
32
+
33
+ # Override the trip method to include cascading behavior
34
+ def trip!
35
+ result = super
36
+ perform_cascade if result && @dependent_circuits.any?
37
+ result
38
+ end
39
+
40
+ # Force cascade failure to all dependent circuits
41
+ def cascade_failure!
42
+ perform_cascade
43
+ end
44
+
45
+ # Get the current status of all dependent circuits
46
+ def dependent_status
47
+ return {} if @dependent_circuits.empty?
48
+
49
+ @dependent_circuits.each_with_object({}) do |circuit_name, status|
50
+ circuit = BreakerMachines.registry.find(circuit_name)
51
+ status[circuit_name] = circuit ? circuit.status_name : :not_found
52
+ end
53
+ end
54
+
55
+ # Check if any dependent circuits are open
56
+ def dependents_compromised?
57
+ @dependent_circuits.any? do |circuit_name|
58
+ circuit = BreakerMachines.registry.find(circuit_name)
59
+ circuit&.open?
60
+ end
61
+ end
62
+
63
+ # Summary that includes cascade information
64
+ def summary
65
+ base_summary = super
66
+ return base_summary if @dependent_circuits.empty?
67
+
68
+ if @cascade_triggered_at&.value
69
+ compromised_count = dependent_status.values.count(:open)
70
+ " CASCADE TRIGGERED: #{compromised_count}/#{@dependent_circuits.length} dependent systems compromised."
71
+ else
72
+ " Monitoring #{@dependent_circuits.length} dependent systems."
73
+ end
74
+
75
+ base_summary + cascade_info_text
76
+ end
77
+
78
+ # Provide cascade info for introspection
79
+ def cascade_info
80
+ BreakerMachines::CascadeInfo.new(
81
+ dependent_circuits: @dependent_circuits,
82
+ emergency_protocol: @emergency_protocol,
83
+ cascade_triggered_at: @cascade_triggered_at&.value,
84
+ dependent_status: dependent_status
85
+ )
86
+ end
87
+
88
+ private
89
+
90
+ def perform_cascade
91
+ return if @dependent_circuits.empty?
92
+
93
+ cascade_results = []
94
+ @cascade_triggered_at ||= Concurrent::AtomicReference.new
95
+ @cascade_triggered_at.value = BreakerMachines.monotonic_time
96
+
97
+ @dependent_circuits.each do |circuit_name|
98
+ # First try to find circuit in registry
99
+ circuit = BreakerMachines.registry.find(circuit_name)
100
+
101
+ # If not found and we have an owner, try to get it from the owner
102
+ if !circuit && @config[:owner]
103
+ owner = @config[:owner]
104
+ # Handle WeakRef if present
105
+ owner = owner.__getobj__ if owner.is_a?(WeakRef)
106
+
107
+ circuit = owner.circuit(circuit_name) if owner.respond_to?(:circuit)
108
+ end
109
+
110
+ next unless circuit
111
+ next unless circuit.closed? || circuit.half_open?
112
+
113
+ # Force the dependent circuit to open
114
+ circuit.force_open!
115
+ cascade_results << circuit_name
116
+
117
+ BreakerMachines.instrument('cascade_failure', {
118
+ source_circuit: @name,
119
+ target_circuit: circuit_name,
120
+ emergency_protocol: @emergency_protocol
121
+ })
122
+ end
123
+
124
+ # Trigger emergency protocol if configured
125
+ trigger_emergency_protocol(cascade_results) if @emergency_protocol && cascade_results.any?
126
+
127
+ # Invoke cascade callback if configured
128
+ if @config[:on_cascade]
129
+ begin
130
+ @config[:on_cascade].call(cascade_results) if @config[:on_cascade].respond_to?(:call)
131
+ rescue StandardError => e
132
+ # Log callback error but don't fail the cascade
133
+ BreakerMachines.logger&.error "Cascade callback error: #{e.message}"
134
+ end
135
+ end
136
+
137
+ cascade_results
138
+ end
139
+
140
+ def trigger_emergency_protocol(affected_circuits)
141
+ BreakerMachines.instrument('emergency_protocol_triggered', {
142
+ protocol: @emergency_protocol,
143
+ source_circuit: @name,
144
+ affected_circuits: affected_circuits
145
+ })
146
+
147
+ # Allow custom emergency protocol handling
148
+ owner = @config[:owner]
149
+ if owner.respond_to?(@emergency_protocol, true)
150
+ begin
151
+ owner.send(@emergency_protocol, affected_circuits)
152
+ rescue StandardError => e
153
+ BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
154
+ end
155
+ elsif respond_to?(@emergency_protocol, true)
156
+ begin
157
+ send(@emergency_protocol, affected_circuits)
158
+ rescue StandardError => e
159
+ BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
160
+ end
161
+ end
162
+ end
163
+
164
+ # Override the on_circuit_open callback to include cascading
165
+ def on_circuit_open
166
+ super # Call the original implementation
167
+ perform_cascade if @dependent_circuits.any?
168
+ end
169
+
170
+ # Force open should also cascade
171
+ def force_open!
172
+ result = super
173
+ perform_cascade if result && @dependent_circuits.any?
174
+ result
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # AsyncStateManagement provides state machine functionality with async support
6
+ # leveraging state_machines' async: true parameter for thread-safe operations
7
+ module AsyncStateManagement
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ # Enable async mode for thread-safe state transitions
12
+ # This automatically provides:
13
+ # - Mutex-protected state reads/writes
14
+ # - Fiber-safe execution
15
+ # - Concurrent transition handling
16
+ state_machine :status, initial: :closed, async: true do
17
+ event :trip do
18
+ transition closed: :open
19
+ transition half_open: :open
20
+ end
21
+
22
+ event :attempt_recovery do
23
+ transition open: :half_open
24
+ end
25
+
26
+ event :reset do
27
+ transition %i[open half_open] => :closed
28
+ transition closed: :closed
29
+ end
30
+
31
+ event :force_open do
32
+ transition any => :open
33
+ end
34
+
35
+ event :force_close do
36
+ transition any => :closed
37
+ end
38
+
39
+ event :hard_reset do
40
+ transition any => :closed
41
+ end
42
+
43
+ before_transition on: :hard_reset do |circuit|
44
+ circuit.storage.clear(circuit.name) if circuit.storage
45
+ circuit.half_open_attempts.value = 0
46
+ circuit.half_open_successes.value = 0
47
+ end
48
+
49
+ # Async-safe callbacks using modern API
50
+ after_transition to: :open do |circuit|
51
+ circuit.send(:on_circuit_open)
52
+ end
53
+
54
+ after_transition to: :closed do |circuit|
55
+ circuit.send(:on_circuit_close)
56
+ end
57
+
58
+ after_transition from: :open, to: :half_open do |circuit|
59
+ circuit.send(:on_circuit_half_open)
60
+ end
61
+ end
62
+
63
+ # Additional async event methods are automatically generated:
64
+ # - trip_async! - Returns Async::Task
65
+ # - attempt_recovery_async! - Returns Async::Task
66
+ # - reset_async! - Returns Async::Task
67
+ # - fire_event_async(:event_name) - Generic async event firing
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module BreakerMachines
6
+ class Circuit
7
+ # Base provides the common initialization and setup logic shared by all circuit types
8
+ module Base
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ include Circuit::Configuration
13
+ include Circuit::Execution
14
+ include Circuit::HedgedExecution
15
+ include Circuit::Introspection
16
+ include Circuit::Callbacks
17
+ include Circuit::StateCallbacks
18
+
19
+ attr_reader :name, :config, :opened_at, :storage, :metrics, :semaphore
20
+ attr_reader :half_open_attempts, :half_open_successes, :mutex
21
+ attr_reader :last_failure_at, :last_error
22
+ end
23
+
24
+ def initialize(name, options = {})
25
+ @name = name
26
+ @config = default_config.merge(options)
27
+ # Always use a storage backend for proper sliding window implementation
28
+ # Use global default storage if not specified
29
+ @storage = @config[:storage] || create_default_storage
30
+ @metrics = @config[:metrics]
31
+ @opened_at = Concurrent::AtomicReference.new(nil)
32
+ @half_open_attempts = Concurrent::AtomicFixnum.new(0)
33
+ @half_open_successes = Concurrent::AtomicFixnum.new(0)
34
+ @mutex = Concurrent::ReentrantReadWriteLock.new
35
+ @last_failure_at = Concurrent::AtomicReference.new(nil)
36
+ @last_error = Concurrent::AtomicReference.new(nil)
37
+
38
+ # Initialize semaphore for bulkheading if max_concurrent is set
39
+ @semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
40
+
41
+ restore_status_from_storage if @storage
42
+
43
+ # Register with global registry unless auto_register is disabled
44
+ BreakerMachines::Registry.instance.register(self) unless @config[:auto_register] == false
45
+ end
46
+
47
+ private
48
+
49
+ def restore_status_from_storage
50
+ stored_status = @storage.get_status(@name)
51
+ return unless stored_status
52
+
53
+ self.status = stored_status.status.to_s
54
+ @opened_at.value = stored_status.opened_at if stored_status.opened_at
55
+ end
56
+ end
57
+ end
58
+ end
@@ -120,19 +120,14 @@ module BreakerMachines
120
120
  end
121
121
  end
122
122
 
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?
128
-
129
- raise error_queue.pop if error_queue.size >= fallbacks.size
123
+ threads.each(&:join)
130
124
 
131
- sleep 0.001
132
- end
133
- end
134
- ensure
135
- threads.each(&:kill)
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
136
131
  end
137
132
  end
138
133
  end
@@ -11,30 +11,6 @@ module BreakerMachines
11
11
  attr_reader :name, :config, :opened_at
12
12
  end
13
13
 
14
- def initialize(name, options = {})
15
- @name = name
16
- @config = default_config.merge(options)
17
- # Always use a storage backend for proper sliding window implementation
18
- # Use global default storage if not specified
19
- @storage = @config[:storage] || create_default_storage
20
- @metrics = @config[:metrics]
21
- @opened_at = Concurrent::AtomicReference.new(nil)
22
- @half_open_attempts = Concurrent::AtomicFixnum.new(0)
23
- @half_open_successes = Concurrent::AtomicFixnum.new(0)
24
- @mutex = Concurrent::ReentrantReadWriteLock.new
25
- @last_failure_at = Concurrent::AtomicReference.new(nil)
26
- @last_error = Concurrent::AtomicReference.new(nil)
27
-
28
- # Initialize semaphore for bulkheading if max_concurrent is set
29
- @semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
30
-
31
- super() # Initialize state machine
32
- restore_status_from_storage if @storage
33
-
34
- # Register with global registry unless auto_register is disabled
35
- BreakerMachines::Registry.instance.register(self) unless @config[:auto_register] == false
36
- end
37
-
38
14
  private
39
15
 
40
16
  def default_config
@@ -78,8 +54,12 @@ module BreakerMachines
78
54
  when :null
79
55
  BreakerMachines::Storage::Null.new
80
56
  else
81
- # Allow for custom storage class names
82
- BreakerMachines.config.default_storage.new
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
83
63
  end
84
64
  end
85
65
  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: ->(circuit) { circuit.recovery_allowed? }
21
+ end
22
+
23
+ event :reset do
24
+ transition %i[open half_open] => :closed,
25
+ if: ->(circuit) { circuit.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) if circuit.storage
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