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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # Callbacks handles the invocation of user-defined callbacks and fallback mechanisms
6
+ # when circuit state changes occur or calls are rejected.
7
+ module Callbacks
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ def invoke_callback(callback_name)
13
+ callback = @config[callback_name]
14
+ return unless callback
15
+
16
+ return unless callback.is_a?(Proc)
17
+
18
+ if @config[:owner]
19
+ @config[:owner].instance_exec(&callback)
20
+ else
21
+ callback.call
22
+ end
23
+ end
24
+
25
+ def invoke_fallback(error)
26
+ case @config[:fallback]
27
+ when Proc
28
+ if @config[:owner]
29
+ @config[:owner].instance_exec(error, &@config[:fallback])
30
+ else
31
+ @config[:fallback].call(error)
32
+ end
33
+ when Array
34
+ # Try each fallback in order until one succeeds
35
+ last_error = error
36
+ @config[:fallback].each do |fallback|
37
+ return invoke_single_fallback(fallback, last_error)
38
+ rescue StandardError => e
39
+ last_error = e
40
+ end
41
+ raise last_error
42
+ else
43
+ # Static values (strings, hashes, etc.) or Symbol fallbacks
44
+ @config[:fallback]
45
+ end
46
+ end
47
+
48
+ def invoke_single_fallback(fallback, error)
49
+ case fallback
50
+ when Proc
51
+ if @config[:owner]
52
+ @config[:owner].instance_exec(error, &fallback)
53
+ else
54
+ fallback.call(error)
55
+ end
56
+ else
57
+ fallback
58
+ end
59
+ end
60
+
61
+ # Async versions for fiber_safe mode
62
+ def invoke_callback_async(callback_name)
63
+ callback = @config[callback_name]
64
+ return unless callback
65
+ return unless callback.is_a?(Proc)
66
+
67
+ result = if @config[:owner]
68
+ @config[:owner].instance_exec(&callback)
69
+ else
70
+ callback.call
71
+ end
72
+
73
+ # If the callback returns an Async::Task, wait for it
74
+ if defined?(::Async::Task) && result.is_a?(::Async::Task)
75
+ result.wait
76
+ else
77
+ result
78
+ end
79
+ end
80
+
81
+ def invoke_fallback_async(error)
82
+ case @config[:fallback]
83
+ when Proc
84
+ result = if @config[:owner]
85
+ @config[:owner].instance_exec(error, &@config[:fallback])
86
+ else
87
+ @config[:fallback].call(error)
88
+ end
89
+
90
+ # If the fallback returns an Async::Task, wait for it
91
+ if defined?(::Async::Task) && result.is_a?(::Async::Task)
92
+ result.wait
93
+ else
94
+ result
95
+ end
96
+ when Array
97
+ # Try each fallback in order until one succeeds
98
+ last_error = error
99
+ @config[:fallback].each do |fallback|
100
+ return invoke_single_fallback_async(fallback, last_error)
101
+ rescue StandardError => e
102
+ last_error = e
103
+ end
104
+ raise last_error
105
+ else
106
+ # Static values (strings, hashes, etc.) or Symbol fallbacks
107
+ @config[:fallback]
108
+ end
109
+ end
110
+
111
+ def invoke_single_fallback_async(fallback, error)
112
+ case fallback
113
+ when Proc
114
+ result = if @config[:owner]
115
+ @config[:owner].instance_exec(error, &fallback)
116
+ else
117
+ fallback.call(error)
118
+ end
119
+
120
+ # If the fallback returns an Async::Task, wait for it
121
+ if defined?(::Async::Task) && result.is_a?(::Async::Task)
122
+ result.wait
123
+ else
124
+ result
125
+ end
126
+ else
127
+ fallback
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # Configuration manages circuit initialization and default settings,
6
+ # including thresholds, timeouts, storage backends, and callbacks.
7
+ module Configuration
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ attr_reader :name, :config, :opened_at
12
+ end
13
+
14
+ def initialize(name, options = {})
15
+ @name = name
16
+ @config = default_config.merge(options)
17
+ # Always use a storage backend for proper sliding window implementation
18
+ # Use global default storage if not specified
19
+ @storage = @config[:storage] || create_default_storage
20
+ @metrics = @config[:metrics]
21
+ @opened_at = Concurrent::AtomicReference.new(nil)
22
+ @half_open_attempts = Concurrent::AtomicFixnum.new(0)
23
+ @half_open_successes = Concurrent::AtomicFixnum.new(0)
24
+ @mutex = Concurrent::ReentrantReadWriteLock.new
25
+ @last_failure_at = Concurrent::AtomicReference.new(nil)
26
+ @last_error = Concurrent::AtomicReference.new(nil)
27
+
28
+ super() # Initialize state machine
29
+ restore_status_from_storage if @storage
30
+
31
+ # Register with global registry
32
+ BreakerMachines::Registry.instance.register(self)
33
+ end
34
+
35
+ private
36
+
37
+ def default_config
38
+ {
39
+ failure_threshold: 5,
40
+ failure_window: 60, # seconds
41
+ success_threshold: 1,
42
+ timeout: nil,
43
+ reset_timeout: 60, # seconds
44
+ reset_timeout_jitter: 0.25, # +/- 25% by default
45
+ half_open_calls: 1,
46
+ storage: nil, # Will default to Memory storage if nil
47
+ metrics: nil,
48
+ fallback: nil,
49
+ on_open: nil,
50
+ on_close: nil,
51
+ on_half_open: nil,
52
+ on_reject: nil,
53
+ exceptions: [StandardError],
54
+ fiber_safe: BreakerMachines.config.fiber_safe
55
+ }
56
+ end
57
+
58
+ def create_default_storage
59
+ case BreakerMachines.config.default_storage
60
+ when :memory
61
+ BreakerMachines::Storage::Memory.new
62
+ when :bucket_memory
63
+ BreakerMachines::Storage::BucketMemory.new
64
+ when :null
65
+ BreakerMachines::Storage::Null.new
66
+ else
67
+ # Allow for custom storage class names
68
+ BreakerMachines.config.default_storage.new
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # Execution handles the core circuit breaker logic including call wrapping,
6
+ # state-based request handling, and failure/success tracking.
7
+ module Execution
8
+ extend ActiveSupport::Concern
9
+
10
+ # Lazy load async support only when needed
11
+ def self.load_async_support
12
+ require 'async'
13
+ require 'async/task'
14
+ rescue LoadError
15
+ raise LoadError, "The 'async' gem is required for fiber_safe mode. Add `gem 'async'` to your Gemfile."
16
+ end
17
+
18
+ def call(&)
19
+ wrap(&)
20
+ end
21
+
22
+ def wrap(&block)
23
+ @mutex.with_read_lock do
24
+ case status_name
25
+ when :open
26
+ handle_open_status(&block)
27
+ when :half_open
28
+ handle_half_open_status(&block)
29
+ when :closed
30
+ handle_closed_status(&block)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def handle_open_status(&)
38
+ if reset_timeout_elapsed?
39
+ @mutex.with_write_lock do
40
+ attempt_recovery if open?
41
+ end
42
+ handle_half_open_status(&)
43
+ else
44
+ reject_call
45
+ end
46
+ end
47
+
48
+ def handle_half_open_status(&)
49
+ # Atomically increment and get the new value
50
+ new_attempts = @half_open_attempts.increment
51
+
52
+ if new_attempts <= @config[:half_open_calls]
53
+ execute_call(&)
54
+ else
55
+ # This thread lost the race, decrement back and reject
56
+ @half_open_attempts.decrement
57
+ reject_call
58
+ end
59
+ end
60
+
61
+ def handle_closed_status(&)
62
+ execute_call(&)
63
+ end
64
+
65
+ def execute_call(&block)
66
+ # Use async version if fiber_safe is enabled
67
+ return execute_call_async(&block) if @config[:fiber_safe]
68
+
69
+ start_time = monotonic_time
70
+
71
+ begin
72
+ # IMPORTANT: We do NOT implement forceful timeouts as they are inherently unsafe
73
+ # The timeout configuration is provided for documentation/intent purposes
74
+ # Users should implement timeouts in their own code using safe mechanisms
75
+ # (e.g., HTTP client timeouts, database statement timeouts, etc.)
76
+ # Log a warning if timeout is configured
77
+ if @config[:timeout] && BreakerMachines.logger && BreakerMachines.config.log_events
78
+ BreakerMachines.logger.warn(
79
+ "[BreakerMachines] Circuit '#{@name}' has timeout configured but " \
80
+ 'forceful timeouts are not implemented for safety. ' \
81
+ 'Please use timeout mechanisms provided by your libraries ' \
82
+ '(e.g., Net::HTTP read_timeout, ActiveRecord statement_timeout).'
83
+ )
84
+ end
85
+
86
+ # Execute normally without forceful timeout
87
+ result = block.call
88
+
89
+ record_success(monotonic_time - start_time)
90
+ handle_success
91
+ result
92
+ rescue *@config[:exceptions] => e
93
+ record_failure(monotonic_time - start_time, e)
94
+ handle_failure
95
+ raise unless @config[:fallback]
96
+
97
+ invoke_fallback(e)
98
+ end
99
+ end
100
+
101
+ def execute_call_async(&)
102
+ # Ensure async is loaded
103
+ Execution.load_async_support unless defined?(::Async)
104
+
105
+ start_time = monotonic_time
106
+
107
+ begin
108
+ result = if @config[:timeout]
109
+ # Use safe, cooperative timeout from async gem
110
+ ::Async::Task.current.with_timeout(@config[:timeout], &)
111
+ else
112
+ yield
113
+ end
114
+
115
+ record_success(monotonic_time - start_time)
116
+ handle_success
117
+ result
118
+ rescue ::Async::TimeoutError => e
119
+ # Handle async timeout as a failure
120
+ record_failure(monotonic_time - start_time, e)
121
+ handle_failure
122
+ raise unless @config[:fallback]
123
+
124
+ invoke_fallback_async(e)
125
+ rescue *@config[:exceptions] => e
126
+ record_failure(monotonic_time - start_time, e)
127
+ handle_failure
128
+ raise unless @config[:fallback]
129
+
130
+ invoke_fallback_async(e)
131
+ end
132
+ end
133
+
134
+ def reject_call
135
+ @metrics&.record_rejection(@name)
136
+ invoke_callback(:on_reject)
137
+
138
+ raise BreakerMachines::CircuitOpenError.new(@name, @opened_at.value) unless @config[:fallback]
139
+
140
+ invoke_fallback(BreakerMachines::CircuitOpenError.new(@name, @opened_at.value))
141
+ end
142
+
143
+ def handle_success
144
+ @mutex.with_write_lock do
145
+ if half_open?
146
+ # Check if all allowed half-open calls have succeeded
147
+ # This ensures the circuit can close even if success_threshold > half_open_calls
148
+ successful_attempts = @half_open_successes.increment
149
+
150
+ # Fast-close logic: Circuit closes if EITHER:
151
+ # 1. All allowed half-open calls succeeded (conservative approach)
152
+ # 2. Success threshold is reached (aggressive approach for quick recovery)
153
+ # This allows flexible configuration - set success_threshold=1 for fast recovery
154
+ # or success_threshold=half_open_calls for cautious recovery
155
+ if successful_attempts >= @config[:half_open_calls] || success_threshold_reached?
156
+ @half_open_attempts.value = 0
157
+ @half_open_successes.value = 0
158
+ reset
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ def handle_failure
165
+ @mutex.with_write_lock do
166
+ if closed? && failure_threshold_exceeded?
167
+ trip
168
+ elsif half_open?
169
+ @half_open_attempts.value = 0
170
+ @half_open_successes.value = 0
171
+ trip
172
+ end
173
+ end
174
+ end
175
+
176
+ def failure_threshold_exceeded?
177
+ recent_failures = @storage.failure_count(@name, @config[:failure_window])
178
+ recent_failures >= @config[:failure_threshold]
179
+ end
180
+
181
+ def success_threshold_reached?
182
+ recent_successes = @storage.success_count(@name, @config[:failure_window])
183
+ recent_successes >= @config[:success_threshold]
184
+ end
185
+
186
+ def record_success(duration)
187
+ @metrics&.record_success(@name, duration)
188
+ @storage&.record_success(@name, duration)
189
+ return unless @storage.respond_to?(:record_event_with_details)
190
+
191
+ @storage.record_event_with_details(@name, :success,
192
+ duration)
193
+ end
194
+
195
+ def record_failure(duration, error = nil)
196
+ @last_failure_at.value = monotonic_time
197
+ @last_error.value = error if error
198
+ @metrics&.record_failure(@name, duration)
199
+ @storage&.record_failure(@name, duration)
200
+ return unless @storage.respond_to?(:record_event_with_details)
201
+
202
+ @storage.record_event_with_details(@name, :failure, duration,
203
+ error: error)
204
+ end
205
+
206
+ def monotonic_time
207
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # Introspection provides methods for inspecting circuit state, statistics,
6
+ # and generating human-readable summaries of circuit status.
7
+ module Introspection
8
+ extend ActiveSupport::Concern
9
+ # State check methods are automatically generated by state_machines:
10
+ # - open? (returns true when status == :open)
11
+ # - closed? (returns true when status == :closed)
12
+ # - half_open? (returns true when status == :half_open)
13
+
14
+ def stats
15
+ {
16
+ state: status_name,
17
+ failure_count: @storage.failure_count(@name, @config[:failure_window]),
18
+ success_count: @storage.success_count(@name, @config[:failure_window]),
19
+ last_failure_at: @last_failure_at.value,
20
+ opened_at: @opened_at.value,
21
+ half_open_attempts: @half_open_attempts.value,
22
+ half_open_successes: @half_open_successes.value
23
+ }
24
+ end
25
+
26
+ def configuration
27
+ @config.dup
28
+ end
29
+
30
+ def event_log(limit: 20)
31
+ @storage.event_log(@name, limit) if @storage.respond_to?(:event_log)
32
+ end
33
+
34
+ def last_error
35
+ @last_error.value
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ name: @name,
41
+ state: status_name,
42
+ stats: stats,
43
+ config: configuration.except(:owner, :storage, :metrics),
44
+ event_log: event_log || [],
45
+ last_error: last_error_info
46
+ }
47
+ end
48
+
49
+ def summary
50
+ case status_name
51
+ when :closed
52
+ "Circuit '#{@name}' is CLOSED. #{stats[:failure_count]} failures recorded."
53
+ when :open
54
+ reset_time = Time.at(@opened_at.value + @config[:reset_timeout])
55
+ opened_time = Time.at(@opened_at.value)
56
+ error_info = @last_error.value ? " The last error was #{@last_error.value.class}." : ''
57
+ "Circuit '#{@name}' is OPEN until #{reset_time}. " \
58
+ "It opened at #{opened_time} after #{@config[:failure_threshold]} failures.#{error_info}"
59
+ when :half_open
60
+ "Circuit '#{@name}' is HALF-OPEN. Testing with limited requests " \
61
+ "(#{@half_open_attempts.value}/#{@config[:half_open_calls]} attempts)."
62
+ end
63
+ end
64
+
65
+ def last_error_info
66
+ error = @last_error.value
67
+ return nil unless error
68
+
69
+ {
70
+ class: error.class.name,
71
+ message: error.message,
72
+ occurred_at: @last_failure_at.value
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BreakerMachines
4
+ class Circuit
5
+ # StateManagement provides the state machine functionality for circuit breakers,
6
+ # managing transitions between closed, open, and half-open states.
7
+ module StateManagement
8
+ extend ActiveSupport::Concern
9
+ included do
10
+ state_machine :status, initial: :closed do
11
+ event :trip do
12
+ transition closed: :open
13
+ transition half_open: :open
14
+ end
15
+
16
+ event :attempt_recovery do
17
+ transition open: :half_open
18
+ end
19
+
20
+ event :reset do
21
+ transition %i[open half_open] => :closed
22
+ end
23
+
24
+ event :force_open do
25
+ transition any => :open
26
+ end
27
+
28
+ event :force_close do
29
+ transition any => :closed
30
+ end
31
+
32
+ after_transition any => :open do |circuit|
33
+ circuit.send(:on_circuit_open)
34
+ end
35
+
36
+ after_transition any => :closed do |circuit|
37
+ circuit.send(:on_circuit_close)
38
+ end
39
+
40
+ after_transition open: :half_open do |circuit|
41
+ circuit.send(:on_circuit_half_open)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def on_circuit_open
49
+ @opened_at.value = monotonic_time
50
+ @storage&.set_status(@name, :open, @opened_at.value)
51
+ if @storage.respond_to?(:record_event_with_details)
52
+ @storage.record_event_with_details(@name, :state_change, 0,
53
+ new_state: :open)
54
+ end
55
+ invoke_callback(:on_open)
56
+ BreakerMachines.instrument('opened', circuit: @name)
57
+ end
58
+
59
+ def on_circuit_close
60
+ @opened_at.value = nil
61
+ @last_error.value = nil
62
+ @last_failure_at.value = nil
63
+ @storage&.set_status(@name, :closed)
64
+ if @storage.respond_to?(:record_event_with_details)
65
+ @storage.record_event_with_details(@name, :state_change, 0,
66
+ new_state: :closed)
67
+ end
68
+ invoke_callback(:on_close)
69
+ BreakerMachines.instrument('closed', circuit: @name)
70
+ end
71
+
72
+ def on_circuit_half_open
73
+ @half_open_attempts.value = 0
74
+ @half_open_successes.value = 0
75
+ @storage&.set_status(@name, :half_open)
76
+ if @storage.respond_to?(:record_event_with_details)
77
+ @storage.record_event_with_details(@name, :state_change, 0,
78
+ new_state: :half_open)
79
+ end
80
+ invoke_callback(:on_half_open)
81
+ BreakerMachines.instrument('half_opened', circuit: @name)
82
+ end
83
+
84
+ def restore_status_from_storage
85
+ stored_status = @storage.get_status(@name)
86
+ return unless stored_status
87
+
88
+ self.status = stored_status[:status].to_s
89
+ @opened_at.value = stored_status[:opened_at] if stored_status[:opened_at]
90
+ end
91
+
92
+ def reset_timeout_elapsed?
93
+ return false unless @opened_at.value
94
+
95
+ # Add jitter to prevent thundering herd
96
+ jitter_factor = @config[:reset_timeout_jitter] || 0.25
97
+ # Calculate random jitter between -jitter_factor and +jitter_factor
98
+ jitter_multiplier = 1.0 + (((rand * 2) - 1) * jitter_factor)
99
+ timeout_with_jitter = @config[:reset_timeout] * jitter_multiplier
100
+
101
+ monotonic_time - @opened_at.value >= timeout_with_jitter
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'state_machines'
4
+ require 'concurrent-ruby'
5
+
6
+ module BreakerMachines
7
+ class Circuit
8
+ include StateManagement
9
+ include Configuration
10
+ include Execution
11
+ include Introspection
12
+ include Callbacks
13
+ end
14
+ end