breaker_machines 0.1.0 → 0.2.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.
@@ -67,6 +67,36 @@ module BreakerMachines
67
67
  end
68
68
  end
69
69
 
70
+ # Define reusable circuit templates
71
+ def circuit_template(name, &block)
72
+ @circuit_templates ||= {}
73
+
74
+ if block_given?
75
+ builder = CircuitBuilder.new
76
+ builder.instance_eval(&block)
77
+ @circuit_templates[name] = builder.config
78
+ end
79
+
80
+ @circuit_templates[name]
81
+ end
82
+
83
+ # Get all circuit templates
84
+ def circuit_templates
85
+ # Start with parent templates if available
86
+ base_templates = if superclass.respond_to?(:circuit_templates)
87
+ superclass.circuit_templates.deep_dup
88
+ else
89
+ {}
90
+ end
91
+
92
+ # Merge with our own templates
93
+ if @circuit_templates
94
+ base_templates.merge(@circuit_templates)
95
+ else
96
+ base_templates
97
+ end
98
+ end
99
+
70
100
  # Get circuit definitions without sensitive data
71
101
  def circuit_definitions
72
102
  circuits.transform_values { |config| config.except(:owner, :storage, :metrics) }
@@ -129,6 +159,70 @@ module BreakerMachines
129
159
  @circuit_instances[name] ||= Circuit.new(name, self.class.circuits[name].merge(owner: self))
130
160
  end
131
161
 
162
+ # Create a dynamic circuit breaker with inline configuration
163
+ # Options:
164
+ # global: true - Store circuit globally, preventing memory leaks in long-lived objects
165
+ # global: false - Store circuit locally in this instance (default, backward compatible)
166
+ def dynamic_circuit(name, template: nil, global: false, &config_block)
167
+ # Start with template config if provided
168
+ base_config = if template && self.class.circuit_templates[template]
169
+ self.class.circuit_templates[template].deep_dup
170
+ else
171
+ default_circuit_config
172
+ end
173
+
174
+ # Apply additional configuration if block provided
175
+ if config_block
176
+ builder = CircuitBuilder.new
177
+ builder.instance_variable_set(:@config, base_config.deep_dup)
178
+ builder.instance_eval(&config_block)
179
+ base_config = builder.config
180
+ end
181
+
182
+ if global
183
+ # Use global registry to prevent memory leaks
184
+ BreakerMachines.registry.get_or_create_dynamic_circuit(name, self, base_config)
185
+ else
186
+ # Local storage (backward compatible)
187
+ @circuit_instances ||= {}
188
+ @circuit_instances[name] ||= Circuit.new(name, base_config.merge(owner: self))
189
+ end
190
+ end
191
+
192
+ # Apply a template to an existing or new circuit
193
+ def apply_template(circuit_name, template_name)
194
+ template_config = self.class.circuit_templates[template_name]
195
+ raise ArgumentError, "Template '#{template_name}' not found" unless template_config
196
+
197
+ @circuit_instances ||= {}
198
+ @circuit_instances[circuit_name] = Circuit.new(circuit_name, template_config.merge(owner: self))
199
+ end
200
+
201
+ private
202
+
203
+ def default_circuit_config
204
+ {
205
+ failure_threshold: 5,
206
+ failure_window: 60,
207
+ success_threshold: 1,
208
+ timeout: nil,
209
+ reset_timeout: 60,
210
+ half_open_calls: 1,
211
+ exceptions: [StandardError],
212
+ storage: nil,
213
+ metrics: nil,
214
+ fallback: nil,
215
+ on_open: nil,
216
+ on_close: nil,
217
+ on_half_open: nil,
218
+ on_reject: nil,
219
+ notifications: [],
220
+ fiber_safe: BreakerMachines.config.fiber_safe
221
+ }
222
+ end
223
+
224
+ public
225
+
132
226
  # Get all circuit instances for this object
133
227
  def circuit_instances
134
228
  @circuit_instances || {}
@@ -149,6 +243,21 @@ module BreakerMachines
149
243
  circuit_instances.each_value(&:reset)
