breaker_machines 0.9.2-x86_64-darwin

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +184 -0
  4. data/ext/breaker_machines_native/extconf.rb +3 -0
  5. data/lib/breaker_machines/async_circuit.rb +47 -0
  6. data/lib/breaker_machines/async_support.rb +104 -0
  7. data/lib/breaker_machines/cascading_circuit.rb +177 -0
  8. data/lib/breaker_machines/circuit/async_state_management.rb +71 -0
  9. data/lib/breaker_machines/circuit/base.rb +59 -0
  10. data/lib/breaker_machines/circuit/callbacks.rb +135 -0
  11. data/lib/breaker_machines/circuit/configuration.rb +67 -0
  12. data/lib/breaker_machines/circuit/coordinated_state_management.rb +117 -0
  13. data/lib/breaker_machines/circuit/execution.rb +231 -0
  14. data/lib/breaker_machines/circuit/hedged_execution.rb +115 -0
  15. data/lib/breaker_machines/circuit/introspection.rb +93 -0
  16. data/lib/breaker_machines/circuit/native.rb +127 -0
  17. data/lib/breaker_machines/circuit/state_callbacks.rb +72 -0
  18. data/lib/breaker_machines/circuit/state_management.rb +59 -0
  19. data/lib/breaker_machines/circuit.rb +8 -0
  20. data/lib/breaker_machines/circuit_group.rb +153 -0
  21. data/lib/breaker_machines/console.rb +345 -0
  22. data/lib/breaker_machines/coordinated_circuit.rb +10 -0
  23. data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
  24. data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
  25. data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
  26. data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
  27. data/lib/breaker_machines/dsl.rb +283 -0
  28. data/lib/breaker_machines/errors.rb +71 -0
  29. data/lib/breaker_machines/hedged_async_support.rb +88 -0
  30. data/lib/breaker_machines/native_extension.rb +81 -0
  31. data/lib/breaker_machines/native_speedup.rb +10 -0
  32. data/lib/breaker_machines/registry.rb +243 -0
  33. data/lib/breaker_machines/storage/backend_state.rb +69 -0
  34. data/lib/breaker_machines/storage/base.rb +52 -0
  35. data/lib/breaker_machines/storage/bucket_memory.rb +176 -0
  36. data/lib/breaker_machines/storage/cache.rb +169 -0
  37. data/lib/breaker_machines/storage/fallback_chain.rb +294 -0
  38. data/lib/breaker_machines/storage/memory.rb +140 -0
  39. data/lib/breaker_machines/storage/native.rb +93 -0
  40. data/lib/breaker_machines/storage/null.rb +54 -0
  41. data/lib/breaker_machines/storage.rb +8 -0
  42. data/lib/breaker_machines/types.rb +41 -0
  43. data/lib/breaker_machines/version.rb +5 -0
  44. data/lib/breaker_machines.rb +200 -0
  45. data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -0
  46. data/sig/README.md +74 -0
  47. data/sig/all.rbs +25 -0
  48. data/sig/breaker_machines/circuit.rbs +154 -0
  49. data/sig/breaker_machines/console.rbs +32 -0
  50. data/sig/breaker_machines/dsl.rbs +50 -0
  51. data/sig/breaker_machines/errors.rbs +24 -0
  52. data/sig/breaker_machines/interfaces.rbs +46 -0
  53. data/sig/breaker_machines/registry.rbs +30 -0
  54. data/sig/breaker_machines/storage.rbs +65 -0
  55. data/sig/breaker_machines/types.rbs +97 -0
  56. data/sig/breaker_machines.rbs +42 -0
  57. data/sig/manifest.yaml +5 -0
  58. metadata +227 -0
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ # DSL module for adding circuit breakers to classes
5
+ #
6
+ # This module uses WeakRef to track instances that include the DSL.
7
+ # Why? In long-running applications (web servers, background workers),
8
+ # objects that include this DSL may be created and destroyed frequently.
9
+ # Without WeakRef, the registry would hold strong references to these
10
+ # objects, preventing garbage collection and causing memory leaks.
11
+ #
12
+ # Example scenario: A Rails controller that includes BreakerMachines::DSL
13
+ # is instantiated for each request. Without WeakRef, every controller
14
+ # instance would be kept in memory forever.
15
+ module DSL
16
+ extend ActiveSupport::Concern
17
+
18
+ # Track instances for each class that includes DSL
19
+ # Using WeakRef to allow garbage collection
20
+ @instance_registries = Concurrent::Map.new
21
+
22
+ class_methods do
23
+ # Get or create instance registry for this class
24
+ def instance_registry
25
+ # Access the module-level registry
26
+ registry = BreakerMachines::DSL.instance_variable_get(:@instance_registries)
27
+ registry.compute_if_absent(self) { Concurrent::Array.new }
28
+ end
29
+
30
+ # Clean up dead references in the registry
31
+ def cleanup_instance_registry
32
+ registry = instance_registry
33
+ # Concurrent::Array supports delete_if
34
+ registry.delete_if do |weak_ref|
35
+ weak_ref.__getobj__
36
+ false
37
+ rescue WeakRef::RefError
38
+ true
39
+ end
40
+ end
41
+
42
+ def circuit(name, &block)
43
+ @circuits ||= {}
44
+
45
+ if block_given?
46
+ builder = DSL::CircuitBuilder.new
47
+ builder.instance_eval(&block)
48
+ @circuits[name] = builder.config
49
+ end
50
+
51
+ @circuits[name]
52
+ end
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
+
67
+ def circuits
68
+ # Start with parent circuits if available
69
+ base_circuits = if superclass.respond_to?(:circuits)
70
+ superclass.circuits.deep_dup
71
+ else
72
+ {}
73
+ end
74
+
75
+ # Merge with our own circuits
76
+ if @circuits
77
+ base_circuits.merge(@circuits)
78
+ else
79
+ base_circuits
80
+ end
81
+ end
82
+
83
+ # Define reusable circuit templates
84
+ def circuit_template(name, &block)
85
+ @circuit_templates ||= {}
86
+
87
+ if block_given?
88
+ builder = DSL::CircuitBuilder.new
89
+ builder.instance_eval(&block)
90
+ @circuit_templates[name] = builder.config
91
+ end
92
+
93
+ @circuit_templates[name]
94
+ end
95
+
96
+ # Get all circuit templates
97
+ def circuit_templates
98
+ # Start with parent templates if available
99
+ base_templates = if superclass.respond_to?(:circuit_templates)
100
+ superclass.circuit_templates.deep_dup
101
+ else
102
+ {}
103
+ end
104
+
105
+ # Merge with our own templates
106
+ if @circuit_templates
107
+ base_templates.merge(@circuit_templates)
108
+ else
109
+ base_templates
110
+ end
111
+ end
112
+
113
+ # Get circuit definitions without sensitive data
114
+ def circuit_definitions
115
+ circuits.transform_values { |config| config.except(:owner, :storage, :metrics) }
116
+ end
117
+
118
+ # Reset all circuits for all instances of this class
119
+ def reset_all_circuits
120
+ cleanup_instance_registry # Clean up dead refs first
121
+
122
+ instance_registry.each do |weak_ref|
123
+ instance = weak_ref.__getobj__
124
+ circuit_instances = instance.instance_variable_get(:@circuit_instances)
125
+ circuit_instances&.each_value(&:hard_reset!)
126
+ rescue WeakRef::RefError
127
+ # Instance was garbage collected, skip it
128
+ end
129
+ end
130
+
131
+ # Get aggregated stats for all circuits of this class
132
+ def circuit_stats
133
+ stats = Hash.new { |h, k| h[k] = { total: 0, by_state: {} } }
134
+ cleanup_instance_registry # Clean up dead refs first
135
+
136
+ instance_registry.each do |weak_ref|
137
+ instance = weak_ref.__getobj__
138
+ circuit_instances = instance.instance_variable_get(:@circuit_instances)
139
+ next unless circuit_instances
140
+
141
+ circuit_instances.each do |name, circuit|
142
+ stats[name][:total] += 1
143
+ state = circuit.status_name
144
+ stats[name][:by_state][state] ||= 0
145
+ stats[name][:by_state][state] += 1
146
+ end
147
+ rescue WeakRef::RefError
148
+ # Instance was garbage collected, skip it
149
+ end
150
+
151
+ stats
152
+ end
153
+ end
154
+
155
+ # Use included callback to add instance tracking
156
+ def self.included(base)
157
+ super
158
+
159
+ # Hook into new to register instances
160
+ base.singleton_class.prepend(Module.new do
161
+ def new(...)
162
+ instance = super
163
+ instance_registry << WeakRef.new(instance)
164
+ instance
165
+ end
166
+ end)
167
+ end
168
+
169
+ def circuit(name)
170
+ self.class.circuits[name] ||= {}
171
+ @circuit_instances ||= {}
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
182
+ end
183
+
184
+ # Create a dynamic circuit breaker with inline configuration
185
+ # Options:
186
+ # global: true - Store circuit globally, preventing memory leaks in long-lived objects
187
+ # global: false - Store circuit locally in this instance (default, backward compatible)
188
+ def dynamic_circuit(name, template: nil, global: false, &config_block)
189
+ # Start with template config if provided
190
+ base_config = if template && self.class.circuit_templates[template]
191
+ self.class.circuit_templates[template].deep_dup
192
+ else
193
+ default_circuit_config
194
+ end
195
+
196
+ # Apply additional configuration if block provided
197
+ if config_block
198
+ builder = DSL::CircuitBuilder.new
199
+ builder.instance_variable_set(:@config, base_config.deep_dup)
200
+ builder.instance_eval(&config_block)
201
+ base_config = builder.config
202
+ end
203
+
204
+ if global
205
+ # Use global registry to prevent memory leaks
206
+ BreakerMachines.registry.get_or_create_dynamic_circuit(name, self, base_config)
207
+ else
208
+ # Local storage (backward compatible)
209
+ @circuit_instances ||= {}
210
+ @circuit_instances[name] ||= Circuit.new(name, base_config.merge(owner: self))
211
+ end
212
+ end
213
+
214
+ # Apply a template to an existing or new circuit
215
+ def apply_template(circuit_name, template_name)
216
+ template_config = self.class.circuit_templates[template_name]
217
+ raise ArgumentError, "Template '#{template_name}' not found" unless template_config
218
+
219
+ @circuit_instances ||= {}
220
+ @circuit_instances[circuit_name] = Circuit.new(circuit_name, template_config.merge(owner: self))
221
+ end
222
+
223
+ private
224
+
225
+ def default_circuit_config
226
+ {
227
+ failure_threshold: 5,
228
+ failure_window: 60,
229
+ success_threshold: 1,
230
+ timeout: nil,
231
+ reset_timeout: 60,
232
+ half_open_calls: 1,
233
+ exceptions: [StandardError],
234
+ storage: nil,
235
+ metrics: nil,
236
+ fallback: nil,
237
+ on_open: nil,
238
+ on_close: nil,
239
+ on_half_open: nil,
240
+ on_reject: nil,
241
+ notifications: [],
242
+ fiber_safe: BreakerMachines.config.fiber_safe
243
+ }
244
+ end
245
+
246
+ public
247
+
248
+ # Get all circuit instances for this object
249
+ def circuit_instances
250
+ @circuit_instances || {}
251
+ end
252
+
253
+ # Get summary of all circuits for this instance
254
+ def circuits_summary
255
+ circuit_instances.transform_values(&:summary)
256
+ end
257
+
258
+ # Get detailed information for all circuits
259
+ def circuits_report
260
+ circuit_instances.transform_values(&:to_h)
261
+ end
262
+
263
+ # Reset all circuits for this instance
264
+ def reset_all_circuits
265
+ circuit_instances.each_value(&:hard_reset!)
266
+ end
267
+
268
+ # Remove a global dynamic circuit by name
269
+ def remove_dynamic_circuit(name)
270
+ BreakerMachines.registry.remove_dynamic_circuit(name)
271
+ end
272
+
273
+ # Get all dynamic circuit names from global registry
274
+ def dynamic_circuit_names
275
+ BreakerMachines.registry.dynamic_circuit_names
276
+ end
277
+
278
+ # Cleanup stale dynamic circuits (global)
279
+ def cleanup_stale_dynamic_circuits(max_age_seconds = 3600)
280
+ BreakerMachines.registry.cleanup_stale_dynamic_circuits(max_age_seconds)
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Error < StandardError; end
5
+
6
+ # Raised when attempting to use a circuit that is in the open state
7
+ class CircuitOpenError < Error
8
+ attr_reader :circuit_name, :opened_at
9
+
10
+ def initialize(circuit_name, opened_at = nil)
11
+ @circuit_name = circuit_name
12
+ @opened_at = opened_at
13
+ super("Circuit '#{circuit_name}' is open")
14
+ end
15
+ end
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
+
27
+ # Raised when a circuit-protected call exceeds the configured timeout
28
+ class CircuitTimeoutError < Error
29
+ attr_reader :circuit_name, :timeout
30
+
31
+ def initialize(circuit_name, timeout)
32
+ @circuit_name = circuit_name
33
+ @timeout = timeout
34
+ super("Circuit '#{circuit_name}' timed out after #{timeout}s")
35
+ end
36
+ end
37
+
38
+ class ConfigurationError < Error; end
39
+ class StorageError < Error; end
40
+
41
+ # Raised when storage backend operation times out
42
+ class StorageTimeoutError < StorageError
43
+ attr_reader :timeout_ms
44
+
45
+ def initialize(message, timeout_ms = nil)
46
+ @timeout_ms = timeout_ms
47
+ super(message)
48
+ end
49
+ end
50
+
51
+ # Raised when circuit rejects call due to bulkhead limit
52
+ class CircuitBulkheadError < Error
53
+ attr_reader :circuit_name, :max_concurrent
54
+
55
+ def initialize(circuit_name, max_concurrent)
56
+ @circuit_name = circuit_name
57
+ @max_concurrent = max_concurrent
58
+ super("Circuit '#{circuit_name}' rejected call: max concurrent limit of #{max_concurrent} reached")
59
+ end
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
71
+ end
@@ -0,0 +1,88 @@
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
+ # Requires async gem ~> 2.31.0 for Promise and modern API features
6
+
7
+ require 'async'
8
+ require 'async/task'
9
+ require 'async/promise'
10
+ require 'async/barrier'
11
+ require 'concurrent'
12
+
13
+ module BreakerMachines
14
+ # AsyncSupport for HedgedExecution
15
+ module HedgedAsyncSupport
16
+ # Execute hedged requests with configurable delay between attempts
17
+ # @param callables [Array<Proc>] Array of callables to execute
18
+ # @param delay_ms [Integer] Milliseconds to wait before starting hedged requests
19
+ # @return [Object] Result from the first successful callable
20
+ # @raise [StandardError] If all callables fail
21
+ def execute_hedged_with_async(callables, delay_ms)
22
+ race_tasks(callables, delay_ms: delay_ms)
23
+ end
24
+
25
+ # Execute parallel fallbacks without delay
26
+ # @param fallbacks [Array<Proc,Object>] Array of fallback values or callables
27
+ # @return [Object] Result from the first successful fallback
28
+ # @raise [StandardError] If all fallbacks fail
29
+ def execute_parallel_fallbacks_async(fallbacks)
30
+ # Normalize fallbacks to callables
31
+ callables = fallbacks.map do |fallback|
32
+ case fallback
33
+ when Proc
34
+ # Handle procs with different arities
35
+ -> { fallback.arity == 1 ? fallback.call(nil) : fallback.call }
36
+ else
37
+ # Wrap static values in callables
38
+ -> { fallback }
39
+ end
40
+ end
41
+
42
+ race_tasks(callables, delay_ms: 0)
43
+ end
44
+
45
+ private
46
+
47
+ # Race callables; return first result or raise if it was an Exception
48
+ # Uses modern Async::Promise and Async::Barrier for cleaner synchronization
49
+ # @param callables [Array<Proc>] Tasks to race
50
+ # @param delay_ms [Integer] Delay in milliseconds between task starts
51
+ # @return [Object] First successful result
52
+ # @raise [Exception] The first exception received
53
+ def race_tasks(callables, delay_ms: 0)
54
+ promise = Async::Promise.new
55
+ barrier = Async::Barrier.new
56
+
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?
63
+
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?
72
+ end
73
+ end
74
+ end
75
+
76
+ # Wait for the first resolution (either success or exception)
77
+ promise.wait
78
+ end.wait
79
+
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
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'active_support/core_ext/object/blank'
5
+
6
+ module BreakerMachines
7
+ # Handles loading and status of the optional native extension
8
+ module NativeExtension
9
+ class << self
10
+ # Load the native extension and set availability flag
11
+ # Can be called multiple times - subsequent calls are memoized
12
+ def load!
13
+ return @loaded if defined?(@loaded)
14
+
15
+ # Native extension is opt-in: only load if explicitly enabled
16
+ unless ENV['BREAKER_MACHINES_NATIVE'] == '1'
17
+ @loaded = false
18
+ BreakerMachines.instance_variable_set(:@native_available, false)
19
+ return false
20
+ end
21
+
22
+ errors = []
23
+
24
+ native_library_candidates.each do |require_path|
25
+ try_require(require_path)
26
+ @loaded = true
27
+ BreakerMachines.instance_variable_set(:@native_available, true)
28
+ BreakerMachines.log(:info, "Native extension loaded successfully (#{require_path})")
29
+ return true
30
+ rescue LoadError => e
31
+ errors << "#{require_path}: #{e.message}"
32
+ end
33
+
34
+ @loaded = false
35
+ BreakerMachines.instance_variable_set(:@native_available, false)
36
+ BreakerMachines.log(:warn, "Native extension not available: #{errors.join(' | ')}") unless errors.empty?
37
+
38
+ false
39
+ end
40
+
41
+ # Check if load was attempted
42
+ def loaded?
43
+ defined?(@loaded) && @loaded
44
+ end
45
+
46
+ private
47
+
48
+ def native_library_candidates
49
+ dlext = RbConfig::CONFIG['DLEXT']
50
+ base_dir = File.expand_path('../breaker_machines_native', __dir__)
51
+ ruby_version = RbConfig::CONFIG['ruby_version']
52
+ arch = RbConfig::CONFIG['arch']
53
+ platform = Gem::Platform.local.to_s
54
+
55
+ matches = Dir.glob(File.join(base_dir, '**', "breaker_machines_native.#{dlext}"))
56
+
57
+ prioritized = matches.sort_by do |path|
58
+ score = 0
59
+ score -= 3 if path.include?(ruby_version)
60
+ score -= 2 if path.include?(arch)
61
+ score -= 1 if path.include?(platform)
62
+ [score, path]
63
+ end
64
+
65
+ candidates = prioritized.map { |full_path| require_path_for(full_path, dlext) }.presence
66
+ candidates ||= ['breaker_machines_native/breaker_machines_native']
67
+ candidates.uniq
68
+ end
69
+
70
+ def require_path_for(full_path, dlext)
71
+ root = File.expand_path('..', __dir__)
72
+ relative = full_path.sub(%r{^#{Regexp.escape(root)}/?}, '')
73
+ relative.sub(/\.#{Regexp.escape(dlext)}\z/, '')
74
+ end
75
+
76
+ def try_require(require_path)
77
+ require require_path
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'native_extension'
4
+
5
+ # Load the native extension if available
6
+ if BreakerMachines::NativeExtension.load!
7
+ # Only load Storage::Native if native extension loaded successfully
8
+ # This prevents referencing BreakerMachinesNative::Storage when not available
9
+ require_relative 'storage/native'
10
+ end