breaker_machines 0.9.2-arm64-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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/ext/breaker_machines_native/extconf.rb +3 -0
- data/lib/breaker_machines/async_circuit.rb +47 -0
- data/lib/breaker_machines/async_support.rb +104 -0
- data/lib/breaker_machines/cascading_circuit.rb +177 -0
- data/lib/breaker_machines/circuit/async_state_management.rb +71 -0
- data/lib/breaker_machines/circuit/base.rb +59 -0
- data/lib/breaker_machines/circuit/callbacks.rb +135 -0
- data/lib/breaker_machines/circuit/configuration.rb +67 -0
- data/lib/breaker_machines/circuit/coordinated_state_management.rb +117 -0
- data/lib/breaker_machines/circuit/execution.rb +231 -0
- data/lib/breaker_machines/circuit/hedged_execution.rb +115 -0
- data/lib/breaker_machines/circuit/introspection.rb +93 -0
- data/lib/breaker_machines/circuit/native.rb +127 -0
- data/lib/breaker_machines/circuit/state_callbacks.rb +72 -0
- data/lib/breaker_machines/circuit/state_management.rb +59 -0
- data/lib/breaker_machines/circuit.rb +8 -0
- data/lib/breaker_machines/circuit_group.rb +153 -0
- data/lib/breaker_machines/console.rb +345 -0
- data/lib/breaker_machines/coordinated_circuit.rb +10 -0
- data/lib/breaker_machines/dsl/cascading_circuit_builder.rb +20 -0
- data/lib/breaker_machines/dsl/circuit_builder.rb +209 -0
- data/lib/breaker_machines/dsl/hedged_builder.rb +21 -0
- data/lib/breaker_machines/dsl/parallel_fallback_wrapper.rb +20 -0
- data/lib/breaker_machines/dsl.rb +283 -0
- data/lib/breaker_machines/errors.rb +71 -0
- data/lib/breaker_machines/hedged_async_support.rb +88 -0
- data/lib/breaker_machines/native_extension.rb +81 -0
- data/lib/breaker_machines/native_speedup.rb +10 -0
- data/lib/breaker_machines/registry.rb +243 -0
- data/lib/breaker_machines/storage/backend_state.rb +69 -0
- data/lib/breaker_machines/storage/base.rb +52 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +176 -0
- data/lib/breaker_machines/storage/cache.rb +169 -0
- data/lib/breaker_machines/storage/fallback_chain.rb +294 -0
- data/lib/breaker_machines/storage/memory.rb +140 -0
- data/lib/breaker_machines/storage/native.rb +93 -0
- data/lib/breaker_machines/storage/null.rb +54 -0
- data/lib/breaker_machines/storage.rb +8 -0
- data/lib/breaker_machines/types.rb +41 -0
- data/lib/breaker_machines/version.rb +5 -0
- data/lib/breaker_machines.rb +200 -0
- data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -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 +42 -0
- data/sig/manifest.yaml +5 -0
- metadata +227 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
module Storage
|
|
5
|
+
# Apocalypse-resistant storage backend that tries multiple storage backends in sequence
|
|
6
|
+
# Falls back to the next storage backend when the current one times out or fails
|
|
7
|
+
#
|
|
8
|
+
# NOTE: For DRb (distributed Ruby) environments, only :cache backend with external
|
|
9
|
+
# cache stores (Redis, Memcached) will work properly. Memory-based backends (:memory,
|
|
10
|
+
# :bucket_memory) are incompatible with DRb as they don't share state between processes.
|
|
11
|
+
class FallbackChain < Base
|
|
12
|
+
attr_reader :storage_configs, :storage_instances, :backend_states
|
|
13
|
+
|
|
14
|
+
def initialize(storage_configs, circuit_breaker_threshold: 3, circuit_breaker_timeout: 30, **)
|
|
15
|
+
super(**)
|
|
16
|
+
@storage_configs = normalize_storage_configs(storage_configs)
|
|
17
|
+
@storage_instances = {}
|
|
18
|
+
@circuit_breaker_threshold = circuit_breaker_threshold
|
|
19
|
+
@circuit_breaker_timeout = circuit_breaker_timeout
|
|
20
|
+
@backend_states = @storage_configs.to_h do |config|
|
|
21
|
+
[config[:backend],
|
|
22
|
+
BackendState.new(config[:backend], threshold: @circuit_breaker_threshold, timeout: @circuit_breaker_timeout)]
|
|
23
|
+
end
|
|
24
|
+
validate_configs!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_status(circuit_name)
|
|
28
|
+
execute_with_fallback(:get_status, circuit_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set_status(circuit_name, status, opened_at = nil)
|
|
32
|
+
execute_with_fallback(:set_status, circuit_name, status, opened_at)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record_success(circuit_name, duration)
|
|
36
|
+
execute_with_fallback(:record_success, circuit_name, duration)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_failure(circuit_name, duration)
|
|
40
|
+
execute_with_fallback(:record_failure, circuit_name, duration)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def success_count(circuit_name, window_seconds)
|
|
44
|
+
execute_with_fallback(:success_count, circuit_name, window_seconds)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def failure_count(circuit_name, window_seconds)
|
|
48
|
+
execute_with_fallback(:failure_count, circuit_name, window_seconds)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clear(circuit_name)
|
|
52
|
+
execute_with_fallback(:clear, circuit_name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clear_all
|
|
56
|
+
execute_with_fallback(:clear_all)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_event_with_details(circuit_name, type, duration, error: nil, new_state: nil)
|
|
60
|
+
execute_with_fallback(:record_event_with_details, circuit_name, type, duration, error: error,
|
|
61
|
+
new_state: new_state)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def event_log(circuit_name, limit)
|
|
65
|
+
execute_with_fallback(:event_log, circuit_name, limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def with_timeout(_timeout_ms)
|
|
69
|
+
# FallbackChain doesn't use timeout directly - each backend handles its own
|
|
70
|
+
yield
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cleanup!
|
|
74
|
+
storage_instances.each_value do |instance|
|
|
75
|
+
instance.clear_all if instance.respond_to?(:clear_all)
|
|
76
|
+
end
|
|
77
|
+
storage_instances.clear
|
|
78
|
+
backend_states.each_value(&:reset)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def execute_with_fallback(method, *args, **kwargs)
|
|
84
|
+
chain_started_at = BreakerMachines.monotonic_time
|
|
85
|
+
attempted_backends = []
|
|
86
|
+
|
|
87
|
+
storage_configs.each_with_index do |config, index|
|
|
88
|
+
backend_type = config[:backend]
|
|
89
|
+
attempted_backends << backend_type
|
|
90
|
+
backend_state = backend_states[backend_type]
|
|
91
|
+
|
|
92
|
+
if backend_state.unhealthy_due_to_timeout?
|
|
93
|
+
emit_backend_skipped_notification(backend_type, method, index)
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
backend = get_backend_instance(backend_type)
|
|
99
|
+
started_at = BreakerMachines.monotonic_time
|
|
100
|
+
|
|
101
|
+
result = backend.with_timeout(config[:timeout]) do
|
|
102
|
+
if kwargs.any?
|
|
103
|
+
backend.send(method, *args, **kwargs)
|
|
104
|
+
else
|
|
105
|
+
backend.send(method, *args)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
duration_ms = ((BreakerMachines.monotonic_time - started_at) * 1000).round(2)
|
|
110
|
+
emit_operation_success_notification(backend_type, method, duration_ms, index)
|
|
111
|
+
reset_backend_failures(backend_type)
|
|
112
|
+
|
|
113
|
+
chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
|
|
114
|
+
emit_chain_success_notification(method, attempted_backends, backend_type, chain_duration_ms)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
rescue BreakerMachines::StorageTimeoutError, BreakerMachines::StorageError, StandardError => e
|
|
118
|
+
duration_ms = ((BreakerMachines.monotonic_time - started_at) * 1000).round(2)
|
|
119
|
+
record_backend_failure(backend_type, e, duration_ms)
|
|
120
|
+
emit_fallback_notification(backend_type, e, duration_ms, index)
|
|
121
|
+
|
|
122
|
+
if index == storage_configs.size - 1
|
|
123
|
+
chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
|
|
124
|
+
emit_chain_failure_notification(method, attempted_backends, chain_duration_ms)
|
|
125
|
+
raise e
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
chain_duration_ms = ((BreakerMachines.monotonic_time - chain_started_at) * 1000).round(2)
|
|
133
|
+
emit_chain_failure_notification(method, attempted_backends, chain_duration_ms)
|
|
134
|
+
raise BreakerMachines::StorageError, 'All storage backends are unhealthy'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def get_backend_instance(backend_type)
|
|
138
|
+
storage_instances[backend_type] ||= create_backend_instance(backend_type)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def create_backend_instance(backend_type)
|
|
142
|
+
case backend_type
|
|
143
|
+
when :memory
|
|
144
|
+
Memory.new
|
|
145
|
+
when :bucket_memory
|
|
146
|
+
BucketMemory.new
|
|
147
|
+
when :cache
|
|
148
|
+
Cache.new
|
|
149
|
+
when :null
|
|
150
|
+
Null.new
|
|
151
|
+
else
|
|
152
|
+
# Allow custom backend classes
|
|
153
|
+
raise ConfigurationError, "Unknown storage backend: #{backend_type}" unless backend_type.is_a?(Class)
|
|
154
|
+
|
|
155
|
+
backend_type.new
|
|
156
|
+
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def record_backend_failure(backend_type, _error, _duration_ms)
|
|
161
|
+
backend_state = backend_states[backend_type]
|
|
162
|
+
return unless backend_state
|
|
163
|
+
|
|
164
|
+
previous_health = backend_state.health_name
|
|
165
|
+
backend_state.record_failure
|
|
166
|
+
new_health = backend_state.health_name
|
|
167
|
+
|
|
168
|
+
if new_health != previous_health
|
|
169
|
+
emit_backend_health_change_notification(backend_type, previous_health, new_health,
|
|
170
|
+
backend_state.failure_count)
|
|
171
|
+
end
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
# Don't let failure recording cause the whole chain to hang
|
|
174
|
+
Rails.logger&.error("FallbackChain: Failed to record backend failure: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reset_backend_failures(backend_type)
|
|
178
|
+
backend_state = backend_states[backend_type]
|
|
179
|
+
return unless backend_state&.unhealthy?
|
|
180
|
+
|
|
181
|
+
previous_health = backend_state.health_name
|
|
182
|
+
backend_state.reset
|
|
183
|
+
new_health = backend_state.health_name
|
|
184
|
+
|
|
185
|
+
return unless new_health != previous_health
|
|
186
|
+
|
|
187
|
+
emit_backend_health_change_notification(backend_type, previous_health, new_health, 0)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def emit_fallback_notification(backend_type, error, duration_ms, backend_index)
|
|
191
|
+
ActiveSupport::Notifications.instrument(
|
|
192
|
+
'storage_fallback.breaker_machines',
|
|
193
|
+
backend: backend_type,
|
|
194
|
+
error_class: error.class.name,
|
|
195
|
+
error_message: error.message,
|
|
196
|
+
duration_ms: duration_ms,
|
|
197
|
+
backend_index: backend_index,
|
|
198
|
+
next_backend: storage_configs[backend_index + 1]&.dig(:backend)
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def emit_operation_success_notification(backend_type, method, duration_ms, backend_index)
|
|
203
|
+
ActiveSupport::Notifications.instrument(
|
|
204
|
+
'storage_operation.breaker_machines',
|
|
205
|
+
backend: backend_type,
|
|
206
|
+
operation: method,
|
|
207
|
+
duration_ms: duration_ms,
|
|
208
|
+
backend_index: backend_index,
|
|
209
|
+
success: true
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def emit_backend_skipped_notification(backend_type, method, backend_index)
|
|
214
|
+
backend_state = backend_states[backend_type]
|
|
215
|
+
ActiveSupport::Notifications.instrument(
|
|
216
|
+
'storage_backend_skipped.breaker_machines',
|
|
217
|
+
backend: backend_type,
|
|
218
|
+
operation: method,
|
|
219
|
+
backend_index: backend_index,
|
|
220
|
+
reason: 'unhealthy',
|
|
221
|
+
unhealthy_until: backend_state&.instance_variable_get(:@unhealthy_until)
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def emit_backend_health_change_notification(backend_type, previous_state, new_state, failure_count)
|
|
226
|
+
backend_state = backend_states[backend_type]
|
|
227
|
+
ActiveSupport::Notifications.instrument(
|
|
228
|
+
'storage_backend_health.breaker_machines',
|
|
229
|
+
backend: backend_type,
|
|
230
|
+
previous_state: previous_state,
|
|
231
|
+
new_state: new_state,
|
|
232
|
+
failure_count: failure_count,
|
|
233
|
+
threshold: backend_state&.instance_variable_get(:@threshold),
|
|
234
|
+
recovery_time: new_state == :unhealthy ? backend_state&.instance_variable_get(:@unhealthy_until) : nil
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def emit_chain_success_notification(method, attempted_backends, successful_backend, duration_ms)
|
|
239
|
+
ActiveSupport::Notifications.instrument(
|
|
240
|
+
'storage_chain_operation.breaker_machines',
|
|
241
|
+
operation: method,
|
|
242
|
+
attempted_backends: attempted_backends,
|
|
243
|
+
successful_backend: successful_backend,
|
|
244
|
+
duration_ms: duration_ms,
|
|
245
|
+
success: true,
|
|
246
|
+
fallback_count: attempted_backends.index(successful_backend)
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def emit_chain_failure_notification(method, attempted_backends, duration_ms)
|
|
251
|
+
ActiveSupport::Notifications.instrument(
|
|
252
|
+
'storage_chain_operation.breaker_machines',
|
|
253
|
+
operation: method,
|
|
254
|
+
attempted_backends: attempted_backends,
|
|
255
|
+
successful_backend: nil,
|
|
256
|
+
duration_ms: duration_ms,
|
|
257
|
+
success: false,
|
|
258
|
+
fallback_count: attempted_backends.size
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def normalize_storage_configs(configs)
|
|
263
|
+
return configs if configs.is_a?(Array)
|
|
264
|
+
|
|
265
|
+
# Convert hash format to array format
|
|
266
|
+
unless configs.is_a?(Hash)
|
|
267
|
+
raise ConfigurationError, "Storage configs must be Array or Hash, got: #{configs.class}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
configs.map do |_key, value|
|
|
271
|
+
if value.is_a?(Hash)
|
|
272
|
+
value
|
|
273
|
+
else
|
|
274
|
+
{ backend: value, timeout: 5 }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate_configs!
|
|
280
|
+
raise ConfigurationError, 'Storage configs cannot be empty' if storage_configs.empty?
|
|
281
|
+
|
|
282
|
+
storage_configs.each_with_index do |config, index|
|
|
283
|
+
unless config.is_a?(Hash) && config[:backend] && config[:timeout]
|
|
284
|
+
raise ConfigurationError, "Invalid storage config at index #{index}: #{config}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
unless config[:timeout].is_a?(Numeric) && config[:timeout].positive?
|
|
288
|
+
raise ConfigurationError, "Timeout must be a positive number, got: #{config[:timeout]}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
#
|
|
10
|
+
# WARNING: This storage backend is NOT compatible with DRb (distributed Ruby)
|
|
11
|
+
# environments as memory is not shared between processes. Use Cache backend
|
|
12
|
+
# with an external cache store (Redis, Memcached) for distributed setups.
|
|
13
|
+
class Memory < Base
|
|
14
|
+
def initialize(**options)
|
|
15
|
+
super
|
|
16
|
+
@circuits = Concurrent::Map.new
|
|
17
|
+
@events = Concurrent::Map.new
|
|
18
|
+
@event_logs = Concurrent::Map.new
|
|
19
|
+
@max_events = options[:max_events] || 100
|
|
20
|
+
# Store creation time as anchor for relative timestamps (like Rust implementation)
|
|
21
|
+
@start_time = BreakerMachines.monotonic_time
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get_status(circuit_name)
|
|
25
|
+
circuit_data = @circuits[circuit_name]
|
|
26
|
+
return nil unless circuit_data
|
|
27
|
+
|
|
28
|
+
BreakerMachines::Status.new(
|
|
29
|
+
status: circuit_data[:status],
|
|
30
|
+
opened_at: circuit_data[:opened_at]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def set_status(circuit_name, status, opened_at = nil)
|
|
35
|
+
@circuits[circuit_name] = {
|
|
36
|
+
status: status,
|
|
37
|
+
opened_at: opened_at,
|
|
38
|
+
updated_at: monotonic_time
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def record_success(circuit_name, duration)
|
|
43
|
+
record_event(circuit_name, :success, duration)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_failure(circuit_name, duration)
|
|
47
|
+
record_event(circuit_name, :failure, duration)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def success_count(circuit_name, window_seconds)
|
|
51
|
+
count_events(circuit_name, :success, window_seconds)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure_count(circuit_name, window_seconds)
|
|
55
|
+
count_events(circuit_name, :failure, window_seconds)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear(circuit_name)
|
|
59
|
+
@circuits.delete(circuit_name)
|
|
60
|
+
@events.delete(circuit_name)
|
|
61
|
+
@event_logs.delete(circuit_name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear_all
|
|
65
|
+
@circuits.clear
|
|
66
|
+
@events.clear
|
|
67
|
+
@event_logs.clear
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record_event_with_details(circuit_name, type, duration, error: nil, new_state: nil)
|
|
71
|
+
events = @event_logs.compute_if_absent(circuit_name) { Concurrent::Array.new }
|
|
72
|
+
|
|
73
|
+
event = {
|
|
74
|
+
type: type,
|
|
75
|
+
timestamp: monotonic_time,
|
|
76
|
+
duration_ms: (duration * 1000).round(2)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
event[:error_class] = error.class.name if error
|
|
80
|
+
event[:error_message] = error.message if error
|
|
81
|
+
event[:new_state] = new_state if new_state
|
|
82
|
+
|
|
83
|
+
events << event
|
|
84
|
+
|
|
85
|
+
# Keep only the most recent events
|
|
86
|
+
events.shift while events.size > @max_events
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def event_log(circuit_name, limit)
|
|
90
|
+
events = @event_logs[circuit_name]
|
|
91
|
+
return [] unless events
|
|
92
|
+
|
|
93
|
+
events.last(limit).map(&:dup)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def with_timeout(_timeout_ms)
|
|
97
|
+
# Memory operations should be instant, but we'll still respect the timeout
|
|
98
|
+
# This is more for consistency and to catch any potential deadlocks
|
|
99
|
+
yield
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def record_event(circuit_name, type, duration)
|
|
105
|
+
# Initialize if needed
|
|
106
|
+
@events.compute_if_absent(circuit_name) { Concurrent::Array.new }
|
|
107
|
+
|
|
108
|
+
# Get the array and add event
|
|
109
|
+
events = @events[circuit_name]
|
|
110
|
+
current_time = monotonic_time
|
|
111
|
+
|
|
112
|
+
# Add new event
|
|
113
|
+
events << {
|
|
114
|
+
type: type,
|
|
115
|
+
duration: duration,
|
|
116
|
+
timestamp: current_time
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Clean old events periodically (every 100 events)
|
|
120
|
+
return unless events.size > 100
|
|
121
|
+
|
|
122
|
+
cutoff_time = current_time - 300 # Keep 5 minutes of history
|
|
123
|
+
events.delete_if { |e| e[:timestamp] < cutoff_time }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def count_events(circuit_name, type, window_seconds)
|
|
127
|
+
events = @events[circuit_name]
|
|
128
|
+
return 0 unless events
|
|
129
|
+
|
|
130
|
+
cutoff_time = monotonic_time - window_seconds
|
|
131
|
+
events.count { |e| e[:type] == type && e[:timestamp] >= cutoff_time }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def monotonic_time
|
|
135
|
+
# Return time relative to storage creation (matches Rust implementation)
|
|
136
|
+
BreakerMachines.monotonic_time - @start_time
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
module Storage
|
|
5
|
+
# Native extension storage backend with graceful fallback to pure Ruby
|
|
6
|
+
#
|
|
7
|
+
# This backend provides identical functionality to Memory storage but with
|
|
8
|
+
# significantly better performance for sliding window calculations when the
|
|
9
|
+
# native extension is available. If the native extension isn't available
|
|
10
|
+
# (e.g., on JRuby or if Rust wasn't installed), it automatically falls back
|
|
11
|
+
# to the pure Ruby Memory storage backend.
|
|
12
|
+
#
|
|
13
|
+
# Performance: ~63x faster than Memory storage when native extension is available
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# BreakerMachines.configure do |config|
|
|
17
|
+
# config.default_storage = :native
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# FFI Hybrid Pattern: This class is only loaded when native extension is available
|
|
21
|
+
# Fallback happens at load time (native_speedup.rb), not at runtime
|
|
22
|
+
class Native < Base
|
|
23
|
+
def initialize(**options)
|
|
24
|
+
super
|
|
25
|
+
# Native extension is guaranteed to be available when this class is loaded
|
|
26
|
+
@backend = BreakerMachinesNative::Storage.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if using native backend
|
|
30
|
+
# @return [Boolean] always true - this class only exists when native is available
|
|
31
|
+
def native?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_status(_circuit_name)
|
|
36
|
+
# Status is still managed by Ruby layer
|
|
37
|
+
# This storage backend only handles event tracking
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_status(circuit_name, status, opened_at = nil)
|
|
42
|
+
# Status management delegated to Ruby layer
|
|
43
|
+
# This backend focuses on high-performance event counting
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_success(circuit_name, duration)
|
|
47
|
+
@backend.record_success(circuit_name.to_s, duration)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_failure(circuit_name, duration)
|
|
51
|
+
@backend.record_failure(circuit_name.to_s, duration)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def success_count(circuit_name, window_seconds)
|
|
55
|
+
@backend.success_count(circuit_name.to_s, window_seconds)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def failure_count(circuit_name, window_seconds)
|
|
59
|
+
@backend.failure_count(circuit_name.to_s, window_seconds)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear(circuit_name)
|
|
63
|
+
@backend.clear(circuit_name.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear_all
|
|
67
|
+
@backend.clear_all
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record_event_with_details(circuit_name, type, duration, error: nil, new_state: nil)
|
|
71
|
+
# Basic event recording (native extension handles type and duration)
|
|
72
|
+
case type
|
|
73
|
+
when :success
|
|
74
|
+
record_success(circuit_name, duration)
|
|
75
|
+
when :failure
|
|
76
|
+
record_failure(circuit_name, duration)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# NOTE: Error and state details not tracked in native backend
|
|
80
|
+
# This is intentional for performance - use Memory backend if you need full event details
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def event_log(circuit_name, limit)
|
|
84
|
+
@backend.event_log(circuit_name.to_s, limit)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def with_timeout(_timeout_ms)
|
|
88
|
+
# Native operations should be instant
|
|
89
|
+
yield
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
44
|
+
def clear_all
|
|
45
|
+
# No-op
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_timeout(_timeout_ms)
|
|
49
|
+
# Null storage always succeeds instantly - perfect for fail-open scenarios
|
|
50
|
+
yield
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BreakerMachines
|
|
4
|
+
# Represents the status of a circuit from storage
|
|
5
|
+
# @return [Symbol] status - the circuit status (:open, :closed, :half_open)
|
|
6
|
+
# @return [Float, nil] opened_at - the monotonic time when the circuit was opened
|
|
7
|
+
Status = Data.define(:status, :opened_at)
|
|
8
|
+
|
|
9
|
+
# Represents statistical information about a circuit
|
|
10
|
+
Stats = Data.define(
|
|
11
|
+
:state,
|
|
12
|
+
:failure_count,
|
|
13
|
+
:success_count,
|
|
14
|
+
:last_failure_at,
|
|
15
|
+
:opened_at,
|
|
16
|
+
:half_open_attempts,
|
|
17
|
+
:half_open_successes
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Represents information about the last error that occurred
|
|
21
|
+
# @return [String] error_class - the error class name
|
|
22
|
+
# @return [String] message - the error message
|
|
23
|
+
# @return [Float, nil] occurred_at - the monotonic time when the error occurred
|
|
24
|
+
ErrorInfo = Data.define(:error_class, :message, :occurred_at)
|
|
25
|
+
|
|
26
|
+
# Represents cascade information for cascading circuits
|
|
27
|
+
CascadeInfo = Data.define(
|
|
28
|
+
:dependent_circuits,
|
|
29
|
+
:emergency_protocol,
|
|
30
|
+
:cascade_triggered_at,
|
|
31
|
+
:dependent_status
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Represents an event in the circuit's event log
|
|
35
|
+
# @return [Symbol] type - the event type (:success, :failure, :state_change)
|
|
36
|
+
# @return [Float] timestamp - the monotonic timestamp
|
|
37
|
+
# @return [Float] duration - the duration in milliseconds
|
|
38
|
+
# @return [String, nil] error - the error message if applicable
|
|
39
|
+
# @return [Symbol, nil] new_state - the new state if this was a state change
|
|
40
|
+
Event = Data.define(:type, :timestamp, :duration, :error, :new_state)
|
|
41
|
+
end
|