150
244
  end
151
245
 
246
+ # Remove a global dynamic circuit by name
247
+ def remove_dynamic_circuit(name)
248
+ BreakerMachines.registry.remove_dynamic_circuit(name)
249
+ end
250
+
251
+ # Get all dynamic circuit names from global registry
252
+ def dynamic_circuit_names
253
+ BreakerMachines.registry.dynamic_circuit_names
254
+ end
255
+
256
+ # Cleanup stale dynamic circuits (global)
257
+ def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
258
+ BreakerMachines.registry.cleanup_stale_dynamic_circuits(max_age_seconds)
259
+ end
260
+
152
261
  # DSL builder for configuring circuit breakers with a fluent interface
153
262
  class CircuitBuilder
154
263
  attr_reader :config
@@ -156,10 +265,10 @@ module BreakerMachines
156
265
  def initialize
157
266
  @config = {
158
267
  failure_threshold: 5,
159
- failure_window: 60,
268
+ failure_window: 60.seconds,
160
269
  success_threshold: 1,
161
270
  timeout: nil,
162
- reset_timeout: 60,
271
+ reset_timeout: 60.seconds,
163
272
  half_open_calls: 1,
164
273
  exceptions: [StandardError],
165
274
  storage: nil,
@@ -174,22 +283,48 @@ module BreakerMachines
174
283
  }
175
284
  end
176
285
 
177
- def threshold(failures: nil, within: 60, successes: nil)
178
- @config[:failure_threshold] = failures if failures
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)
179
303
  @config[:failure_window] = within.to_i
180
- @config[:success_threshold] = successes if successes
304
+
305
+ return unless successes
306
+
307
+ validate_positive_integer!(:successes, successes)
308
+ @config[:success_threshold] = successes
181
309
  end
182
310
 
183
311
  def reset_after(duration, jitter: nil)
312
+ validate_positive_integer!(:duration, duration.to_i)
184
313
  @config[:reset_timeout] = duration.to_i
185
- @config[:reset_timeout_jitter] = jitter if jitter
314
+
315
+ return unless jitter
316
+
317
+ validate_jitter!(jitter)
318
+ @config[:reset_timeout_jitter] = jitter
186
319
  end
187
320
 
188
321
  def timeout(duration)
322
+ validate_non_negative_integer!(:timeout, duration.to_i)
189
323
  @config[:timeout] = duration.to_i
190
324
  end
191
325
 
192
326
  def half_open_requests(count)
327
+ validate_positive_integer!(:half_open_requests, count)
193
328
  @config[:half_open_calls] = count
194
329
  end
195
330
 
@@ -199,6 +334,8 @@ module BreakerMachines
199
334
  Storage::Memory.new(**)
200
335
  when :bucket_memory
201
336
  Storage::BucketMemory.new(**)
337
+ when :cache
338
+ Storage::Cache.new(**)
202
339
  when :redis
203
340
  Storage::Redis.new(**)
204
341
  when Class
@@ -242,6 +379,26 @@ module BreakerMachines
242
379
  @config[:on_reject] = block
243
380
  end
244
381
 
382
+ # Configure hedged requests
383
+ def hedged(&)
384
+ if block_given?
385
+ hedged_builder = HedgedBuilder.new(@config)
386
+ hedged_builder.instance_eval(&)
387
+ else
388
+ @config[:hedged_requests] = true
389
+ end
390
+ end
391
+
392
+ # Configure multiple backends
393
+ def backends(*backend_list)
394
+ @config[:backends] = backend_list.flatten
395
+ end
396
+
397
+ # Configure parallel fallback execution
398
+ def parallel_fallback(fallback_list)
399
+ @config[:fallback] = ParallelFallbackWrapper.new(fallback_list)
400
+ end
401
+
245
402
  def notify(service, url = nil, events: %i[open close], **options)
