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
@@ -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
@@ -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(&:reset)
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
- @circuit_instances[name] ||= Circuit.new(name, self.class.circuits[name].merge(owner: self))
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(&:reset)
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/condition'
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::Condition to signal the winner instead of an Async::Channel.
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
- Async do |parent|
53
- mutex = Mutex.new
54
- condition = Async::Condition.new
55
- winner = nil
56
- exception = nil
54
+ promise = Async::Promise.new
55
+ barrier = Async::Barrier.new
57
56
 
58
- tasks = callables.map.with_index do |callable, idx|
59
- parent.async do |task|
60
- # stagger hedged attempts
61
- task.sleep(delay_ms / 1000.0) if idx.positive? && delay_ms.positive?
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
- begin
64
- res = callable.call
65
- mutex.synchronize do
66
- next if winner || exception
67
-
68
- winner = res
69
- condition.signal
70
- end
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
- # block until first signal
83
- condition.wait
76
+ # Wait for the first resolution (either success or exception)
77
+ promise.wait
78
+ end.wait
84
79
 
85
- # tear down
86
- tasks.each(&:stop)
87
-
88
- # propagate
89
- raise(exception) if exception
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