breaker_machines 0.2.1 → 0.4.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.
@@ -16,10 +16,10 @@ module BreakerMachines
16
16
  data = @cache.read(status_key(circuit_name))
17
17
  return nil unless data
18
18
 
19
- {
19
+ BreakerMachines::Status.new(
20
20
  status: data[:status].to_sym,
21
21
  opened_at: data[:opened_at]
22
- }
22
+ )
23
23
  end
24
24
 
25
25
  def set_status(circuit_name, status, opened_at = nil)
@@ -94,6 +94,13 @@ module BreakerMachines
94
94
  events.last(limit)
95
95
  end
96
96
 
97
+ def with_timeout(_timeout_ms)
98
+ # Rails cache operations should rely on their own underlying timeouts
99
+ # Using Ruby's Timeout.timeout is dangerous and can cause deadlocks
100
+ # For Redis cache stores, configure connect_timeout and read_timeout instead
101
+ yield
102
+ end
103
+
97
104
  private
98
105
 
99
106
  def increment_counter(key)
@@ -155,7 +162,7 @@ module BreakerMachines
155
162
  end
156
163
 
157
164
  def monotonic_time
158
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ BreakerMachines.monotonic_time
159
166
  end
160
167
  end
161
168
  end
@@ -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
@@ -6,6 +6,10 @@ require 'concurrent/array'
6
6
  module BreakerMachines
7
7
  module Storage
8
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.
9
13
  class Memory < Base
10
14
  def initialize(**options)
11
15
  super
@@ -19,10 +23,10 @@ module BreakerMachines
19
23
  circuit_data = @circuits[circuit_name]
20
24
  return nil unless circuit_data
21
25
 
22
- {
26
+ BreakerMachines::Status.new(
23
27
  status: circuit_data[:status],
24
28
  opened_at: circuit_data[:opened_at]
25
- }
29
+ )
26
30
  end
27
31
 
28
32
  def set_status(circuit_name, status, opened_at = nil)
@@ -87,6 +91,12 @@ module BreakerMachines
87
91
  events.last(limit).map(&:dup)
88
92
  end
89
93
 
94
+ def with_timeout(_timeout_ms)
95
+ # Memory operations should be instant, but we'll still respect the timeout
96
+ # This is more for consistency and to catch any potential deadlocks
97
+ yield
98
+ end
99
+
90
100
  private
91
101
 
92
102
  def record_event(circuit_name, type, duration)
@@ -120,7 +130,7 @@ module BreakerMachines
120
130
  end
121
131
 
122
132
  def monotonic_time
123
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ BreakerMachines.monotonic_time
124
134
  end
125
135
  end
126
136
  end
@@ -40,6 +40,15 @@ module BreakerMachines
40
40
  def record_event_with_details(_circuit_name, _event_type, _duration, _metadata = {})
41
41
  # No-op
42
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
43
52
  end
44
53
  end
45
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BreakerMachines
4
- VERSION = '0.2.1'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -3,11 +3,14 @@
3
3
  require 'zeitwerk'
4
4
  require 'active_support'
5
5
  require 'active_support/core_ext'
6
+ require 'state_machines'
6
7
  require_relative 'breaker_machines/errors'
8
+ require_relative 'breaker_machines/types'
7
9
 
8
10
  loader = Zeitwerk::Loader.for_gem
9
11
  loader.inflector.inflect('dsl' => 'DSL')
10
12
  loader.ignore("#{__dir__}/breaker_machines/errors.rb")
13
+ loader.ignore("#{__dir__}/breaker_machines/types.rb")
11
14
  loader.ignore("#{__dir__}/breaker_machines/console.rb")
12
15
  loader.ignore("#{__dir__}/breaker_machines/async_support.rb")
13
16
  loader.ignore("#{__dir__}/breaker_machines/hedged_async_support.rb")
@@ -77,6 +80,15 @@ module BreakerMachines
77
80
  def registry
78
81
  Registry.instance
79
82
  end
83
+
84
+ # Returns the current monotonic time in seconds.
85
+ # Monotonic time is guaranteed to always increase and is not affected
86
+ # by system clock adjustments, making it ideal for measuring durations.
87
+ #
88
+ # @return [Float] current monotonic time in seconds
89
+ def monotonic_time
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ end
80
92
  end
81
93
 
82
94
  # Set up notifications on first use
data/sig/README.md CHANGED
@@ -24,7 +24,7 @@ To use these type signatures in your project:
24
24
  target :app do
25
25
  signature "sig"
26
26
  check "lib"
27
-
27
+
28
28
  library "breaker_machines"
29
29
  end
30
30
  ```
@@ -38,7 +38,7 @@ To use these type signatures in your project:
38
38
 
39
39
  ### Basic Circuit Usage
40
40
  ```ruby
41
- circuit = BreakerMachines::Circuit.new("api",
41
+ circuit = BreakerMachines::Circuit.new("api",
42
42
  failure_threshold: 5,
43
43
  reset_timeout: 30
44
44
  )
@@ -50,7 +50,7 @@ result = circuit.call { api.fetch_data }
50
50
  ```ruby
51
51
  class MyService
52
52
  include BreakerMachines::DSL
53
-
53
+
54
54
  circuit :database do
55
55
  threshold failures: 10, within: 60
56
56
  reset_after 120
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: breaker_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: concurrent-ruby
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.31.0
46
+ version: 0.50.0
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.31.0
53
+ version: 0.50.0
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -109,6 +109,7 @@ files:
109
109
  - README.md
110
110
  - lib/breaker_machines.rb
111
111
  - lib/breaker_machines/async_support.rb
112
+ - lib/breaker_machines/cascading_circuit.rb
112
113
  - lib/breaker_machines/circuit.rb
113
114
  - lib/breaker_machines/circuit/callbacks.rb
114
115
  - lib/breaker_machines/circuit/configuration.rb
@@ -117,16 +118,23 @@ files:
117
118
  - lib/breaker_machines/circuit/state_management.rb
118
119
  - lib/breaker_machines/console.rb
119
120
  - lib/breaker_machines/dsl.rb
121
+ - lib/breaker_machines/dsl/cascading_circuit_builder.rb
122
+ - lib/breaker_machines/dsl/circuit_builder.rb
123
+ - lib/breaker_machines/dsl/hedged_builder.rb
124
+ - lib/breaker_machines/dsl/parallel_fallback_wrapper.rb
120
125
  - lib/breaker_machines/errors.rb
121
126
  - lib/breaker_machines/hedged_async_support.rb
122
127
  - lib/breaker_machines/hedged_execution.rb
123
128
  - lib/breaker_machines/registry.rb
124
129
  - lib/breaker_machines/storage.rb
130
+ - lib/breaker_machines/storage/backend_state.rb
125
131
  - lib/breaker_machines/storage/base.rb
126
132
  - lib/breaker_machines/storage/bucket_memory.rb
127
133
  - lib/breaker_machines/storage/cache.rb
134
+ - lib/breaker_machines/storage/fallback_chain.rb
128
135
  - lib/breaker_machines/storage/memory.rb
129
136
  - lib/breaker_machines/storage/null.rb
137
+ - lib/breaker_machines/types.rb
130
138
  - lib/breaker_machines/version.rb
131
139
  - sig/README.md
132
140
  - sig/all.rbs