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
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
module DSL
|
5
|
+
# DSL builder for configuring circuit breakers with a fluent interface
|
6
|
+
class CircuitBuilder
|
7
|
+
attr_reader :config
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@config = {
|
11
|
+
failure_threshold: 5,
|
12
|
+
failure_window: 60.seconds,
|
13
|
+
success_threshold: 1,
|
14
|
+
timeout: nil,
|
15
|
+
reset_timeout: 60.seconds,
|
16
|
+
half_open_calls: 1,
|
17
|
+
exceptions: [StandardError],
|
18
|
+
storage: nil,
|
19
|
+
metrics: nil,
|
20
|
+
fallback: nil,
|
21
|
+
on_open: nil,
|
22
|
+
on_close: nil,
|
23
|
+
on_half_open: nil,
|
24
|
+
on_reject: nil,
|
25
|
+
notifications: [],
|
26
|
+
fiber_safe: BreakerMachines.config.fiber_safe
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def threshold(failures: nil, failure_rate: nil, minimum_calls: nil, within: 60.seconds, successes: nil)
|
31
|
+
if failure_rate
|
32
|
+
# Rate-based threshold
|
33
|
+
validate_failure_rate!(failure_rate)
|
34
|
+
validate_positive_integer!(:minimum_calls, minimum_calls) if minimum_calls
|
35
|
+
|
36
|
+
@config[:failure_rate] = failure_rate
|
37
|
+
@config[:minimum_calls] = minimum_calls || 5
|
38
|
+
@config[:use_rate_threshold] = true
|
39
|
+
elsif failures
|
40
|
+
# Absolute count threshold (existing behavior)
|
41
|
+
validate_positive_integer!(:failures, failures)
|
42
|
+
@config[:failure_threshold] = failures
|
43
|
+
@config[:use_rate_threshold] = false
|
44
|
+
end
|
45
|
+
|
46
|
+
validate_positive_integer!(:within, within.to_i)
|
47
|
+
@config[:failure_window] = within.to_i
|
48
|
+
|
49
|
+
return unless successes
|
50
|
+
|
51
|
+
validate_positive_integer!(:successes, successes)
|
52
|
+
@config[:success_threshold] = successes
|
53
|
+
end
|
54
|
+
|
55
|
+
def reset_after(duration, jitter: nil)
|
56
|
+
validate_positive_integer!(:duration, duration.to_i)
|
57
|
+
@config[:reset_timeout] = duration.to_i
|
58
|
+
|
59
|
+
return unless jitter
|
60
|
+
|
61
|
+
validate_jitter!(jitter)
|
62
|
+
@config[:reset_timeout_jitter] = jitter
|
63
|
+
end
|
64
|
+
|
65
|
+
def timeout(duration)
|
66
|
+
validate_non_negative_integer!(:timeout, duration.to_i)
|
67
|
+
@config[:timeout] = duration.to_i
|
68
|
+
end
|
69
|
+
|
70
|
+
def half_open_requests(count)
|
71
|
+
validate_positive_integer!(:half_open_requests, count)
|
72
|
+
@config[:half_open_calls] = count
|
73
|
+
end
|
74
|
+
|
75
|
+
def storage(backend, **options)
|
76
|
+
@config[:storage] = case backend
|
77
|
+
when :memory
|
78
|
+
Storage::Memory.new(**options)
|
79
|
+
when :bucket_memory
|
80
|
+
Storage::BucketMemory.new(**options)
|
81
|
+
when :cache
|
82
|
+
Storage::Cache.new(**options)
|
83
|
+
when :null
|
84
|
+
Storage::Null.new(**options)
|
85
|
+
when :fallback_chain
|
86
|
+
config = options.is_a?(Proc) ? options.call(timeout: 5) : options
|
87
|
+
Storage::FallbackChain.new(config)
|
88
|
+
when Class
|
89
|
+
backend.new(**options)
|
90
|
+
else
|
91
|
+
backend
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def metrics(recorder = nil, &block)
|
96
|
+
@config[:metrics] = recorder || block
|
97
|
+
end
|
98
|
+
|
99
|
+
def fallback(value = nil, &block)
|
100
|
+
raise ArgumentError, 'Fallback requires either a value or a block' if value.nil? && !block_given?
|
101
|
+
|
102
|
+
fallback_value = block || value
|
103
|
+
|
104
|
+
if @config[:fallback].is_a?(Array)
|
105
|
+
@config[:fallback] << fallback_value
|
106
|
+
elsif @config[:fallback]
|
107
|
+
@config[:fallback] = [@config[:fallback], fallback_value]
|
108
|
+
else
|
109
|
+
@config[:fallback] = fallback_value
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def on_open(&block)
|
114
|
+
@config[:on_open] = block
|
115
|
+
end
|
116
|
+
|
117
|
+
def on_close(&block)
|
118
|
+
@config[:on_close] = block
|
119
|
+
end
|
120
|
+
|
121
|
+
def on_half_open(&block)
|
122
|
+
@config[:on_half_open] = block
|
123
|
+
end
|
124
|
+
|
125
|
+
def on_reject(&block)
|
126
|
+
@config[:on_reject] = block
|
127
|
+
end
|
128
|
+
|
129
|
+
# Configure hedged requests
|
130
|
+
def hedged(&)
|
131
|
+
if block_given?
|
132
|
+
hedged_builder = DSL::HedgedBuilder.new(@config)
|
133
|
+
hedged_builder.instance_eval(&)
|
134
|
+
else
|
135
|
+
@config[:hedged_requests] = true
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Configure multiple backends
|
140
|
+
def backends(*backend_list)
|
141
|
+
@config[:backends] = backend_list.flatten
|
142
|
+
end
|
143
|
+
|
144
|
+
# Configure parallel fallback execution
|
145
|
+
def parallel_fallback(fallback_list)
|
146
|
+
@config[:fallback] = DSL::ParallelFallbackWrapper.new(fallback_list)
|
147
|
+
end
|
148
|
+
|
149
|
+
def notify(service, url = nil, events: %i[open close], **options)
|
150
|
+
notification = {
|
151
|
+
via: service,
|
152
|
+
url: url,
|
153
|
+
events: Array(events),
|
154
|
+
options: options
|
155
|
+
}
|
156
|
+
@config[:notifications] << notification
|
157
|
+
end
|
158
|
+
|
159
|
+
def handle(*exceptions)
|
160
|
+
@config[:exceptions] = exceptions
|
161
|
+
end
|
162
|
+
|
163
|
+
def fiber_safe(enabled = true) # rubocop:disable Style/OptionalBooleanParameter
|
164
|
+
@config[:fiber_safe] = enabled
|
165
|
+
end
|
166
|
+
|
167
|
+
def max_concurrent(limit)
|
168
|
+
validate_positive_integer!(:max_concurrent, limit)
|
169
|
+
@config[:max_concurrent] = limit
|
170
|
+
end
|
171
|
+
|
172
|
+
# Advanced features
|
173
|
+
def parallel_calls(count, timeout: nil)
|
174
|
+
@config[:parallel_calls] = count
|
175
|
+
@config[:parallel_timeout] = timeout
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def validate_positive_integer!(name, value)
|
181
|
+
return if value.is_a?(Integer) && value.positive?
|
182
|
+
|
183
|
+
raise BreakerMachines::ConfigurationError,
|
184
|
+
"#{name} must be a positive integer, got: #{value.inspect}"
|
185
|
+
end
|
186
|
+
|
187
|
+
def validate_non_negative_integer!(name, value)
|
188
|
+
return if value.is_a?(Integer) && value >= 0
|
189
|
+
|
190
|
+
raise BreakerMachines::ConfigurationError,
|
191
|
+
"#{name} must be a non-negative integer, got: #{value.inspect}"
|
192
|
+
end
|
193
|
+
|
194
|
+
def validate_failure_rate!(rate)
|
195
|
+
return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
|
196
|
+
|
197
|
+
raise BreakerMachines::ConfigurationError,
|
198
|
+
"failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
|
199
|
+
end
|
200
|
+
|
201
|
+
def validate_jitter!(jitter)
|
202
|
+
return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
|
203
|
+
|
204
|
+
raise BreakerMachines::ConfigurationError,
|
205
|
+
"jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
module DSL
|
5
|
+
# Builder for hedged request configuration
|
6
|
+
class HedgedBuilder
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
@config[:hedged_requests] = true
|
10
|
+
end
|
11
|
+
|
12
|
+
def delay(milliseconds)
|
13
|
+
@config[:hedging_delay] = milliseconds
|
14
|
+
end
|
15
|
+
|
16
|
+
def max_requests(count)
|
17
|
+
@config[:max_hedged_requests] = count
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module BreakerMachines
|
4
|
+
module DSL
|
5
|
+
# Wrapper to indicate parallel execution for fallbacks
|
6
|
+
class ParallelFallbackWrapper
|
7
|
+
attr_reader :fallbacks
|
8
|
+
|
9
|
+
def initialize(fallbacks)
|
10
|
+
@fallbacks = fallbacks
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(error)
|
14
|
+
# This will be handled by the circuit's fallback mechanism
|
15
|
+
# to execute fallbacks in parallel
|
16
|
+
raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/breaker_machines/dsl.rb
CHANGED
@@ -43,7 +43,7 @@ module BreakerMachines
|
|
43
43
|
@circuits ||= {}
|
44
44
|
|
45
45
|
if block_given?
|
46
|
-
builder = CircuitBuilder.new
|
46
|
+
builder = DSL::CircuitBuilder.new
|
47
47
|
builder.instance_eval(&block)
|
48
48
|
@circuits[name] = builder.config
|
49
49
|
end
|
@@ -51,6 +51,19 @@ module BreakerMachines
|
|
51
51
|
@circuits[name]
|
52
52
|
end
|
53
53
|
|
54
|
+
# Define a cascading circuit breaker that can trip dependent circuits
|
55
|
+
def cascade_circuit(name, &block)
|
56
|
+
@circuits ||= {}
|
57
|
+
|
58
|
+
if block_given?
|
59
|
+
builder = DSL::CascadingCircuitBuilder.new
|
60
|
+
builder.instance_eval(&block)
|
61
|
+
@circuits[name] = builder.config.merge(circuit_type: :cascading)
|
62
|
+
end
|
63
|
+
|
64
|
+
@circuits[name]
|
65
|
+
end
|
66
|
+
|
54
67
|
def circuits
|
55
68
|
# Start with parent circuits if available
|
56
69
|
base_circuits = if superclass.respond_to?(:circuits)
|
@@ -72,7 +85,7 @@ module BreakerMachines
|
|
72
85
|
@circuit_templates ||= {}
|
73
86
|
|
74
87
|
if block_given?
|
75
|
-
builder = CircuitBuilder.new
|
88
|
+
builder = DSL::CircuitBuilder.new
|
76
89
|
builder.instance_eval(&block)
|
77
90
|
@circuit_templates[name] = builder.config
|
78
91
|
end
|
@@ -109,7 +122,7 @@ module BreakerMachines
|
|
109
122
|
instance_registry.each do |weak_ref|
|
110
123
|
instance = weak_ref.__getobj__
|
111
124
|
circuit_instances = instance.instance_variable_get(:@circuit_instances)
|
112
|
-
circuit_instances&.each_value(&:
|
125
|
+
circuit_instances&.each_value(&:hard_reset!)
|
113
126
|
rescue WeakRef::RefError
|
114
127
|
# Instance was garbage collected, skip it
|
115
128
|
end
|
@@ -156,7 +169,16 @@ module BreakerMachines
|
|
156
169
|
def circuit(name)
|
157
170
|
self.class.circuits[name] ||= {}
|
158
171
|
@circuit_instances ||= {}
|
159
|
-
|
172
|
+
|
173
|
+
config = self.class.circuits[name].merge(owner: self)
|
174
|
+
circuit_type = config.delete(:circuit_type)
|
175
|
+
|
176
|
+
@circuit_instances[name] ||= case circuit_type
|
177
|
+
when :cascading
|
178
|
+
CascadingCircuit.new(name, config)
|
179
|
+
else
|
180
|
+
Circuit.new(name, config)
|
181
|
+
end
|
160
182
|
end
|
161
183
|
|
162
184
|
# Create a dynamic circuit breaker with inline configuration
|
@@ -173,7 +195,7 @@ module BreakerMachines
|
|
173
195
|
|
174
196
|
# Apply additional configuration if block provided
|
175
197
|
if config_block
|
176
|
-
builder = CircuitBuilder.new
|
198
|
+
builder = DSL::CircuitBuilder.new
|
177
199
|
builder.instance_variable_set(:@config, base_config.deep_dup)
|
178
200
|
builder.instance_eval(&config_block)
|
179
201
|
base_config = builder.config
|
@@ -240,7 +262,7 @@ module BreakerMachines
|
|
240
262
|
|
241
263
|
# Reset all circuits for this instance
|
242
264
|
def reset_all_circuits
|
243
|
-
circuit_instances.each_value(&:
|
265
|
+
circuit_instances.each_value(&:hard_reset!)
|
244
266
|
end
|
245
267
|
|
246
268
|
# Remove a global dynamic circuit by name
|
@@ -257,240 +279,5 @@ module BreakerMachines
|
|
257
279
|
def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
|
258
280
|
BreakerMachines.registry.cleanup_stale_dynamic_circuits(max_age_seconds)
|
259
281
|
end
|
260
|
-
|
261
|
-
# DSL builder for configuring circuit breakers with a fluent interface
|
262
|
-
class CircuitBuilder
|
263
|
-
attr_reader :config
|
264
|
-
|
265
|
-
def initialize
|
266
|
-
@config = {
|
267
|
-
failure_threshold: 5,
|
268
|
-
failure_window: 60.seconds,
|
269
|
-
success_threshold: 1,
|
270
|
-
timeout: nil,
|
271
|
-
reset_timeout: 60.seconds,
|
272
|
-
half_open_calls: 1,
|
273
|
-
exceptions: [StandardError],
|
274
|
-
storage: nil,
|
275
|
-
metrics: nil,
|
276
|
-
fallback: nil,
|
277
|
-
on_open: nil,
|
278
|
-
on_close: nil,
|
279
|
-
on_half_open: nil,
|
280
|
-
on_reject: nil,
|
281
|
-
notifications: [],
|
282
|
-
fiber_safe: BreakerMachines.config.fiber_safe
|
283
|
-
}
|
284
|
-
end
|
285
|
-
|
286
|
-
def threshold(failures: nil, failure_rate: nil, minimum_calls: nil, within: 60.seconds, successes: nil)
|
287
|
-
if failure_rate
|
288
|
-
# Rate-based threshold
|
289
|
-
validate_failure_rate!(failure_rate)
|
290
|
-
validate_positive_integer!(:minimum_calls, minimum_calls) if minimum_calls
|
291
|
-
|
292
|
-
@config[:failure_rate] = failure_rate
|
293
|
-
@config[:minimum_calls] = minimum_calls || 5
|
294
|
-
@config[:use_rate_threshold] = true
|
295
|
-
elsif failures
|
296
|
-
# Absolute count threshold (existing behavior)
|
297
|
-
validate_positive_integer!(:failures, failures)
|
298
|
-
@config[:failure_threshold] = failures
|
299
|
-
@config[:use_rate_threshold] = false
|
300
|
-
end
|
301
|
-
|
302
|
-
validate_positive_integer!(:within, within.to_i)
|
303
|
-
@config[:failure_window] = within.to_i
|
304
|
-
|
305
|
-
return unless successes
|
306
|
-
|
307
|
-
validate_positive_integer!(:successes, successes)
|
308
|
-
@config[:success_threshold] = successes
|
309
|
-
end
|
310
|
-
|
311
|
-
def reset_after(duration, jitter: nil)
|
312
|
-
validate_positive_integer!(:duration, duration.to_i)
|
313
|
-
@config[:reset_timeout] = duration.to_i
|
314
|
-
|
315
|
-
return unless jitter
|
316
|
-
|
317
|
-
validate_jitter!(jitter)
|
318
|
-
@config[:reset_timeout_jitter] = jitter
|
319
|
-
end
|
320
|
-
|
321
|
-
def timeout(duration)
|
322
|
-
validate_non_negative_integer!(:timeout, duration.to_i)
|
323
|
-
@config[:timeout] = duration.to_i
|
324
|
-
end
|
325
|
-
|
326
|
-
def half_open_requests(count)
|
327
|
-
validate_positive_integer!(:half_open_requests, count)
|
328
|
-
@config[:half_open_calls] = count
|
329
|
-
end
|
330
|
-
|
331
|
-
def storage(backend, **options)
|
332
|
-
@config[:storage] = case backend
|
333
|
-
when :memory
|
334
|
-
Storage::Memory.new(**options)
|
335
|
-
when :bucket_memory
|
336
|
-
Storage::BucketMemory.new(**options)
|
337
|
-
when :cache
|
338
|
-
Storage::Cache.new(**options)
|
339
|
-
when :null
|
340
|
-
Storage::Null.new(**options)
|
341
|
-
when :fallback_chain
|
342
|
-
config = options.is_a?(Proc) ? options.call(timeout: 5) : options
|
343
|
-
Storage::FallbackChain.new(config)
|
344
|
-
when Class
|
345
|
-
backend.new(**options)
|
346
|
-
else
|
347
|
-
backend
|
348
|
-
end
|
349
|
-
end
|
350
|
-
|
351
|
-
def metrics(recorder = nil, &block)
|
352
|
-
@config[:metrics] = recorder || block
|
353
|
-
end
|
354
|
-
|
355
|
-
def fallback(value = nil, &block)
|
356
|
-
raise ArgumentError, 'Fallback requires either a value or a block' if value.nil? && !block_given?
|
357
|
-
|
358
|
-
fallback_value = block || value
|
359
|
-
|
360
|
-
if @config[:fallback].is_a?(Array)
|
361
|
-
@config[:fallback] << fallback_value
|
362
|
-
elsif @config[:fallback]
|
363
|
-
@config[:fallback] = [@config[:fallback], fallback_value]
|
364
|
-
else
|
365
|
-
@config[:fallback] = fallback_value
|
366
|
-
end
|
367
|
-
end
|
368
|
-
|
369
|
-
def on_open(&block)
|
370
|
-
@config[:on_open] = block
|
371
|
-
end
|
372
|
-
|
373
|
-
def on_close(&block)
|
374
|
-
@config[:on_close] = block
|
375
|
-
end
|
376
|
-
|
377
|
-
def on_half_open(&block)
|
378
|
-
@config[:on_half_open] = block
|
379
|
-
end
|
380
|
-
|
381
|
-
def on_reject(&block)
|
382
|
-
@config[:on_reject] = block
|
383
|
-
end
|
384
|
-
|
385
|
-
# Configure hedged requests
|
386
|
-
def hedged(&)
|
387
|
-
if block_given?
|
388
|
-
hedged_builder = HedgedBuilder.new(@config)
|
389
|
-
hedged_builder.instance_eval(&)
|
390
|
-
else
|
391
|
-
@config[:hedged_requests] = true
|
392
|
-
end
|
393
|
-
end
|
394
|
-
|
395
|
-
# Configure multiple backends
|
396
|
-
def backends(*backend_list)
|
397
|
-
@config[:backends] = backend_list.flatten
|
398
|
-
end
|
399
|
-
|
400
|
-
# Configure parallel fallback execution
|
401
|
-
def parallel_fallback(fallback_list)
|
402
|
-
@config[:fallback] = ParallelFallbackWrapper.new(fallback_list)
|
403
|
-
end
|
404
|
-
|
405
|
-
def notify(service, url = nil, events: %i[open close], **options)
|
406
|
-
notification = {
|
407
|
-
via: service,
|
408
|
-
url: url,
|
409
|
-
events: Array(events),
|
410
|
-
options: options
|
411
|
-
}
|
412
|
-
@config[:notifications] << notification
|
413
|
-
end
|
414
|
-
|
415
|
-
def handle(*exceptions)
|
416
|
-
@config[:exceptions] = exceptions
|
417
|
-
end
|
418
|
-
|
419
|
-
def fiber_safe(enabled = true) # rubocop:disable Style/OptionalBooleanParameter
|
420
|
-
@config[:fiber_safe] = enabled
|
421
|
-
end
|
422
|
-
|
423
|
-
def max_concurrent(limit)
|
424
|
-
validate_positive_integer!(:max_concurrent, limit)
|
425
|
-
@config[:max_concurrent] = limit
|
426
|
-
end
|
427
|
-
|
428
|
-
# Advanced features
|
429
|
-
def parallel_calls(count, timeout: nil)
|
430
|
-
@config[:parallel_calls] = count
|
431
|
-
@config[:parallel_timeout] = timeout
|
432
|
-
end
|
433
|
-
|
434
|
-
private
|
435
|
-
|
436
|
-
def validate_positive_integer!(name, value)
|
437
|
-
return if value.is_a?(Integer) && value.positive?
|
438
|
-
|
439
|
-
raise BreakerMachines::ConfigurationError,
|
440
|
-
"#{name} must be a positive integer, got: #{value.inspect}"
|
441
|
-
end
|
442
|
-
|
443
|
-
def validate_non_negative_integer!(name, value)
|
444
|
-
return if value.is_a?(Integer) && value >= 0
|
445
|
-
|
446
|
-
raise BreakerMachines::ConfigurationError,
|
447
|
-
"#{name} must be a non-negative integer, got: #{value.inspect}"
|
448
|
-
end
|
449
|
-
|
450
|
-
def validate_failure_rate!(rate)
|
451
|
-
return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
|
452
|
-
|
453
|
-
raise BreakerMachines::ConfigurationError,
|
454
|
-
"failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
|
455
|
-
end
|
456
|
-
|
457
|
-
def validate_jitter!(jitter)
|
458
|
-
return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
|
459
|
-
|
460
|
-
raise BreakerMachines::ConfigurationError,
|
461
|
-
"jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
|
462
|
-
end
|
463
|
-
end
|
464
|
-
|
465
|
-
# Builder for hedged request configuration
|
466
|
-
class HedgedBuilder
|
467
|
-
def initialize(config)
|
468
|
-
@config = config
|
469
|
-
@config[:hedged_requests] = true
|
470
|
-
end
|
471
|
-
|
472
|
-
def delay(milliseconds)
|
473
|
-
@config[:hedging_delay] = milliseconds
|
474
|
-
end
|
475
|
-
|
476
|
-
def max_requests(count)
|
477
|
-
@config[:max_hedged_requests] = count
|
478
|
-
end
|
479
|
-
end
|
480
|
-
|
481
|
-
# Wrapper to indicate parallel execution for fallbacks
|
482
|
-
class ParallelFallbackWrapper
|
483
|
-
attr_reader :fallbacks
|
484
|
-
|
485
|
-
def initialize(fallbacks)
|
486
|
-
@fallbacks = fallbacks
|
487
|
-
end
|
488
|
-
|
489
|
-
def call(error)
|
490
|
-
# This will be handled by the circuit's fallback mechanism
|
491
|
-
# to execute fallbacks in parallel
|
492
|
-
raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
|
493
|
-
end
|
494
|
-
end
|
495
282
|
end
|
496
283
|
end
|
@@ -14,6 +14,16 @@ module BreakerMachines
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
+
# Raised when a circuit cannot be called due to unmet dependencies
|
18
|
+
class CircuitDependencyError < CircuitOpenError
|
19
|
+
def initialize(circuit_name, message = nil)
|
20
|
+
@circuit_name = circuit_name
|
21
|
+
@opened_at = nil
|
22
|
+
super_message = message || "Circuit '#{circuit_name}' cannot be called: dependencies not met"
|
23
|
+
Error.instance_method(:initialize).bind(self).call(super_message)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
17
27
|
# Raised when a circuit-protected call exceeds the configured timeout
|
18
28
|
class CircuitTimeoutError < Error
|
19
29
|
attr_reader :circuit_name, :timeout
|
@@ -48,4 +58,14 @@ module BreakerMachines
|
|
48
58
|
super("Circuit '#{circuit_name}' rejected call: max concurrent limit of #{max_concurrent} reached")
|
49
59
|
end
|
50
60
|
end
|
61
|
+
|
62
|
+
# Raised when all parallel fallbacks fail
|
63
|
+
class ParallelFallbackError < Error
|
64
|
+
attr_reader :errors
|
65
|
+
|
66
|
+
def initialize(message, errors)
|
67
|
+
@errors = errors
|
68
|
+
super(message)
|
69
|
+
end
|
70
|
+
end
|
51
71
|
end
|
@@ -2,10 +2,12 @@
|
|
2
2
|
|
3
3
|
# This file contains async support for hedged execution
|
4
4
|
# It is only loaded when fiber_safe mode is enabled
|
5
|
+
# Requires async gem ~> 2.31.0 for Promise and modern API features
|
5
6
|
|
6
7
|
require 'async'
|
7
8
|
require 'async/task'
|
8
|
-
require 'async/
|
9
|
+
require 'async/promise'
|
10
|
+
require 'async/barrier'
|
9
11
|
require 'concurrent'
|
10
12
|
|
11
13
|
module BreakerMachines
|
@@ -43,53 +45,44 @@ module BreakerMachines
|
|
43
45
|
private
|
44
46
|
|
45
47
|
# Race callables; return first result or raise if it was an Exception
|
46
|
-
# Uses Async::
|
48
|
+
# Uses modern Async::Promise and Async::Barrier for cleaner synchronization
|
47
49
|
# @param callables [Array<Proc>] Tasks to race
|
48
50
|
# @param delay_ms [Integer] Delay in milliseconds between task starts
|
49
51
|
# @return [Object] First successful result
|
50
52
|
# @raise [Exception] The first exception received
|
51
53
|
def race_tasks(callables, delay_ms: 0)
|
52
|
-
|
53
|
-
|
54
|
-
condition = Async::Condition.new
|
55
|
-
winner = nil
|
56
|
-
exception = nil
|
54
|
+
promise = Async::Promise.new
|
55
|
+
barrier = Async::Barrier.new
|
57
56
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
57
|
+
begin
|
58
|
+
result = Async do
|
59
|
+
callables.each_with_index do |callable, idx|
|
60
|
+
barrier.async do
|
61
|
+
# stagger hedged attempts
|
62
|
+
sleep(delay_ms / 1000.0) if idx.positive? && delay_ms.positive?
|
62
63
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
rescue StandardError => e
|
72
|
-
mutex.synchronize do
|
73
|
-
next if winner || exception
|
74
|
-
|
75
|
-
exception = e
|
76
|
-
condition.signal
|
64
|
+
begin
|
65
|
+
result = callable.call
|
66
|
+
# Try to resolve the promise with this result
|
67
|
+
# Only the first resolution will succeed
|
68
|
+
promise.resolve(result) unless promise.resolved?
|
69
|
+
rescue StandardError => e
|
70
|
+
# Only set exception if no result has been resolved yet
|
71
|
+
promise.resolve(e) unless promise.resolved?
|
77
72
|
end
|
78
73
|
end
|
79
74
|
end
|
80
|
-
end
|
81
75
|
|
82
|
-
|
83
|
-
|
76
|
+
# Wait for the first resolution (either success or exception)
|
77
|
+
promise.wait
|
78
|
+
end.wait
|
84
79
|
|
85
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
#
|
89
|
-
|
90
|
-
|
91
|
-
winner
|
92
|
-
end.wait
|
80
|
+
# If result is an exception, raise it; otherwise return the result
|
81
|
+
result.is_a?(StandardError) ? raise(result) : result
|
82
|
+
ensure
|
83
|
+
# Ensure all tasks are stopped
|
84
|
+
barrier&.stop
|
85
|
+
end
|
93
86
|
end
|
94
87
|
end
|
95
88
|
end
|