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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'weakref'
5
+
6
+ module BreakerMachines
7
+ # Global registry for tracking all circuit breaker instances
8
+ class Registry
9
+ include Singleton
10
+
11
+ def initialize
12
+ @circuits = Concurrent::Map.new
13
+ @mutex = Mutex.new
14
+ @registration_count = 0
15
+ @cleanup_interval = 100 # Clean up every N registrations
16
+ end
17
+
18
+ # Register a circuit instance
19
+ def register(circuit)
20
+ @mutex.synchronize do
21
+ # Use circuit object as key - Concurrent::Map handles object identity correctly
22
+ @circuits[circuit] = WeakRef.new(circuit)
23
+
24
+ # Periodic cleanup
25
+ @registration_count += 1
26
+ if @registration_count >= @cleanup_interval
27
+ cleanup_dead_references_unsafe
28
+ @registration_count = 0
29
+ end
30
+ end
31
+ end
32
+
33
+ # Unregister a circuit instance
34
+ def unregister(circuit)
35
+ @mutex.synchronize do
36
+ @circuits.delete(circuit)
37
+ end
38
+ end
39
+
40
+ # Get all active circuits
41
+ def all_circuits
42
+ @mutex.synchronize do
43
+ @circuits.values.map do |weak_ref|
44
+ weak_ref.__getobj__
45
+ rescue WeakRef::RefError
46
+ nil
47
+ end.compact
48
+ end
49
+ end
50
+
51
+ # Find circuits by name
52
+ def find_by_name(name)
53
+ all_circuits.select { |circuit| circuit.name == name }
54
+ end
55
+
56
+ # Get summary statistics
57
+ def stats_summary
58
+ circuits = all_circuits
59
+ {
60
+ total: circuits.size,
61
+ by_state: circuits.group_by(&:status_name).transform_values(&:count),
62
+ by_name: circuits.group_by(&:name).transform_values(&:count)
63
+ }
64
+ end
65
+
66
+ # Get detailed information for all circuits
67
+ def detailed_report
68
+ all_circuits.map(&:to_h)
69
+ end
70
+
71
+ # Clear all circuits (useful for testing)
72
+ def clear
73
+ @mutex.synchronize do
74
+ @circuits.clear
75
+ end
76
+ end
77
+
78
+ # Clean up dead references (thread-safe)
79
+ def cleanup_dead_references
80
+ @mutex.synchronize do
81
+ cleanup_dead_references_unsafe
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # Clean up dead references (must be called within mutex)
88
+ def cleanup_dead_references_unsafe
89
+ dead_ids = []
90
+ @circuits.each_pair do |id, weak_ref|
91
+ weak_ref.__getobj__
92
+ rescue WeakRef::RefError
93
+ dead_ids << id
94
+ end
95
+
96
+ dead_ids.each { |id| @circuits.delete(id) }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module Storage
5
+ # Abstract base class for storage backends
6
+ class Base
7
+ def initialize(**options)
8
+ @options = options
9
+ end
10
+
11
+ # Status management
12
+ def get_status(circuit_name)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def set_status(circuit_name, status, opened_at = nil)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ # Metrics tracking
21
+ def record_success(circuit_name, duration)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def record_failure(circuit_name, duration)
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def success_count(circuit_name, window_seconds)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def failure_count(circuit_name, window_seconds)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # Cleanup
38
+ def clear(circuit_name)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ def clear_all
43
+ raise NotImplementedError
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+ require 'concurrent/array'
5
+
6
+ module BreakerMachines
7
+ module Storage
8
+ # Efficient bucket-based memory storage implementation
9
+ # Uses fixed-size circular buffers for constant-time event counting
10
+ class BucketMemory < Base
11
+ BUCKET_SIZE = 1 # 1 second per bucket
12
+
13
+ def initialize(**options)
14
+ super
15
+ @circuits = Concurrent::Map.new
16
+ @circuit_buckets = Concurrent::Map.new
17
+ @event_logs = Concurrent::Map.new
18
+ @bucket_count = options[:bucket_count] || 300 # Default 5 minutes
19
+ @max_events = options[:max_events] || 100
20
+ end
21
+
22
+ def get_status(circuit_name)
23
+ circuit_data = @circuits[circuit_name]
24
+ return nil unless circuit_data
25
+
26
+ {
27
+ status: circuit_data[:status],
28
+ opened_at: circuit_data[:opened_at]
29
+ }
30
+ end
31
+
32
+ def set_status(circuit_name, status, opened_at = nil)
33
+ @circuits[circuit_name] = {
34
+ status: status,
35
+ opened_at: opened_at,
36
+ updated_at: monotonic_time
37
+ }
38
+ end
39
+
40
+ def record_success(circuit_name, duration)
41
+ record_event(circuit_name, :success, duration)
42
+ end
43
+
44
+ def record_failure(circuit_name, duration)
45
+ record_event(circuit_name, :failure, duration)
46
+ end
47
+
48
+ def success_count(circuit_name, window_seconds)
49
+ count_events(circuit_name, :success, window_seconds)
50
+ end
51
+
52
+ def failure_count(circuit_name, window_seconds)
53
+ count_events(circuit_name, :failure, window_seconds)
54
+ end
55
+
56
+ def clear(circuit_name)
57
+ @circuits.delete(circuit_name)
58
+ @circuit_buckets.delete(circuit_name)
59
+ @event_logs.delete(circuit_name)
60
+ end
61
+
62
+ def clear_all
63
+ @circuits.clear
64
+ @circuit_buckets.clear
65
+ @event_logs.clear
66
+ end
67
+
68
+ def record_event_with_details(circuit_name, type, duration, error: nil, new_state: nil)
69
+ events = @event_logs.compute_if_absent(circuit_name) { Concurrent::Array.new }
70
+
71
+ event = {
72
+ type: type,
73
+ timestamp: monotonic_time,
74
+ duration_ms: (duration * 1000).round(2)
75
+ }
76
+
77
+ event[:error_class] = error.class.name if error
78
+ event[:error_message] = error.message if error
79
+ event[:new_state] = new_state if new_state
80
+
81
+ events << event
82
+
83
+ # Keep only the most recent events
84
+ events.shift while events.size > @max_events
85
+ end
86
+
87
+ def event_log(circuit_name, limit)
88
+ events = @event_logs[circuit_name]
89
+ return [] unless events
90
+
91
+ events.last(limit).map(&:dup)
92
+ end
93
+
94
+ private
95
+
96
+ def record_event(circuit_name, type, _duration)
97
+ buckets = @circuit_buckets.compute_if_absent(circuit_name) do
98
+ {
99
+ successes: Concurrent::Array.new(@bucket_count) { Concurrent::AtomicFixnum.new(0) },
100
+ failures: Concurrent::Array.new(@bucket_count) { Concurrent::AtomicFixnum.new(0) },
101
+ last_bucket_time: Concurrent::AtomicReference.new(current_bucket_time)
102
+ }
103
+ end
104
+
105
+ current_time = current_bucket_time
106
+ rotate_buckets_if_needed(buckets, current_time)
107
+
108
+ bucket_index = current_time % @bucket_count
109
+ counter_array = type == :success ? buckets[:successes] : buckets[:failures]
110
+ counter_array[bucket_index].increment
111
+ end
112
+
113
+ def count_events(circuit_name, type, window_seconds)
114
+ buckets = @circuit_buckets[circuit_name]
115
+ return 0 unless buckets
116
+
117
+ current_time = current_bucket_time
118
+ rotate_buckets_if_needed(buckets, current_time)
119
+
120
+ # Calculate how many buckets to count
121
+ buckets_to_count = [window_seconds / BUCKET_SIZE, @bucket_count].min.to_i
122
+
123
+ counter_array = type == :success ? buckets[:successes] : buckets[:failures]
124
+ total = 0
125
+
126
+ buckets_to_count.times do |i|
127
+ bucket_index = (current_time - i) % @bucket_count
128
+ total += counter_array[bucket_index].value
129
+ end
130
+
131
+ total
132
+ end
133
+
134
+ def rotate_buckets_if_needed(buckets, current_time)
135
+ last_time = buckets[:last_bucket_time].value
136
+
137
+ return if current_time == last_time
138
+
139
+ # Only one thread should rotate buckets
140
+ return unless buckets[:last_bucket_time].compare_and_set(last_time, current_time)
141
+
142
+ # Clear buckets that are now outdated
143
+ time_diff = current_time - last_time
144
+ buckets_to_clear = [time_diff, @bucket_count].min
145
+
146
+ buckets_to_clear.times do |i|
147
+ # Clear the bucket that will be reused
148
+ bucket_index = (last_time + i + 1) % @bucket_count
149
+ buckets[:successes][bucket_index].value = 0
150
+ buckets[:failures][bucket_index].value = 0
151
+ end
152
+ end
153
+
154
+ def current_bucket_time
155
+ (monotonic_time / BUCKET_SIZE).to_i
156
+ end
157
+
158
+ def monotonic_time
159
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+ require 'concurrent/array'
5
+
6
+ module BreakerMachines
7
+ module Storage
8
+ # High-performance in-memory storage backend with thread-safe operations
9
+ class Memory < Base
10
+ def initialize(**options)
11
+ super
12
+ @circuits = Concurrent::Map.new
13
+ @events = Concurrent::Map.new
14
+ @event_logs = Concurrent::Map.new
15
+ @max_events = options[:max_events] || 100
16
+ end
17
+
18
+ def get_status(circuit_name)
19
+ circuit_data = @circuits[circuit_name]
20
+ return nil unless circuit_data
21
+
22
+ {
23
+ status: circuit_data[:status],
24
+ opened_at: circuit_data[:opened_at]
25
+ }
26
+ end
27
+
28
+ def set_status(circuit_name, status, opened_at = nil)
29
+ @circuits[circuit_name] = {
30
+ status: status,
31
+ opened_at: opened_at,
32
+ updated_at: monotonic_time
33
+ }
34
+ end
35
+
36
+ def record_success(circuit_name, duration)
37
+ record_event(circuit_name, :success, duration)
38
+ end
39
+
40
+ def record_failure(circuit_name, duration)
41
+ record_event(circuit_name, :failure, duration)
42
+ end
43
+
44
+ def success_count(circuit_name, window_seconds)
45
+ count_events(circuit_name, :success, window_seconds)
46
+ end
47
+
48
+ def failure_count(circuit_name, window_seconds)
49
+ count_events(circuit_name, :failure, window_seconds)
50
+ end
51
+
52
+ def clear(circuit_name)
53
+ @circuits.delete(circuit_name)
54
+ @events.delete(circuit_name)
55
+ @event_logs.delete(circuit_name)
56
+ end
57
+
58
+ def clear_all
59
+ @circuits.clear
60
+ @events.clear
61
+ @event_logs.clear
62
+ end
63
+
64
+ def record_event_with_details(circuit_name, type, duration, error: nil, new_state: nil)
65
+ events = @event_logs.compute_if_absent(circuit_name) { Concurrent::Array.new }
66
+
67
+ event = {
68
+ type: type,
69
+ timestamp: monotonic_time,
70
+ duration_ms: (duration * 1000).round(2)
71
+ }
72
+
73
+ event[:error_class] = error.class.name if error
74
+ event[:error_message] = error.message if error
75
+ event[:new_state] = new_state if new_state
76
+
77
+ events << event
78
+
79
+ # Keep only the most recent events
80
+ events.shift while events.size > @max_events
81
+ end
82
+
83
+ def event_log(circuit_name, limit)
84
+ events = @event_logs[circuit_name]
85
+ return [] unless events
86
+
87
+ events.last(limit).map(&:dup)
88
+ end
89
+
90
+ private
91
+
92
+ def record_event(circuit_name, type, duration)
93
+ # Initialize if needed
94
+ @events.compute_if_absent(circuit_name) { Concurrent::Array.new }
95
+
96
+ # Get the array and add event
97
+ events = @events[circuit_name]
98
+ current_time = monotonic_time
99
+
100
+ # Add new event
101
+ events << {
102
+ type: type,
103
+ duration: duration,
104
+ timestamp: current_time
105
+ }
106
+
107
+ # Clean old events periodically (every 100 events)
108
+ return unless events.size > 100
109
+
110
+ cutoff_time = current_time - 300 # Keep 5 minutes of history
111
+ events.delete_if { |e| e[:timestamp] < cutoff_time }
112
+ end
113
+
114
+ def count_events(circuit_name, type, window_seconds)
115
+ events = @events[circuit_name]
116
+ return 0 unless events
117
+
118
+ cutoff_time = monotonic_time - window_seconds
119
+ events.count { |e| e[:type] == type && e[:timestamp] >= cutoff_time }
120
+ end
121
+
122
+ def monotonic_time
123
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ module Storage
5
+ # A no-op storage backend for minimal overhead
6
+ # Use this when you don't need event logging or metrics
7
+ class Null < Base
8
+ def record_success(_circuit_name, _duration = nil)
9
+ # No-op
10
+ end
11
+
12
+ def record_failure(_circuit_name, _duration = nil)
13
+ # No-op
14
+ end
15
+
16
+ def success_count(_circuit_name, _window = nil)
17
+ 0
18
+ end
19
+
20
+ def failure_count(_circuit_name, _window = nil)
21
+ 0
22
+ end
23
+
24
+ def get_status(_circuit_name)
25
+ nil
26
+ end
27
+
28
+ def set_status(_circuit_name, _status, _opened_at = nil)
29
+ # No-op
30
+ end
31
+
32
+ def clear(_circuit_name)
33
+ # No-op
34
+ end
35
+
36
+ def event_log(_circuit_name, _limit = 20)
37
+ []
38
+ end
39
+
40
+ def record_event_with_details(_circuit_name, _event_type, _duration, _metadata = {})
41
+ # No-op
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ # Storage backends for persisting circuit state and metrics
5
+ module Storage
6
+ # Storage module for circuit breaker state persistence
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ require 'active_support'
5
+ require 'active_support/core_ext'
6
+ require_relative 'breaker_machines/errors'
7
+
8
+ loader = Zeitwerk::Loader.for_gem
9
+ loader.inflector.inflect('dsl' => 'DSL')
10
+ loader.ignore("#{__dir__}/breaker_machines/errors.rb")
11
+ loader.ignore("#{__dir__}/breaker_machines/console.rb")
12
+ loader.setup
13
+
14
+ # BreakerMachines provides a thread-safe implementation of the Circuit Breaker pattern
15
+ # for Ruby applications, helping to prevent cascading failures in distributed systems.
16
+ module BreakerMachines
17
+ class << self
18
+ def loader
19
+ loader
20
+ end
21
+ end
22
+
23
+ # Global configuration
24
+ include ActiveSupport::Configurable
25
+
26
+ config_accessor :default_storage, default: :bucket_memory
27
+ config_accessor :default_timeout, default: nil
28
+ config_accessor :default_reset_timeout, default: 60
29
+ config_accessor :default_failure_threshold, default: 5
30
+ config_accessor :log_events, default: true
31
+ config_accessor :fiber_safe, default: false
32
+
33
+ class << self
34
+ def configure
35
+ yield config
36
+ end
37
+
38
+ def setup_notifications
39
+ return unless config.log_events
40
+
41
+ ActiveSupport::Notifications.subscribe(/^breaker_machines\./) do |name, _start, _finish, _id, payload|
42
+ event_type = name.split('.').last
43
+ circuit_name = payload[:circuit]
44
+
45
+ case event_type
46
+ when 'opened'
47
+ logger&.warn "[BreakerMachines] Circuit '#{circuit_name}' opened"
48
+ when 'closed'
49
+ logger&.info "[BreakerMachines] Circuit '#{circuit_name}' closed"
50
+ when 'half_opened'
51
+ logger&.info "[BreakerMachines] Circuit '#{circuit_name}' half-opened"
52
+ end
53
+ end
54
+ end
55
+
56
+ def logger
57
+ @logger ||= ActiveSupport::Logger.new($stdout)
58
+ end
59
+
60
+ attr_writer :logger
61
+
62
+ def instrument(event, payload = {})
63
+ return unless config.log_events
64
+
65
+ ActiveSupport::Notifications.instrument("breaker_machines.#{event}", payload)
66
+ end
67
+
68
+ # Launch the interactive console
69
+ def console
70
+ require_relative 'breaker_machines/console'
71
+ Console.start
72
+ end
73
+
74
+ # Get the global registry
75
+ def registry
76
+ Registry.instance
77
+ end
78
+ end
79
+
80
+ # Set up notifications on first use
81
+ setup_notifications if config.log_events
82
+ end
data/sig/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # BreakerMachines RBS Type Signatures
2
+
3
+ This directory contains RBS (Ruby type signatures) for the BreakerMachines gem.
4
+
5
+ ## Structure
6
+
7
+ - `breaker_machines.rbs` - Main module and configuration
8
+ - `breaker_machines/` - Type signatures for all classes and modules
9
+ - `circuit.rbs` - Circuit class and its modules
10
+ - `errors.rbs` - Error classes
11
+ - `storage.rbs` - Storage backends
12
+ - `dsl.rbs` - DSL module for including in classes
13
+ - `registry.rbs` - Global circuit registry
14
+ - `console.rbs` - Interactive console
15
+ - `types.rbs` - Common type aliases
16
+ - `interfaces.rbs` - Interface definitions
17
+
18
+ ## Usage
19
+
20
+ To use these type signatures in your project:
21
+
22
+ 1. Add to your `Steepfile`:
23
+ ```ruby
24
+ target :app do
25
+ signature "sig"
26
+ check "lib"
27
+
28
+ library "breaker_machines"
29
+ end
30
+ ```
31
+
32
+ 2. Or with RBS directly:
33
+ ```bash
34
+ rbs validate
35
+ ```
36
+
37
+ ## Type Checking Examples
38
+
39
+ ### Basic Circuit Usage
40
+ ```ruby
41
+ circuit = BreakerMachines::Circuit.new("api",
42
+ failure_threshold: 5,
43
+ reset_timeout: 30
44
+ )
45
+
46
+ result = circuit.call { api.fetch_data }
47
+ ```
48
+
49
+ ### DSL Usage
50
+ ```ruby
51
+ class MyService
52
+ include BreakerMachines::DSL
53
+
54
+ circuit :database do
55
+ threshold failures: 10, within: 60
56
+ reset_after 120
57
+ fallback { [] }
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Key Types
63
+
64
+ - `circuit_state` - `:open | :closed | :half_open`
65
+ - `storage_backend` - `:memory | :bucket_memory | :null | :redis`
66
+ - `circuit_options` - Configuration hash for circuits
67
+ - `event_record` - Structure for logged events
68
+
69
+ ## Interfaces
70
+
71
+ The type signatures define several interfaces:
72
+ - `_StorageBackend` - For custom storage implementations
73
+ - `_MetricsRecorder` - For custom metrics recording
74
+ - `_CircuitLike` - For circuit-compatible objects
data/sig/all.rbs ADDED
@@ -0,0 +1,25 @@
1
+ # Main entry point for BreakerMachines RBS types
2
+ #
3
+ # This file provides a complete type definition for the BreakerMachines gem,
4
+ # a Ruby implementation of the Circuit Breaker pattern.
5
+ #
6
+ # Usage:
7
+ # class MyService
8
+ # include BreakerMachines::DSL
9
+ #
10
+ # circuit :api_call do
11
+ # threshold failures: 5, within: 60
12
+ # reset_after 30
13
+ # fallback { |error| { error: "Service unavailable" } }
14
+ # end
15
+ #
16
+ # def fetch_data
17
+ # circuit(:api_call).call do
18
+ # # Your API call here
19
+ # end
20
+ # end
21
+ # end
22
+
23
+ # Import all type definitions
24
+ use BreakerMachines::*
25
+ use BreakerMachines::Storage::*