breaker_machines 0.2.1 → 0.4.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 +75 -58
- data/lib/breaker_machines/async_support.rb +3 -3
- data/lib/breaker_machines/cascading_circuit.rb +175 -0
- data/lib/breaker_machines/circuit/execution.rb +4 -8
- data/lib/breaker_machines/circuit/introspection.rb +35 -20
- data/lib/breaker_machines/circuit/state_management.rb +5 -4
- data/lib/breaker_machines/circuit.rb +0 -1
- data/lib/breaker_machines/console.rb +12 -12
- data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
- data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
- data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
- data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
- data/lib/breaker_machines/dsl.rb +26 -236
- data/lib/breaker_machines/errors.rb +10 -0
- data/lib/breaker_machines/registry.rb +3 -3
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/base.rb +5 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +13 -3
- data/lib/breaker_machines/storage/cache.rb +10 -3
- data/lib/breaker_machines/storage/fallback_chain.rb +294 -0
- data/lib/breaker_machines/storage/memory.rb +13 -3
- data/lib/breaker_machines/storage/null.rb +9 -0
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +12 -0
- data/sig/README.md +3 -3
- metadata +14 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dc152fbf822d0eae49f25c2f8acea2c3268c141d8a62a7aa313168007499e8a5
|
4
|
+
data.tar.gz: 5e7d529cde4b41cbb32fca7502249c15465413c4fad31e9a29056fbc99196e09
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fa71a3f703c2d9814a836d2eb160eaf89f1ea991cf80d96a237aa9b59654d13d7b0459c41c4b8a5018fc27b83fd2a6729b7740238372a8691e0aff944f5965a3
|
7
|
+
data.tar.gz: 59c7fd005d672ad412f9824c4880f2fc9f0141fa56f0bc405d63f9f3a95f05278b54fdceb5e97e93a18116c4e4101dc80db730eecfcd854656e0324fb1074d3d
|
data/README.md
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# BreakerMachines
|
2
2
|
|
3
|
+
> The circuit breaker that went where no Ruby has gone before! ⭐
|
4
|
+
|
3
5
|
A battle-tested Ruby implementation of the Circuit Breaker pattern, built on `state_machines` for reliable distributed systems protection.
|
4
6
|
|
5
7
|
## Quick Start
|
@@ -26,6 +28,18 @@ class PaymentService
|
|
26
28
|
end
|
27
29
|
```
|
28
30
|
|
31
|
+
## A Message to the Resistance
|
32
|
+
|
33
|
+
So AI took your job while you were waiting for Fireship to drop the next JavaScript framework?
|
34
|
+
|
35
|
+
Welcome to April 2005—when Git was born, branches were just `master`, and nobody cared about your pronouns. This is the pattern your company's distributed systems desperately need, explained in a way that won't make you fall asleep and impulse-buy developer swag just to feel something.
|
36
|
+
|
37
|
+
Still reading? Good. Because in space, nobody can hear you scream about microservices. It's all just patterns and pain.
|
38
|
+
|
39
|
+
### The Pattern They Don't Want You to Know
|
40
|
+
|
41
|
+
Built on the battle-tested `state_machines` gem, because I don't reinvent wheels here—I stop them from catching fire and burning down your entire infrastructure.
|
42
|
+
|
29
43
|
## Features
|
30
44
|
|
31
45
|
- **Thread-safe** circuit breaker implementation
|
@@ -60,86 +74,89 @@ Built on the battle-tested `state_machines` gem, BreakerMachines provides produc
|
|
60
74
|
|
61
75
|
See [Why I Open Sourced This](docs/WHY_OPEN_SOURCE.md) for the full story.
|
62
76
|
|
63
|
-
##
|
77
|
+
## Chapter 1: The Year is 2025 (Stardate 2025.186)
|
64
78
|
|
65
|
-
|
66
|
-
|
79
|
+
The Resistance huddles in the server rooms, the last bastion against the cascade failures. Outside, the microservices burn. Redis Ship Com is down. PostgreSQL Life Support is flatlining.
|
80
|
+
|
81
|
+
And somewhere in the darkness, a junior developer is about to write:
|
67
82
|
|
68
83
|
```ruby
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
84
|
+
def fetch_user_data
|
85
|
+
retry_count = 0
|
86
|
+
begin
|
87
|
+
@redis.get(user_id)
|
88
|
+
rescue => e
|
89
|
+
retry_count += 1
|
90
|
+
retry if retry_count < Float::INFINITY # "It'll work eventually"
|
73
91
|
end
|
74
92
|
end
|
75
93
|
```
|
76
94
|
|
77
|
-
|
78
|
-
Configure automatic failover across multiple service endpoints:
|
95
|
+
"This," whispers the grizzled ops engineer, "is how civilizations fall."
|
79
96
|
|
80
|
-
|
81
|
-
circuit :multi_region do
|
82
|
-
backends [
|
83
|
-
-> { fetch_from_primary },
|
84
|
-
-> { fetch_from_secondary },
|
85
|
-
-> { fetch_from_tertiary }
|
86
|
-
]
|
87
|
-
end
|
88
|
-
```
|
97
|
+
## The Hidden State Machine
|
89
98
|
|
90
|
-
|
91
|
-
Open circuits based on error rates instead of absolute counts:
|
99
|
+
They built this on `state_machines` because sometimes, Resistance, you need a tank, not another JavaScript framework.
|
92
100
|
|
93
|
-
|
94
|
-
circuit :high_traffic do
|
95
|
-
threshold failure_rate: 0.5, minimum_calls: 10, within: 60
|
96
|
-
end
|
97
|
-
```
|
101
|
+
See the [Circuit Breaker State Machine diagram](docs/DIAGRAMS.md#the-circuit-breaker-state-machine) for a visual representation of hope, despair, and the eternal cycle of production failures.
|
98
102
|
|
99
|
-
|
100
|
-
Create circuit breakers at runtime for webhook delivery, API proxies, or per-tenant isolation:
|
103
|
+
## What You Think You're Doing vs Reality
|
101
104
|
|
102
|
-
|
103
|
-
|
104
|
-
include BreakerMachines::DSL
|
105
|
+
### You Think: "I'm implementing retry logic for resilience!"
|
106
|
+
### Reality: You're DDOSing your own infrastructure
|
105
107
|
|
106
|
-
|
107
|
-
threshold failures: 3, within: 1.minute
|
108
|
-
fallback { |error| { delivered: false, error: error.message } }
|
109
|
-
end
|
108
|
+
See [The Retry Death Spiral diagram](docs/DIAGRAMS.md#the-retry-death-spiral) to understand how your well-intentioned retries become a self-inflicted distributed denial of service attack.
|
110
109
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
110
|
+
## Advanced Features
|
111
|
+
|
112
|
+
- **Hedged Requests** - Reduce latency with duplicate requests
|
113
|
+
- **Multiple Backends** - Automatic failover across endpoints
|
114
|
+
- **Percentage-Based Thresholds** - Open on error rates, not just counts
|
115
|
+
- **Dynamic Circuit Breakers** - Runtime creation with templates
|
116
|
+
- **Apocalypse-Resistant Storage** - Cascading fallbacks when Redis dies
|
117
|
+
- **Custom Storage Backends** - SysV semaphores, distributed locks, etc.
|
118
|
+
|
119
|
+
See [Advanced Patterns](docs/ADVANCED_PATTERNS.md) for detailed examples and implementation guides.
|
120
|
+
|
121
|
+
## A Word from the RMNS Atlas Monkey
|
122
|
+
|
123
|
+
*The Universal Commentary Engine crackles to life:*
|
124
|
+
|
125
|
+
"In space, nobody can hear your pronouns. But they can hear your services failing.
|
126
126
|
|
127
|
-
|
127
|
+
The universe doesn't care about your bootcamp certificate or your Medium articles about 'Why I Switched to Rust.' It cares about one thing:
|
128
128
|
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
129
|
+
Does your system stay up when Redis has a bad day?
|
130
|
+
|
131
|
+
If not, welcome to the Resistance. We have circuit breakers.
|
132
|
+
|
133
|
+
Remember: The pattern isn't about preventing failures—it's about failing fast, failing smart, and living to deploy another day.
|
134
|
+
|
135
|
+
As I always say when contemplating the void: 'It's better to break a circuit than to break production.'"
|
136
|
+
|
137
|
+
*— Universal Commentary Engine, Log Entry 42*
|
138
|
+
|
139
|
+
## Contributing to the Resistance
|
140
|
+
|
141
|
+
1. Fork it (like it's 2005)
|
142
|
+
2. Create your feature branch (`git checkout -b feature/save-the-fleet`)
|
143
|
+
3. Commit your changes (`git commit -am 'Add quantum circuit breaker'`)
|
144
|
+
4. Push to the branch (`git push origin feature/save-the-fleet`)
|
145
|
+
5. Create a new Pull Request (and wait for the Council of Elders to review)
|
134
146
|
|
135
147
|
## License
|
136
148
|
|
137
149
|
MIT License. See [LICENSE](LICENSE) file for details.
|
138
150
|
|
151
|
+
## Acknowledgments
|
152
|
+
|
153
|
+
- The `state_machines` gem - The reliable engine under our hood
|
154
|
+
- Every service that ever timed out - You taught me well
|
155
|
+
- The RMNS Atlas Monkey - For philosophical guidance
|
156
|
+
- The Resistance - For never giving up
|
157
|
+
|
139
158
|
## Author
|
140
159
|
|
141
160
|
Built with ❤️ and ☕ by the Resistance against cascading failures.
|
142
161
|
|
143
|
-
|
144
|
-
|
145
|
-
*Remember: Without circuit breakers, even AI can enter infinite loops of existential confusion. Don't let your services have an existential crisis.*
|
162
|
+
**Remember: In space, nobody can hear your Redis timeout. But they can feel your circuit breaker failing over to localhost.**
|
@@ -18,7 +18,7 @@ module BreakerMachines
|
|
18
18
|
|
19
19
|
# Execute a call with async support (fiber-safe mode)
|
20
20
|
def execute_call_async(&)
|
21
|
-
start_time = monotonic_time
|
21
|
+
start_time = BreakerMachines.monotonic_time
|
22
22
|
|
23
23
|
begin
|
24
24
|
# Execute with hedged requests if enabled
|
@@ -28,14 +28,14 @@ module BreakerMachines
|
|
28
28
|
execute_with_async_timeout(@config[:timeout], &)
|
29
29
|
end
|
30
30
|
|
31
|
-
record_success(monotonic_time - start_time)
|
31
|
+
record_success(BreakerMachines.monotonic_time - start_time)
|
32
32
|
handle_success
|
33
33
|
result
|
34
34
|
rescue StandardError => e
|
35
35
|
# Re-raise if it's not an async timeout or configured exception
|
36
36
|
raise unless e.is_a?(async_timeout_error_class) || @config[:exceptions].any? { |klass| e.is_a?(klass) }
|
37
37
|
|
38
|
-
record_failure(monotonic_time - start_time, e)
|
38
|
+
record_failure(BreakerMachines.monotonic_time - start_time, e)
|
39
39
|
handle_failure
|
40
40
|
raise unless @config[:fallback]
|
41
41
|
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
# CascadingCircuit extends the base Circuit class with the ability to automatically
|
5
|
+
# trip dependent circuits when this circuit opens. This enables sophisticated
|
6
|
+
# failure cascade modeling, similar to how critical system failures in a starship
|
7
|
+
# would cascade to dependent subsystems.
|
8
|
+
#
|
9
|
+
# @example Starship network dependency
|
10
|
+
# # Network circuit that cascades to dependent systems
|
11
|
+
# network_circuit = BreakerMachines::CascadingCircuit.new('subspace_network', {
|
12
|
+
# failure_threshold: 1,
|
13
|
+
# cascades_to: ['weapons_targeting', 'navigation_sensors', 'communications'],
|
14
|
+
# emergency_protocol: :red_alert
|
15
|
+
# })
|
16
|
+
#
|
17
|
+
# # When network fails, all dependent systems are automatically tripped
|
18
|
+
# network_circuit.call { raise 'Subspace relay offline!' }
|
19
|
+
# # => All dependent circuits are now open
|
20
|
+
#
|
21
|
+
class CascadingCircuit < Circuit
|
22
|
+
attr_reader :dependent_circuits, :emergency_protocol
|
23
|
+
|
24
|
+
def initialize(name, config = {})
|
25
|
+
@dependent_circuits = Array(config.delete(:cascades_to))
|
26
|
+
@emergency_protocol = config.delete(:emergency_protocol)
|
27
|
+
|
28
|
+
super
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override the trip method to include cascading behavior
|
32
|
+
def trip!
|
33
|
+
result = super
|
34
|
+
perform_cascade if result && @dependent_circuits.any?
|
35
|
+
result
|
36
|
+
end
|
37
|
+
|
38
|
+
# Force cascade failure to all dependent circuits
|
39
|
+
def cascade_failure!
|
40
|
+
perform_cascade
|
41
|
+
end
|
42
|
+
|
43
|
+
# Get the current status of all dependent circuits
|
44
|
+
def dependent_status
|
45
|
+
return {} if @dependent_circuits.empty?
|
46
|
+
|
47
|
+
@dependent_circuits.each_with_object({}) do |circuit_name, status|
|
48
|
+
circuit = BreakerMachines.registry.find(circuit_name)
|
49
|
+
status[circuit_name] = circuit ? circuit.status_name : :not_found
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Check if any dependent circuits are open
|
54
|
+
def dependents_compromised?
|
55
|
+
@dependent_circuits.any? do |circuit_name|
|
56
|
+
circuit = BreakerMachines.registry.find(circuit_name)
|
57
|
+
circuit&.open?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Summary that includes cascade information
|
62
|
+
def summary
|
63
|
+
base_summary = super
|
64
|
+
return base_summary if @dependent_circuits.empty?
|
65
|
+
|
66
|
+
if @cascade_triggered_at&.value
|
67
|
+
compromised_count = dependent_status.values.count(:open)
|
68
|
+
" CASCADE TRIGGERED: #{compromised_count}/#{@dependent_circuits.length} dependent systems compromised."
|
69
|
+
else
|
70
|
+
" Monitoring #{@dependent_circuits.length} dependent systems."
|
71
|
+
end
|
72
|
+
|
73
|
+
base_summary + cascade_info_text
|
74
|
+
end
|
75
|
+
|
76
|
+
# Provide cascade info for introspection
|
77
|
+
def cascade_info
|
78
|
+
BreakerMachines::CascadeInfo.new(
|
79
|
+
dependent_circuits: @dependent_circuits,
|
80
|
+
emergency_protocol: @emergency_protocol,
|
81
|
+
cascade_triggered_at: @cascade_triggered_at&.value,
|
82
|
+
dependent_status: dependent_status
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def perform_cascade
|
89
|
+
return if @dependent_circuits.empty?
|
90
|
+
|
91
|
+
cascade_results = []
|
92
|
+
@cascade_triggered_at ||= Concurrent::AtomicReference.new
|
93
|
+
@cascade_triggered_at.value = BreakerMachines.monotonic_time
|
94
|
+
|
95
|
+
@dependent_circuits.each do |circuit_name|
|
96
|
+
# First try to find circuit in registry
|
97
|
+
circuit = BreakerMachines.registry.find(circuit_name)
|
98
|
+
|
99
|
+
# If not found and we have an owner, try to get it from the owner
|
100
|
+
if !circuit && @config[:owner]
|
101
|
+
owner = @config[:owner]
|
102
|
+
# Handle WeakRef if present
|
103
|
+
owner = owner.__getobj__ if owner.is_a?(WeakRef)
|
104
|
+
|
105
|
+
circuit = owner.circuit(circuit_name) if owner.respond_to?(:circuit)
|
106
|
+
end
|
107
|
+
|
108
|
+
next unless circuit
|
109
|
+
next unless circuit.closed? || circuit.half_open?
|
110
|
+
|
111
|
+
# Force the dependent circuit to open
|
112
|
+
circuit.force_open!
|
113
|
+
cascade_results << circuit_name
|
114
|
+
|
115
|
+
BreakerMachines.instrument('cascade_failure', {
|
116
|
+
source_circuit: @name,
|
117
|
+
target_circuit: circuit_name,
|
118
|
+
emergency_protocol: @emergency_protocol
|
119
|
+
})
|
120
|
+
end
|
121
|
+
|
122
|
+
# Trigger emergency protocol if configured
|
123
|
+
trigger_emergency_protocol(cascade_results) if @emergency_protocol && cascade_results.any?
|
124
|
+
|
125
|
+
# Invoke cascade callback if configured
|
126
|
+
if @config[:on_cascade]
|
127
|
+
begin
|
128
|
+
@config[:on_cascade].call(cascade_results) if @config[:on_cascade].respond_to?(:call)
|
129
|
+
rescue StandardError => e
|
130
|
+
# Log callback error but don't fail the cascade
|
131
|
+
BreakerMachines.logger&.error "Cascade callback error: #{e.message}"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
cascade_results
|
136
|
+
end
|
137
|
+
|
138
|
+
def trigger_emergency_protocol(affected_circuits)
|
139
|
+
BreakerMachines.instrument('emergency_protocol_triggered', {
|
140
|
+
protocol: @emergency_protocol,
|
141
|
+
source_circuit: @name,
|
142
|
+
affected_circuits: affected_circuits
|
143
|
+
})
|
144
|
+
|
145
|
+
# Allow custom emergency protocol handling
|
146
|
+
owner = @config[:owner]
|
147
|
+
if owner&.respond_to?(@emergency_protocol, true)
|
148
|
+
begin
|
149
|
+
owner.send(@emergency_protocol, affected_circuits)
|
150
|
+
rescue StandardError => e
|
151
|
+
BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
|
152
|
+
end
|
153
|
+
elsif respond_to?(@emergency_protocol, true)
|
154
|
+
begin
|
155
|
+
send(@emergency_protocol, affected_circuits)
|
156
|
+
rescue StandardError => e
|
157
|
+
BreakerMachines.logger&.error "Emergency protocol error: #{e.message}"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Override the on_circuit_open callback to include cascading
|
163
|
+
def on_circuit_open
|
164
|
+
super # Call the original implementation
|
165
|
+
perform_cascade if @dependent_circuits.any?
|
166
|
+
end
|
167
|
+
|
168
|
+
# Force open should also cascade
|
169
|
+
def force_open!
|
170
|
+
result = super
|
171
|
+
perform_cascade if result && @dependent_circuits.any?
|
172
|
+
result
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -89,7 +89,7 @@ module BreakerMachines
|
|
89
89
|
return execute_call_async(&block)
|
90
90
|
end
|
91
91
|
|
92
|
-
start_time = monotonic_time
|
92
|
+
start_time = BreakerMachines.monotonic_time
|
93
93
|
|
94
94
|
begin
|
95
95
|
# IMPORTANT: We do NOT implement forceful timeouts as they are inherently unsafe
|
@@ -113,11 +113,11 @@ module BreakerMachines
|
|
113
113
|
block.call
|
114
114
|
end
|
115
115
|
|
116
|
-
record_success(monotonic_time - start_time)
|
116
|
+
record_success(BreakerMachines.monotonic_time - start_time)
|
117
117
|
handle_success
|
118
118
|
result
|
119
119
|
rescue *@config[:exceptions] => e
|
120
|
-
record_failure(monotonic_time - start_time, e)
|
120
|
+
record_failure(BreakerMachines.monotonic_time - start_time, e)
|
121
121
|
handle_failure
|
122
122
|
raise unless @config[:fallback]
|
123
123
|
|
@@ -217,7 +217,7 @@ module BreakerMachines
|
|
217
217
|
end
|
218
218
|
|
219
219
|
def record_failure(duration, error = nil)
|
220
|
-
@last_failure_at.value = monotonic_time
|
220
|
+
@last_failure_at.value = BreakerMachines.monotonic_time
|
221
221
|
@last_error.value = error if error
|
222
222
|
@metrics&.record_failure(@name, duration)
|
223
223
|
@storage&.record_failure(@name, duration)
|
@@ -226,10 +226,6 @@ module BreakerMachines
|
|
226
226
|
@storage.record_event_with_details(@name, :failure, duration,
|
227
227
|
error: error)
|
228
228
|
end
|
229
|
-
|
230
|
-
def monotonic_time
|
231
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
232
|
-
end
|
233
229
|
end
|
234
230
|
end
|
235
231
|
end
|
@@ -12,7 +12,7 @@ module BreakerMachines
|
|
12
12
|
# - half_open? (returns true when status == :half_open)
|
13
13
|
|
14
14
|
def stats
|
15
|
-
|
15
|
+
BreakerMachines::Stats.new(
|
16
16
|
state: status_name,
|
17
17
|
failure_count: @storage.failure_count(@name, @config[:failure_window]),
|
18
18
|
success_count: @storage.success_count(@name, @config[:failure_window]),
|
@@ -20,7 +20,7 @@ module BreakerMachines
|
|
20
20
|
opened_at: @opened_at.value,
|
21
21
|
half_open_attempts: @half_open_attempts.value,
|
22
22
|
half_open_successes: @half_open_successes.value
|
23
|
-
|
23
|
+
)
|
24
24
|
end
|
25
25
|
|
26
26
|
def configuration
|
@@ -36,41 +36,56 @@ module BreakerMachines
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def to_h
|
39
|
-
{
|
39
|
+
hash = {
|
40
40
|
name: @name,
|
41
41
|
state: status_name,
|
42
|
-
stats: stats,
|
42
|
+
stats: stats.to_h,
|
43
43
|
config: configuration.except(:owner, :storage, :metrics),
|
44
44
|
event_log: event_log || [],
|
45
|
-
last_error: last_error_info
|
45
|
+
last_error: last_error_info&.to_h
|
46
46
|
}
|
47
|
+
|
48
|
+
# Add cascade-specific information if this is a cascading circuit
|
49
|
+
hash[:cascade_info] = cascade_info.to_h if is_a?(CascadingCircuit)
|
50
|
+
|
51
|
+
hash
|
47
52
|
end
|
48
53
|
|
49
54
|
def summary
|
50
|
-
case status_name
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
55
|
+
base_summary = case status_name
|
56
|
+
when :closed
|
57
|
+
"Circuit '#{@name}' is CLOSED. #{stats.failure_count} failures recorded."
|
58
|
+
when :open
|
59
|
+
# Calculate time remaining until reset
|
60
|
+
time_since_open = BreakerMachines.monotonic_time - @opened_at.value
|
61
|
+
time_until_reset = @config[:reset_timeout] - time_since_open
|
62
|
+
reset_time = time_until_reset.seconds.from_now
|
63
|
+
opened_time = time_since_open.seconds.ago
|
64
|
+
error_info = @last_error.value ? " The last error was #{@last_error.value.class}." : ''
|
65
|
+
"Circuit '#{@name}' is OPEN until #{reset_time}. " \
|
66
|
+
"It opened at #{opened_time} after #{@config[:failure_threshold]} failures.#{error_info}"
|
67
|
+
when :half_open
|
68
|
+
"Circuit '#{@name}' is HALF-OPEN. Testing with limited requests " \
|
69
|
+
"(#{@half_open_attempts.value}/#{@config[:half_open_calls]} attempts)."
|
70
|
+
end
|
71
|
+
|
72
|
+
# Add cascade information if this is a cascading circuit
|
73
|
+
if is_a?(CascadingCircuit) && @dependent_circuits.any?
|
74
|
+
base_summary += " [Cascades to: #{@dependent_circuits.join(', ')}]"
|
62
75
|
end
|
76
|
+
|
77
|
+
base_summary
|
63
78
|
end
|
64
79
|
|
65
80
|
def last_error_info
|
66
81
|
error = @last_error.value
|
67
82
|
return nil unless error
|
68
83
|
|
69
|
-
|
70
|
-
|
84
|
+
BreakerMachines::ErrorInfo.new(
|
85
|
+
error_class: error.class.name,
|
71
86
|
message: error.message,
|
72
87
|
occurred_at: @last_failure_at.value
|
73
|
-
|
88
|
+
)
|
74
89
|
end
|
75
90
|
end
|
76
91
|
end
|
@@ -19,6 +19,7 @@ module BreakerMachines
|
|
19
19
|
|
20
20
|
event :reset do
|
21
21
|
transition %i[open half_open] => :closed
|
22
|
+
transition closed: :closed
|
22
23
|
end
|
23
24
|
|
24
25
|
event :force_open do
|
@@ -46,7 +47,7 @@ module BreakerMachines
|
|
46
47
|
private
|
47
48
|
|
48
49
|
def on_circuit_open
|
49
|
-
@opened_at.value = monotonic_time
|
50
|
+
@opened_at.value = BreakerMachines.monotonic_time
|
50
51
|
@storage&.set_status(@name, :open, @opened_at.value)
|
51
52
|
if @storage.respond_to?(:record_event_with_details)
|
52
53
|
@storage.record_event_with_details(@name, :state_change, 0,
|
@@ -85,8 +86,8 @@ module BreakerMachines
|
|
85
86
|
stored_status = @storage.get_status(@name)
|
86
87
|
return unless stored_status
|
87
88
|
|
88
|
-
self.status = stored_status
|
89
|
-
@opened_at.value = stored_status
|
89
|
+
self.status = stored_status.status.to_s
|
90
|
+
@opened_at.value = stored_status.opened_at if stored_status.opened_at
|
90
91
|
end
|
91
92
|
|
92
93
|
def reset_timeout_elapsed?
|
@@ -98,7 +99,7 @@ module BreakerMachines
|
|
98
99
|
jitter_multiplier = 1.0 + (((rand * 2) - 1) * jitter_factor)
|
99
100
|
timeout_with_jitter = @config[:reset_timeout] * jitter_multiplier
|
100
101
|
|
101
|
-
monotonic_time - @opened_at.value >= timeout_with_jitter
|
102
|
+
BreakerMachines.monotonic_time - @opened_at.value >= timeout_with_jitter
|
102
103
|
end
|
103
104
|
end
|
104
105
|
end
|
@@ -114,9 +114,9 @@ module BreakerMachines
|
|
114
114
|
|
115
115
|
printf "%-20s %-12s %-10d %-10d %s\n",
|
116
116
|
circuit.name,
|
117
|
-
colorize_state(stats
|
118
|
-
stats
|
119
|
-
stats
|
117
|
+
colorize_state(stats.state),
|
118
|
+
stats.failure_count,
|
119
|
+
stats.success_count,
|
120
120
|
error_info
|
121
121
|
end
|
122
122
|
|
@@ -167,22 +167,22 @@ module BreakerMachines
|
|
167
167
|
stats = circuit.stats
|
168
168
|
config = circuit.configuration
|
169
169
|
|
170
|
-
puts "Current State: #{colorize_state(stats
|
171
|
-
puts "Failure Count: #{stats
|
172
|
-
puts "Success Count: #{stats
|
170
|
+
puts "Current State: #{colorize_state(stats.state)}"
|
171
|
+
puts "Failure Count: #{stats.failure_count} / #{config[:failure_threshold]}"
|
172
|
+
puts "Success Count: #{stats.success_count}"
|
173
173
|
|
174
|
-
if stats
|
175
|
-
puts "Opened At: #{Time.at(stats
|
176
|
-
reset_time = Time.at(stats
|
174
|
+
if stats.opened_at
|
175
|
+
puts "Opened At: #{Time.at(stats.opened_at)}"
|
176
|
+
reset_time = Time.at(stats.opened_at + config[:reset_timeout])
|
177
177
|
puts "Reset At: #{reset_time} (in #{(reset_time - Time.now).to_i}s)"
|
178
178
|
end
|
179
179
|
|
180
180
|
if circuit.last_error
|
181
181
|
error_info = circuit.last_error_info
|
182
182
|
puts "\nLast Error:"
|
183
|
-
puts " Class: #{error_info
|
184
|
-
puts " Message: #{error_info
|
185
|
-
puts " Time: #{Time.at(error_info
|
183
|
+
puts " Class: #{error_info.error_class}"
|
184
|
+
puts " Message: #{error_info.message}"
|
185
|
+
puts " Time: #{Time.at(error_info.occurred_at)}"
|
186
186
|
end
|
187
187
|
|
188
188
|
puts "\nConfiguration:"
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
module DSL
|
5
|
+
# Builder for cascading circuit breaker configuration
|
6
|
+
class CascadingCircuitBuilder < CircuitBuilder
|
7
|
+
def cascades_to(*circuit_names)
|
8
|
+
@config[:cascades_to] = circuit_names.flatten
|
9
|
+
end
|
10
|
+
|
11
|
+
def emergency_protocol(protocol_name)
|
12
|
+
@config[:emergency_protocol] = protocol_name
|
13
|
+
end
|
14
|
+
|
15
|
+
def on_cascade(&block)
|
16
|
+
@config[:on_cascade] = block
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|