breaker_machines 0.1.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.
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ # Interactive console for debugging and monitoring circuit breakers
5
+ class Console
6
+ def self.start
7
+ new.run
8
+ end
9
+
10
+ def initialize
11
+ @running = true
12
+ end
13
+
14
+ def run
15
+ print_header
16
+ print_help if BreakerMachines::Registry.instance.all_circuits.empty?
17
+
18
+ while @running
19
+ print_prompt
20
+ command = gets&.chomp
21
+ break unless command
22
+
23
+ process_command(command)
24
+ end
25
+
26
+ puts "\nExiting BreakerMachines Console..."
27
+ end
28
+
29
+ private
30
+
31
+ def print_header
32
+ puts "\n#{'=' * 60}"
33
+ puts "BreakerMachines Console v#{BreakerMachines::VERSION}"
34
+ puts '=' * 60
35
+ puts "Type 'help' for available commands"
36
+ puts
37
+ end
38
+
39
+ def print_help
40
+ puts <<~HELP
41
+ Available commands:
42
+ list - List all circuits
43
+ stats - Show summary statistics
44
+ show <name> - Show details for circuits with given name
45
+ events <name> [limit] - Show event log for circuit
46
+ reset <name> - Reset circuit(s) to closed state
47
+ force_open <name> - Force circuit to open state
48
+ force_close <name> - Force circuit to closed state
49
+ report - Generate full report
50
+ cleanup - Remove dead circuit references
51
+ refresh - Refresh display
52
+ help - Show this help
53
+ exit/quit - Exit console
54
+
55
+ HELP
56
+ end
57
+
58
+ def print_prompt
59
+ print '> '
60
+ end
61
+
62
+ def process_command(command)
63
+ parts = command.split
64
+ cmd = parts[0]&.downcase
65
+ args = parts[1..]
66
+
67
+ case cmd
68
+ when 'help', '?'
69
+ print_help
70
+ when 'list', 'ls'
71
+ list_circuits
72
+ when 'stats'
73
+ show_stats
74
+ when 'show'
75
+ show_circuit(args[0])
76
+ when 'events'
77
+ show_events(args[0], args[1]&.to_i || 10)
78
+ when 'reset'
79
+ reset_circuit(args[0])
80
+ when 'force_open', 'open'
81
+ force_open_circuit(args[0])
82
+ when 'force_close', 'close'
83
+ force_close_circuit(args[0])
84
+ when 'report'
85
+ generate_report
86
+ when 'cleanup'
87
+ cleanup_registry
88
+ when 'refresh', 'clear'
89
+ system('clear') || system('cls')
90
+ print_header
91
+ when 'exit', 'quit', 'q'
92
+ @running = false
93
+ else
94
+ puts "Unknown command: #{cmd}. Type 'help' for available commands."
95
+ end
96
+ end
97
+
98
+ def list_circuits
99
+ circuits = BreakerMachines::Registry.instance.all_circuits
100
+
101
+ if circuits.empty?
102
+ puts 'No circuits registered.'
103
+ return
104
+ end
105
+
106
+ puts "\nRegistered Circuits:"
107
+ puts '-' * 60
108
+ printf "%-20s %-12s %-10s %-10s %s\n", 'Name', 'State', 'Failures', 'Successes', 'Last Error'
109
+ puts '-' * 60
110
+
111
+ circuits.each do |circuit|
112
+ stats = circuit.stats
113
+ error_info = circuit.last_error ? circuit.last_error.class.name : '-'
114
+
115
+ printf "%-20s %-12s %-10d %-10d %s\n",
116
+ circuit.name,
117
+ colorize_state(stats[:state]),
118
+ stats[:failure_count],
119
+ stats[:success_count],
120
+ error_info
121
+ end
122
+
123
+ puts '-' * 60
124
+ puts "Total: #{circuits.size} circuit(s)"
125
+ puts
126
+ end
127
+
128
+ def show_stats
129
+ stats = BreakerMachines::Registry.instance.stats_summary
130
+
131
+ puts "\nCircuit Statistics:"
132
+ puts '-' * 40
133
+ puts "Total circuits: #{stats[:total]}"
134
+ puts "\nBy State:"
135
+ stats[:by_state].each do |state, count|
136
+ puts " #{colorize_state(state)}: #{count}"
137
+ end
138
+
139
+ puts "\nBy Name:"
140
+ stats[:by_name].each do |name, count|
141
+ puts " #{name}: #{count} instance(s)"
142
+ end
143
+ puts
144
+ end
145
+
146
+ def show_circuit(name)
147
+ if name.nil?
148
+ puts 'Usage: show <circuit_name>'
149
+ return
150
+ end
151
+
152
+ circuits = BreakerMachines::Registry.instance.find_by_name(name.to_sym)
153
+
154
+ if circuits.empty?
155
+ puts "No circuits found with name: #{name}"
156
+ return
157
+ end
158
+
159
+ circuits.each_with_index do |circuit, index|
160
+ puts "\n#{'-' * 60}"
161
+ puts "Circuit ##{index + 1}: #{circuit.name}"
162
+ puts '-' * 60
163
+
164
+ puts circuit.summary
165
+ puts
166
+
167
+ stats = circuit.stats
168
+ config = circuit.configuration
169
+
170
+ puts "Current State: #{colorize_state(stats[:state])}"
171
+ puts "Failure Count: #{stats[:failure_count]} / #{config[:failure_threshold]}"
172
+ puts "Success Count: #{stats[:success_count]}"
173
+
174
+ if stats[:opened_at]
175
+ puts "Opened At: #{Time.at(stats[:opened_at])}"
176
+ reset_time = Time.at(stats[:opened_at] + config[:reset_timeout])
177
+ puts "Reset At: #{reset_time} (in #{(reset_time - Time.now).to_i}s)"
178
+ end
179
+
180
+ if circuit.last_error
181
+ error_info = circuit.last_error_info
182
+ puts "\nLast Error:"
183
+ puts " Class: #{error_info[:class]}"
184
+ puts " Message: #{error_info[:message]}"
185
+ puts " Time: #{Time.at(error_info[:occurred_at])}"
186
+ end
187
+
188
+ puts "\nConfiguration:"
189
+ puts " Failure Threshold: #{config[:failure_threshold]}"
190
+ puts " Failure Window: #{config[:failure_window]}s"
191
+ puts " Reset Timeout: #{config[:reset_timeout]}s"
192
+ puts " Success Threshold: #{config[:success_threshold]}"
193
+ puts " Half-Open Calls: #{config[:half_open_calls]}"
194
+ end
195
+ puts
196
+ end
197
+
198
+ def show_events(name, limit)
199
+ if name.nil?
200
+ puts 'Usage: events <circuit_name> [limit]'
201
+ return
202
+ end
203
+
204
+ circuits = BreakerMachines::Registry.instance.find_by_name(name.to_sym)
205
+
206
+ if circuits.empty?
207
+ puts "No circuits found with name: #{name}"
208
+ return
209
+ end
210
+
211
+ circuits.each do |circuit|
212
+ events = circuit.event_log(limit: limit)
213
+
214
+ puts "\nEvent Log for #{circuit.name} (last #{limit} events):"
215
+ puts '-' * 80
216
+
217
+ if events.empty?
218
+ puts 'No events recorded.'
219
+ else
220
+ printf "%-20s %-15s %-10s %-20s %s\n", 'Timestamp', 'Type', 'Duration', 'Error', 'Details'
221
+ puts '-' * 80
222
+
223
+ events.each do |event|
224
+ timestamp = Time.at(event[:timestamp]).strftime('%Y-%m-%d %H:%M:%S')
225
+ type = colorize_event_type(event[:type])
226
+ duration = event[:duration_ms] ? "#{event[:duration_ms]}ms" : '-'
227
+ error = event[:error_class] || '-'
228
+ details = event[:new_state] ? "→ #{event[:new_state]}" : ''
229
+
230
+ printf "%-20s %-15s %-10s %-20s %s\n", timestamp, type, duration, error, details
231
+ end
232
+ end
233
+ end
234
+ puts
235
+ end
236
+
237
+ def reset_circuit(name)
238
+ if name.nil?
239
+ puts 'Usage: reset <circuit_name>'
240
+ return
241
+ end
242
+
243
+ circuits = BreakerMachines::Registry.instance.find_by_name(name.to_sym)
244
+
245
+ if circuits.empty?
246
+ puts "No circuits found with name: #{name}"
247
+ return
248
+ end
249
+
250
+ circuits.each do |circuit|
251
+ circuit.reset
252
+ puts "Reset circuit: #{circuit.name} (now #{circuit.status_name})"
253
+ end
254
+ end
255
+
256
+ def force_open_circuit(name)
257
+ if name.nil?
258
+ puts 'Usage: force_open <circuit_name>'
259
+ return
260
+ end
261
+
262
+ circuits = BreakerMachines::Registry.instance.find_by_name(name.to_sym)
263
+
264
+ if circuits.empty?
265
+ puts "No circuits found with name: #{name}"
266
+ return
267
+ end
268
+
269
+ circuits.each do |circuit|
270
+ circuit.force_open
271
+ puts "Forced open circuit: #{circuit.name} (now #{circuit.status_name})"
272
+ end
273
+ end
274
+
275
+ def force_close_circuit(name)
276
+ if name.nil?
277
+ puts 'Usage: force_close <circuit_name>'
278
+ return
279
+ end
280
+
281
+ circuits = BreakerMachines::Registry.instance.find_by_name(name.to_sym)
282
+
283
+ if circuits.empty?
284
+ puts "No circuits found with name: #{name}"
285
+ return
286
+ end
287
+
288
+ circuits.each do |circuit|
289
+ circuit.force_close
290
+ puts "Forced close circuit: #{circuit.name} (now #{circuit.status_name})"
291
+ end
292
+ end
293
+
294
+ def generate_report
295
+ report = BreakerMachines::Registry.instance.detailed_report
296
+
297
+ puts "\nFull Circuit Report"
298
+ puts '=' * 80
299
+ puts "Generated at: #{Time.now}"
300
+ puts "Total circuits: #{report.size}"
301
+ puts '=' * 80
302
+
303
+ report.each do |circuit_data|
304
+ puts "\nCircuit: #{circuit_data[:name]}"
305
+ puts JSON.pretty_generate(circuit_data)
306
+ puts '-' * 80
307
+ end
308
+ end
309
+
310
+ def cleanup_registry
311
+ before = BreakerMachines::Registry.instance.all_circuits.size
312
+ BreakerMachines::Registry.instance.cleanup_dead_references
313
+ after = BreakerMachines::Registry.instance.all_circuits.size
314
+
315
+ removed = before - after
316
+ puts "Cleaned up #{removed} dead circuit reference(s)."
317
+ end
318
+
319
+ def colorize_state(state)
320
+ case state
321
+ when :closed
322
+ "\e[32m#{state}\e[0m" # Green
323
+ when :open
324
+ "\e[31m#{state}\e[0m" # Red
325
+ when :half_open
326
+ "\e[33m#{state}\e[0m" # Yellow
327
+ else
328
+ state.to_s
329
+ end
330
+ end
331
+
332
+ def colorize_event_type(type)
333
+ case type
334
+ when :success
335
+ "\e[32m#{type}\e[0m" # Green
336
+ when :failure
337
+ "\e[31m#{type}\e[0m" # Red
338
+ when :state_change
339
+ "\e[36m#{type}\e[0m" # Cyan
340
+ else
341
+ type.to_s
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,274 @@
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 = CircuitBuilder.new
47
+ builder.instance_eval(&block)
48
+ @circuits[name] = builder.config
49
+ end
50
+
51
+ @circuits[name]
52
+ end
53
+
54
+ def circuits
55
+ # Start with parent circuits if available
56
+ base_circuits = if superclass.respond_to?(:circuits)
57
+ superclass.circuits.deep_dup
58
+ else
59
+ {}
60
+ end
61
+
62
+ # Merge with our own circuits
63
+ if @circuits
64
+ base_circuits.merge(@circuits)
65
+ else
66
+ base_circuits
67
+ end
68
+ end
69
+
70
+ # Get circuit definitions without sensitive data
71
+ def circuit_definitions
72
+ circuits.transform_values { |config| config.except(:owner, :storage, :metrics) }
73
+ end
74
+
75
+ # Reset all circuits for all instances of this class
76
+ def reset_all_circuits
77
+ cleanup_instance_registry # Clean up dead refs first
78
+
79
+ instance_registry.each do |weak_ref|
80
+ instance = weak_ref.__getobj__
81
+ circuit_instances = instance.instance_variable_get(:@circuit_instances)
82
+ circuit_instances&.each_value(&:reset)
83
+ rescue WeakRef::RefError
84
+ # Instance was garbage collected, skip it
85
+ end
86
+ end
87
+
88
+ # Get aggregated stats for all circuits of this class
89
+ def circuit_stats
90
+ stats = Hash.new { |h, k| h[k] = { total: 0, by_state: {} } }
91
+ cleanup_instance_registry # Clean up dead refs first
92
+
93
+ instance_registry.each do |weak_ref|
94
+ instance = weak_ref.__getobj__
95
+ circuit_instances = instance.instance_variable_get(:@circuit_instances)
96
+ next unless circuit_instances
97
+
98
+ circuit_instances.each do |name, circuit|
99
+ stats[name][:total] += 1
100
+ state = circuit.status_name
101
+ stats[name][:by_state][state] ||= 0
102
+ stats[name][:by_state][state] += 1
103
+ end
104
+ rescue WeakRef::RefError
105
+ # Instance was garbage collected, skip it
106
+ end
107
+
108
+ stats
109
+ end
110
+ end
111
+
112
+ # Use included callback to add instance tracking
113
+ def self.included(base)
114
+ super
115
+
116
+ # Hook into new to register instances
117
+ base.singleton_class.prepend(Module.new do
118
+ def new(...)
119
+ instance = super
120
+ instance_registry << WeakRef.new(instance)
121
+ instance
122
+ end
123
+ end)
124
+ end
125
+
126
+ def circuit(name)
127
+ self.class.circuits[name] ||= {}
128
+ @circuit_instances ||= {}
129
+ @circuit_instances[name] ||= Circuit.new(name, self.class.circuits[name].merge(owner: self))
130
+ end
131
+
132
+ # Get all circuit instances for this object
133
+ def circuit_instances
134
+ @circuit_instances || {}
135
+ end
136
+
137
+ # Get summary of all circuits for this instance
138
+ def circuits_summary
139
+ circuit_instances.transform_values(&:summary)
140
+ end
141
+
142
+ # Get detailed information for all circuits
143
+ def circuits_report
144
+ circuit_instances.transform_values(&:to_h)
145
+ end
146
+
147
+ # Reset all circuits for this instance
148
+ def reset_all_circuits
149
+ circuit_instances.each_value(&:reset)
150
+ end
151
+
152
+ # DSL builder for configuring circuit breakers with a fluent interface
153
+ class CircuitBuilder
154
+ attr_reader :config
155
+
156
+ def initialize
157
+ @config = {
158
+ failure_threshold: 5,
159
+ failure_window: 60,
160
+ success_threshold: 1,
161
+ timeout: nil,
162
+ reset_timeout: 60,
163
+ half_open_calls: 1,
164
+ exceptions: [StandardError],
165
+ storage: nil,
166
+ metrics: nil,
167
+ fallback: nil,
168
+ on_open: nil,
169
+ on_close: nil,
170
+ on_half_open: nil,
171
+ on_reject: nil,
172
+ notifications: [],
173
+ fiber_safe: BreakerMachines.config.fiber_safe
174
+ }
175
+ end
176
+
177
+ def threshold(failures: nil, within: 60, successes: nil)
178
+ @config[:failure_threshold] = failures if failures
179
+ @config[:failure_window] = within.to_i
180
+ @config[:success_threshold] = successes if successes
181
+ end
182
+
183
+ def reset_after(duration, jitter: nil)
184
+ @config[:reset_timeout] = duration.to_i
185
+ @config[:reset_timeout_jitter] = jitter if jitter
186
+ end
187
+
188
+ def timeout(duration)
189
+ @config[:timeout] = duration.to_i
190
+ end
191
+
192
+ def half_open_requests(count)
193
+ @config[:half_open_calls] = count
194
+ end
195
+
196
+ def storage(backend, **)
197
+ @config[:storage] = case backend
198
+ when :memory
199
+ Storage::Memory.new(**)
200
+ when :bucket_memory
201
+ Storage::BucketMemory.new(**)
202
+ when :redis
203
+ Storage::Redis.new(**)
204
+ when Class
205
+ backend.new(**)
206
+ else
207
+ backend
208
+ end
209
+ end
210
+
211
+ def metrics(recorder = nil, &block)
212
+ @config[:metrics] = recorder || block
213
+ end
214
+
215
+ def fallback(value = nil, &block)
216
+ raise ArgumentError, 'Fallback requires either a value or a block' if value.nil? && !block_given?
217
+
218
+ fallback_value = block || value
219
+
220
+ if @config[:fallback].is_a?(Array)
221
+ @config[:fallback] << fallback_value
222
+ elsif @config[:fallback]
223
+ @config[:fallback] = [@config[:fallback], fallback_value]
224
+ else
225
+ @config[:fallback] = fallback_value
226
+ end
227
+ end
228
+
229
+ def on_open(&block)
230
+ @config[:on_open] = block
231
+ end
232
+
233
+ def on_close(&block)
234
+ @config[:on_close] = block
235
+ end
236
+
237
+ def on_half_open(&block)
238
+ @config[:on_half_open] = block
239
+ end
240
+
241
+ def on_reject(&block)
242
+ @config[:on_reject] = block
243
+ end
244
+
245
+ def notify(service, url = nil, events: %i[open close], **options)
246
+ notification = {
247
+ via: service,
248
+ url: url,
249
+ events: Array(events),
250
+ options: options
251
+ }
252
+ @config[:notifications] << notification
253
+ end
254
+
255
+ def handle(*exceptions)
256
+ @config[:exceptions] = exceptions
257
+ end
258
+
259
+ def fiber_safe(enabled = true)
260
+ @config[:fiber_safe] = enabled
261
+ end
262
+
263
+ # Advanced features
264
+ def backends(list)
265
+ @config[:backends] = list
266
+ end
267
+
268
+ def parallel_calls(count, timeout: nil)
269
+ @config[:parallel_calls] = count
270
+ @config[:parallel_timeout] = timeout
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,30 @@
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-protected call exceeds the configured timeout
18
+ class CircuitTimeoutError < Error
19
+ attr_reader :circuit_name, :timeout
20
+
21
+ def initialize(circuit_name, timeout)
22
+ @circuit_name = circuit_name
23
+ @timeout = timeout
24
+ super("Circuit '#{circuit_name}' timed out after #{timeout}s")
25
+ end
26
+ end
27
+
28
+ class ConfigurationError < Error; end
29
+ class StorageError < Error; end
30
+ end