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.
- checksums.yaml +4 -4
- data/README.md +25 -3
- data/lib/breaker_machines/async_circuit.rb +47 -0
- data/lib/breaker_machines/async_support.rb +7 -6
- data/lib/breaker_machines/cascading_circuit.rb +177 -0
- data/lib/breaker_machines/circuit/async_state_management.rb +71 -0
- data/lib/breaker_machines/circuit/base.rb +58 -0
- data/lib/breaker_machines/circuit/callbacks.rb +7 -12
- data/lib/breaker_machines/circuit/configuration.rb +6 -26
- data/lib/breaker_machines/circuit/coordinated_state_management.rb +117 -0
- data/lib/breaker_machines/circuit/execution.rb +4 -8
- data/lib/breaker_machines/circuit/hedged_execution.rb +115 -0
- data/lib/breaker_machines/circuit/introspection.rb +36 -20
- data/lib/breaker_machines/circuit/state_callbacks.rb +60 -0
- data/lib/breaker_machines/circuit/state_management.rb +15 -61
- data/lib/breaker_machines/circuit.rb +1 -8
- data/lib/breaker_machines/circuit_group.rb +153 -0
- data/lib/breaker_machines/console.rb +12 -12
- data/lib/breaker_machines/coordinated_circuit.rb +10 -0
- 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 +28 -241
- data/lib/breaker_machines/errors.rb +20 -0
- data/lib/breaker_machines/hedged_async_support.rb +29 -36
- data/lib/breaker_machines/registry.rb +3 -3
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +3 -3
- data/lib/breaker_machines/storage/cache.rb +3 -3
- data/lib/breaker_machines/storage/fallback_chain.rb +56 -70
- data/lib/breaker_machines/storage/memory.rb +3 -3
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +29 -0
- metadata +21 -7
- 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
|
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
|
111
|
-
total_successes: circuits.sum { |c| c.stats
|
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
|
-
|
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
|
-
|
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, :
|
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
|
-
@
|
19
|
-
@
|
20
|
-
@
|
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
|
-
|
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 =
|
84
|
+
chain_started_at = BreakerMachines.monotonic_time
|
83
85
|
attempted_backends = []
|
84
86
|
|
85
87
|
storage_configs.each_with_index do |config, index|
|
86
|
-
|
88
|
+
backend_type = config[:backend]
|
89
|
+
attempted_backends << backend_type
|
90
|
+
backend_state = backend_states[backend_type]
|
87
91
|
|
88
|
-
if
|
89
|
-
emit_backend_skipped_notification(
|
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(
|
95
|
-
started_at =
|
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
|
-
|
106
|
-
|
107
|
-
|
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
|
-
|
111
|
-
|
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 = ((
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
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
|
-
|
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
|
162
|
-
|
163
|
-
return
|
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
|
-
|
187
|
-
|
164
|
+
previous_health = backend_state.health_name
|
165
|
+
backend_state.record_failure
|
166
|
+
new_health = backend_state.health_name
|
188
167
|
|
189
|
-
|
190
|
-
|
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
|
-
|
198
|
-
|
199
|
-
unhealthy_until.delete(backend_type)
|
178
|
+
backend_state = backend_states[backend_type]
|
179
|
+
return unless backend_state&.unhealthy?
|
200
180
|
|
201
|
-
|
202
|
-
|
203
|
-
|
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
|
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:
|
248
|
-
recovery_time: new_state == :unhealthy ? unhealthy_until
|
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
|
-
|
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
|
data/lib/breaker_machines.rb
CHANGED
@@ -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.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
8
|
-
bindir:
|
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: '
|
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: '
|
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.
|
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.
|
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
|