246
403
  notification = {
247
404
  via: service,
@@ -256,19 +413,81 @@ module BreakerMachines
256
413
  @config[:exceptions] = exceptions
257
414
  end
258
415
 
259
- def fiber_safe(enabled = true)
416
+ def fiber_safe(enabled: true)
260
417
  @config[:fiber_safe] = enabled
261
418
  end
262
419
 
263
- # Advanced features
264
- def backends(list)
265
- @config[:backends] = list
420
+ def max_concurrent(limit)
421
+ validate_positive_integer!(:max_concurrent, limit)
422
+ @config[:max_concurrent] = limit
266
423
  end
267
424
 
425
+ # Advanced features
268
426
  def parallel_calls(count, timeout: nil)
269
427
  @config[:parallel_calls] = count
270
428
  @config[:parallel_timeout] = timeout
271
429
  end
430
+
431
+ private
432
+
433
+ def validate_positive_integer!(name, value)
434
+ return if value.is_a?(Integer) && value.positive?
435
+
436
+ raise BreakerMachines::ConfigurationError,
437
+ "#{name} must be a positive integer, got: #{value.inspect}"
438
+ end
439
+
440
+ def validate_non_negative_integer!(name, value)
441
+ return if value.is_a?(Integer) && value >= 0
442
+
443
+ raise BreakerMachines::ConfigurationError,
444
+ "#{name} must be a non-negative integer, got: #{value.inspect}"
445
+ end
446
+
447
+ def validate_failure_rate!(rate)
448
+ return if rate.is_a?(Numeric) && rate >= 0.0 && rate <= 1.0
449
+
450
+ raise BreakerMachines::ConfigurationError,
451
+ "failure_rate must be between 0.0 and 1.0, got: #{rate.inspect}"
452
+ end
453
+
454
+ def validate_jitter!(jitter)
455
+ return if jitter.is_a?(Numeric) && jitter >= 0.0 && jitter <= 1.0
456
+
457
+ raise BreakerMachines::ConfigurationError,
458
+ "jitter must be between 0.0 and 1.0 (0% to 100%), got: #{jitter.inspect}"
459
+ end
460
+ end
461
+
462
+ # Builder for hedged request configuration
463
+ class HedgedBuilder
464
+ def initialize(config)
465
+ @config = config
466
+ @config[:hedged_requests] = true
467
+ end
468
+
469
+ def delay(milliseconds)
470
+ @config[:hedging_delay] = milliseconds
471
+ end
472
+
473
+ def max_requests(count)
474
+ @config[:max_hedged_requests] = count
475
+ end
476
+ end
477
+
478
+ # Wrapper to indicate parallel execution for fallbacks
479
+ class ParallelFallbackWrapper
480
+ attr_reader :fallbacks
481
+
482
+ def initialize(fallbacks)
483
+ @fallbacks = fallbacks
484
+ end
485
+
486
+ def call(error)
487
+ # This will be handled by the circuit's fallback mechanism
488
+ # to execute fallbacks in parallel
489
+ raise NotImplementedError, 'ParallelFallbackWrapper should be handled by Circuit'
490
+ end
272
491
  end
273
492
  end
274
493
  end
@@ -27,4 +27,15 @@ module BreakerMachines
27
27
 
28
28
  class ConfigurationError < Error; end
29
29
  class StorageError < Error; end
30
+
31
+ # Raised when circuit rejects call due to bulkhead limit
32
+ class CircuitBulkheadError < Error
33
+ attr_reader :circuit_name, :max_concurrent
34
+
35
+ def initialize(circuit_name, max_concurrent)
36
+ @circuit_name = circuit_name
37
+ @max_concurrent = max_concurrent
38
+ super("Circuit '#{circuit_name}' rejected call: max concurrent limit of #{max_concurrent} reached")
39
+ end
40
+ end
30
41
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file contains async support for hedged execution
4
+ # It is only loaded when fiber_safe mode is enabled
5
+
6
+ require 'async'
7
+ require 'async/task'
8
+ require 'async/condition'
9
+ require 'concurrent'
10
+
11
+ module BreakerMachines
12
+ # AsyncSupport for HedgedExecution
13
+ module HedgedAsyncSupport
14
+ # Execute hedged requests with configurable delay between attempts
15
+ # @param callables [Array<Proc>] Array of callables to execute
16
+ # @param delay_ms [Integer] Milliseconds to wait before starting hedged requests
17
+ # @return [Object] Result from the first successful callable
18
+ # @raise [StandardError] If all callables fail
19
+ def execute_hedged_with_async(callables, delay_ms)
20
+ race_tasks(callables, delay_ms: delay_ms)
21
+ end
22
+
23
+ # Execute parallel fallbacks without delay
24
+ # @param fallbacks [Array<Proc,Object>] Array of fallback values or callables
25
+ # @return [Object] Result from the first successful fallback
26
+ # @raise [StandardError] If all fallbacks fail
27
+ def execute_parallel_fallbacks_async(fallbacks)
28
+ # Normalize fallbacks to callables
29
+ callables = fallbacks.map do |fallback|
30
+ case fallback
31
+ when Proc
32
+ # Handle procs with different arities
33
+ -> { fallback.arity == 1 ? fallback.call(nil) : fallback.call }
34
+ else
35
+ # Wrap static values in callables
36
+ -> { fallback }
37
+ end
38
+ end
39
+
40
+ race_tasks(callables, delay_ms: 0)
41
+ end
42
+
43
+ private
44
+
45
+ # 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.
47
+ # @param callables [Array<Proc>] Tasks to race
48
+ # @param delay_ms [Integer] Delay in milliseconds between task starts
49
+ # @return [Object] First successful result
50
+ # @raise [Exception] The first exception received
51
+ 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
57
+
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?
62
+
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
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # block until first signal
83
+ condition.wait
84
+
85
+ # tear down
86
+ tasks.each(&:stop)
87
+
88
+ # propagate
89
+ raise(exception) if exception
90
+
91
+ winner
92
+ end.wait
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,113 @@
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
@@ -10,6 +10,7 @@ module BreakerMachines
10
10
 
11
11
  def initialize
12
12
  @circuits = Concurrent::Map.new
13
+ @named_circuits = Concurrent::Map.new # For dynamic circuits by name
13
14
  @mutex = Mutex.new
14
15
  @registration_count = 0
15
16
  @cleanup_interval = 100 # Clean up every N registrations
@@ -53,6 +54,38 @@ module BreakerMachines
53
54
  all_circuits.select { |circuit| circuit.name == name }
54
55
  end
55
56
 
57
+ # Find first circuit by name
58
+ def find(name)
59
+ find_by_name(name).first
60
+ end
61
+
62
+ # Force open a circuit by name
63
+ def force_open(name) # rubocop:disable Naming/PredicateMethod
64
+ circuits = find_by_name(name)
65
+ return false if circuits.empty?
66
+
67
+ circuits.each(&:force_open)
68
+ true
69
+ end
70
+
71
+ # Force close a circuit by name
72
+ def force_close(name) # rubocop:disable Naming/PredicateMethod
73
+ circuits = find_by_name(name)
74
+ return false if circuits.empty?
75
+
76
+ circuits.each(&:force_close)
77
+ true
78
+ end
79
+
80
+ # Reset a circuit by name
81
+ def reset(name) # rubocop:disable Naming/PredicateMethod
82
+ circuits = find_by_name(name)
83
+ return false if circuits.empty?
84
+
85
+ circuits.each(&:reset)
86
+ true
87
+ end
88
+
56
89
  # Get summary statistics
57
90
  def stats_summary
58
91
  circuits = all_circuits
@@ -63,15 +96,126 @@ module BreakerMachines
63
96
  }
