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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1894 -0
- data/lib/breaker_machines/circuit/callbacks.rb +132 -0
- data/lib/breaker_machines/circuit/configuration.rb +73 -0
- data/lib/breaker_machines/circuit/execution.rb +211 -0
- data/lib/breaker_machines/circuit/introspection.rb +77 -0
- data/lib/breaker_machines/circuit/state_management.rb +105 -0
- data/lib/breaker_machines/circuit.rb +14 -0
- data/lib/breaker_machines/console.rb +345 -0
- data/lib/breaker_machines/dsl.rb +274 -0
- data/lib/breaker_machines/errors.rb +30 -0
- data/lib/breaker_machines/registry.rb +99 -0
- data/lib/breaker_machines/storage/base.rb +47 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +163 -0
- data/lib/breaker_machines/storage/memory.rb +127 -0
- data/lib/breaker_machines/storage/null.rb +45 -0
- data/lib/breaker_machines/storage.rb +8 -0
- data/lib/breaker_machines/version.rb +5 -0
- data/lib/breaker_machines.rb +82 -0
- data/sig/README.md +74 -0
- data/sig/all.rbs +25 -0
- data/sig/breaker_machines/circuit.rbs +154 -0
- data/sig/breaker_machines/console.rbs +32 -0
- data/sig/breaker_machines/dsl.rbs +50 -0
- data/sig/breaker_machines/errors.rbs +24 -0
- data/sig/breaker_machines/interfaces.rbs +46 -0
- data/sig/breaker_machines/registry.rbs +30 -0
- data/sig/breaker_machines/storage.rbs +65 -0
- data/sig/breaker_machines/types.rbs +97 -0
- data/sig/breaker_machines.rbs +30 -0
- data/sig/manifest.yaml +5 -0
- metadata +167 -0
@@ -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
|