64
97
  end
65
98
 
99
+ # Get all stats with detailed metrics
100
+ def all_stats
101
+ circuits = all_circuits
102
+
103
+ {
104
+ summary: stats_summary,
105
+ circuits: circuits.map(&:stats),
106
+ health: {
107
+ open_count: circuits.count(&:open?),
108
+ closed_count: circuits.count(&:closed?),
109
+ half_open_count: circuits.count(&:half_open?),
110
+ total_failures: circuits.sum { |c| c.stats[:failure_count] },
111
+ total_successes: circuits.sum { |c| c.stats[:success_count] }
112
+ }
113
+ }
114
+ end
115
+
66
116
  # Get detailed information for all circuits
67
117
  def detailed_report
68
118
  all_circuits.map(&:to_h)
69
119
  end
70
120
 
121
+ # Get or create a globally managed dynamic circuit
122
+ def get_or_create_dynamic_circuit(name, owner, config)
123
+ @mutex.synchronize do
124
+ # Check if circuit already exists and is still alive
125
+ if @named_circuits.key?(name)
126
+ weak_ref = @named_circuits[name]
127
+ begin
128
+ existing_circuit = weak_ref.__getobj__
129
+ return existing_circuit if existing_circuit
130
+ rescue WeakRef::RefError
131
+ # Circuit was garbage collected, remove the stale reference
132
+ @named_circuits.delete(name)
133
+ end
134
+ end
135
+
136
+ # Create new circuit with weak owner reference
137
+ # Don't auto-register to avoid deadlock
138
+ weak_owner = owner.is_a?(WeakRef) ? owner : WeakRef.new(owner)
139
+ circuit_config = config.merge(owner: weak_owner, auto_register: false)
140
+ new_circuit = Circuit.new(name, circuit_config)
141
+
142
+ # Manually register the circuit (we're already in sync block)
143
+ @circuits[new_circuit] = WeakRef.new(new_circuit)
144
+ @named_circuits[name] = WeakRef.new(new_circuit)
145
+
146
+ new_circuit
147
+ end
148
+ end
149
+
150
+ # Remove a dynamic circuit by name
151
+ def remove_dynamic_circuit(name)
152
+ @mutex.synchronize do
153
+ if @named_circuits.key?(name)
154
+ weak_ref = @named_circuits.delete(name)
155
+ begin
156
+ circuit = weak_ref.__getobj__
157
+ @circuits.delete(circuit) if circuit
158
+ true
159
+ rescue WeakRef::RefError
160
+ false
161
+ end
162
+ else
163
+ false
164
+ end
165
+ end
166
+ end
167
+
168
+ # Get all dynamic circuit names
169
+ def dynamic_circuit_names
170
+ @mutex.synchronize do
171
+ alive_names = []
172
+ @named_circuits.each_pair do |name, weak_ref|
173
+ weak_ref.__getobj__
174
+ alive_names << name
175
+ rescue WeakRef::RefError
176
+ @named_circuits.delete(name)
177
+ end
178
+ alive_names
179
+ end
180
+ end
181
+
182
+ # Cleanup stale dynamic circuits older than given age
183
+ def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
184
+ @mutex.synchronize do
185
+ cutoff_time = Time.now - max_age_seconds
186
+ stale_names = []
187
+
188
+ @named_circuits.each_pair do |name, weak_ref|
189
+ circuit = weak_ref.__getobj__
190
+ # Check if circuit has a last_activity_time and it's stale
191
+ if circuit.respond_to?(:last_activity_time) &&
192
+ circuit.last_activity_time &&
193
+ circuit.last_activity_time < cutoff_time
194
+ stale_names << name
195
+ end
196
+ rescue WeakRef::RefError
197
+ stale_names << name
198
+ end
199
+
200
+ stale_names.each do |name|
201
+ weak_ref = @named_circuits.delete(name)
202
+ begin
203
+ circuit = weak_ref.__getobj__
204
+ @circuits.delete(circuit) if circuit
205
+ rescue WeakRef::RefError
206
+ # Already gone
207
+ end
208
+ end
209
+
210
+ stale_names.size
211
+ end
212
+ end
213
+
71
214
  # Clear all circuits (useful for testing)
72
215
  def clear
73
216
  @mutex.synchronize do
74
217
  @circuits.clear
218
+ @named_circuits.clear
75
219
  end
76
220
  end
77
221