breaker_machines 0.5.0 → 0.6.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 +4 -4
- data/ext/breaker_machines_native/Cargo.toml +8 -0
- data/ext/breaker_machines_native/core/Cargo.toml +18 -0
- data/ext/breaker_machines_native/core/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/core/src/builder.rs +232 -0
- data/ext/breaker_machines_native/core/src/bulkhead.rs +223 -0
- data/ext/breaker_machines_native/core/src/callbacks.rs +58 -0
- data/ext/breaker_machines_native/core/src/circuit.rs +1156 -0
- data/ext/breaker_machines_native/core/src/classifier.rs +177 -0
- data/ext/breaker_machines_native/core/src/errors.rs +47 -0
- data/ext/breaker_machines_native/core/src/lib.rs +62 -0
- data/ext/breaker_machines_native/core/src/storage.rs +377 -0
- data/ext/breaker_machines_native/extconf.rb +40 -0
- data/ext/breaker_machines_native/ffi/Cargo.toml +16 -0
- data/ext/breaker_machines_native/ffi/src/lib.rs +218 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/common.rs +355 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/dynamic.rs +276 -0
- data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/macros.rs +49 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-2bb7281aac8faec8/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-54cb99ea6aeab8bc/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-9e64a270c6421e93/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/debug/build/rb-sys-e627030114d3fc19/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/builder.rs +154 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/circuit.rs +607 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/builder.rs +173 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/circuit.rs +855 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/builder.rs +154 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/callbacks.rs +55 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/circuit.rs +607 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/errors.rs +38 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/lib.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/Cargo.toml +48 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/examples/basic.rs +61 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/builder.rs +232 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/bulkhead.rs +223 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/callbacks.rs +58 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/circuit.rs +1156 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/classifier.rs +177 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/errors.rs +47 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/lib.rs +62 -0
- data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/storage.rs +377 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/common.rs +355 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/dynamic.rs +276 -0
- data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/macros.rs +49 -0
- data/ext/breaker_machines_native/target/release/build/rb-sys-064bf9961dd17810/out/bindings-0.9.117-mri-arm64-darwin24-3.4.7.rs +8936 -0
- data/lib/breaker_machines/circuit/async_state_management.rb +1 -1
- data/lib/breaker_machines/circuit/base.rb +2 -1
- data/lib/breaker_machines/circuit/coordinated_state_management.rb +3 -3
- data/lib/breaker_machines/circuit/native.rb +127 -0
- data/lib/breaker_machines/circuit/state_callbacks.rb +17 -5
- data/lib/breaker_machines/circuit/state_management.rb +1 -1
- data/lib/breaker_machines/native_extension.rb +36 -0
- data/lib/breaker_machines/native_speedup.rb +6 -0
- data/lib/breaker_machines/storage/bucket_memory.rb +4 -1
- data/lib/breaker_machines/storage/memory.rb +4 -1
- data/lib/breaker_machines/storage/native.rb +90 -0
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +98 -11
- data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -0
- data/sig/breaker_machines.rbs +20 -8
- metadata +99 -6
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
//! Circuit breaker implementation using state machines
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides a complete circuit breaker with state management.
|
|
4
|
+
|
|
5
|
+
use crate::{callbacks::Callbacks, errors::CircuitError, StorageBackend};
|
|
6
|
+
use state_machines::state_machine;
|
|
7
|
+
use std::sync::Arc;
|
|
8
|
+
|
|
9
|
+
/// Circuit breaker configuration
|
|
10
|
+
#[derive(Debug, Clone, Copy)]
|
|
11
|
+
pub struct Config {
|
|
12
|
+
/// Number of failures required to open the circuit
|
|
13
|
+
pub failure_threshold: usize,
|
|
14
|
+
|
|
15
|
+
/// Time window in seconds for counting failures
|
|
16
|
+
pub failure_window_secs: f64,
|
|
17
|
+
|
|
18
|
+
/// Timeout in seconds before transitioning from Open to HalfOpen
|
|
19
|
+
pub half_open_timeout_secs: f64,
|
|
20
|
+
|
|
21
|
+
/// Number of successes required in HalfOpen to close the circuit
|
|
22
|
+
pub success_threshold: usize,
|
|
23
|
+
|
|
24
|
+
/// Jitter factor for half_open_timeout (0.0 = no jitter, 1.0 = full jitter)
|
|
25
|
+
/// Uses chrono-machines formula: timeout * (1 - jitter + rand * jitter)
|
|
26
|
+
pub jitter_factor: f64,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl Default for Config {
|
|
30
|
+
fn default() -> Self {
|
|
31
|
+
Self {
|
|
32
|
+
failure_threshold: 5,
|
|
33
|
+
failure_window_secs: 60.0,
|
|
34
|
+
half_open_timeout_secs: 30.0,
|
|
35
|
+
success_threshold: 2,
|
|
36
|
+
jitter_factor: 0.0,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Circuit breaker context - shared data across all states
|
|
42
|
+
#[derive(Clone)]
|
|
43
|
+
pub struct CircuitContext {
|
|
44
|
+
pub name: String,
|
|
45
|
+
pub config: Config,
|
|
46
|
+
pub storage: Arc<dyn StorageBackend>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
impl Default for CircuitContext {
|
|
50
|
+
fn default() -> Self {
|
|
51
|
+
Self {
|
|
52
|
+
name: String::new(),
|
|
53
|
+
config: Config::default(),
|
|
54
|
+
storage: Arc::new(crate::MemoryStorage::new()),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
impl std::fmt::Debug for CircuitContext {
|
|
60
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
61
|
+
f.debug_struct("CircuitContext")
|
|
62
|
+
.field("name", &self.name)
|
|
63
|
+
.field("config", &self.config)
|
|
64
|
+
.field("storage", &"<dyn StorageBackend>")
|
|
65
|
+
.finish()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Data specific to the Open state
|
|
70
|
+
#[derive(Debug, Clone, Default)]
|
|
71
|
+
pub struct OpenData {
|
|
72
|
+
pub opened_at: f64,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Data specific to the HalfOpen state
|
|
76
|
+
#[derive(Debug, Clone, Default)]
|
|
77
|
+
pub struct HalfOpenData {
|
|
78
|
+
pub consecutive_successes: usize,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Define the circuit breaker state machine with dynamic mode
|
|
82
|
+
state_machine! {
|
|
83
|
+
name: Circuit,
|
|
84
|
+
context: CircuitContext,
|
|
85
|
+
dynamic: true, // Enable dynamic mode for runtime state transitions
|
|
86
|
+
|
|
87
|
+
initial: Closed,
|
|
88
|
+
states: [
|
|
89
|
+
Closed,
|
|
90
|
+
Open(OpenData),
|
|
91
|
+
HalfOpen(HalfOpenData),
|
|
92
|
+
],
|
|
93
|
+
events {
|
|
94
|
+
trip {
|
|
95
|
+
guards: [should_open],
|
|
96
|
+
transition: { from: [Closed, HalfOpen], to: Open }
|
|
97
|
+
}
|
|
98
|
+
attempt_reset {
|
|
99
|
+
guards: [timeout_elapsed],
|
|
100
|
+
transition: { from: Open, to: HalfOpen }
|
|
101
|
+
}
|
|
102
|
+
close {
|
|
103
|
+
guards: [should_close],
|
|
104
|
+
transition: { from: HalfOpen, to: Closed }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Guards for dynamic mode - implemented on typestate machines
|
|
110
|
+
impl Circuit<Closed> {
|
|
111
|
+
/// Check if failure threshold is exceeded
|
|
112
|
+
fn should_open(&self, ctx: &CircuitContext) -> bool {
|
|
113
|
+
let failures = ctx
|
|
114
|
+
.storage
|
|
115
|
+
.failure_count(&ctx.name, ctx.config.failure_window_secs);
|
|
116
|
+
failures >= ctx.config.failure_threshold
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
impl Circuit<HalfOpen> {
|
|
121
|
+
/// Check if failure threshold is exceeded
|
|
122
|
+
fn should_open(&self, ctx: &CircuitContext) -> bool {
|
|
123
|
+
let failures = ctx
|
|
124
|
+
.storage
|
|
125
|
+
.failure_count(&ctx.name, ctx.config.failure_window_secs);
|
|
126
|
+
failures >= ctx.config.failure_threshold
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Check if enough successes to close circuit
|
|
130
|
+
fn should_close(&self, ctx: &CircuitContext) -> bool {
|
|
131
|
+
let data = self
|
|
132
|
+
.state_data_half_open()
|
|
133
|
+
.expect("HalfOpen state must have data");
|
|
134
|
+
data.consecutive_successes >= ctx.config.success_threshold
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
impl Circuit<Open> {
|
|
139
|
+
/// Check if timeout has elapsed for Open -> HalfOpen transition
|
|
140
|
+
fn timeout_elapsed(&self, ctx: &CircuitContext) -> bool {
|
|
141
|
+
let data = self.state_data_open().expect("Open state must have data");
|
|
142
|
+
let current_time = ctx.storage.monotonic_time();
|
|
143
|
+
let elapsed = current_time - data.opened_at;
|
|
144
|
+
|
|
145
|
+
// Apply jitter using chrono-machines if jitter_factor > 0
|
|
146
|
+
let timeout_secs = if ctx.config.jitter_factor > 0.0 {
|
|
147
|
+
let policy = chrono_machines::Policy {
|
|
148
|
+
max_attempts: 1,
|
|
149
|
+
base_delay_ms: (ctx.config.half_open_timeout_secs * 1000.0) as u64,
|
|
150
|
+
multiplier: 1.0,
|
|
151
|
+
max_delay_ms: (ctx.config.half_open_timeout_secs * 1000.0) as u64,
|
|
152
|
+
};
|
|
153
|
+
let timeout_ms = policy.calculate_delay(1, ctx.config.jitter_factor);
|
|
154
|
+
(timeout_ms as f64) / 1000.0
|
|
155
|
+
} else {
|
|
156
|
+
ctx.config.half_open_timeout_secs
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
elapsed >= timeout_secs
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Circuit breaker public API
|
|
164
|
+
pub struct CircuitBreaker {
|
|
165
|
+
machine: DynamicCircuit,
|
|
166
|
+
context: CircuitContext,
|
|
167
|
+
callbacks: Callbacks,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
impl CircuitBreaker {
|
|
171
|
+
/// Create a new circuit breaker (use builder() for more options)
|
|
172
|
+
pub fn new(name: String, config: Config) -> Self {
|
|
173
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
174
|
+
let context = CircuitContext {
|
|
175
|
+
name,
|
|
176
|
+
config,
|
|
177
|
+
storage,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let machine = DynamicCircuit::new(context.clone());
|
|
181
|
+
let callbacks = Callbacks::new();
|
|
182
|
+
|
|
183
|
+
Self {
|
|
184
|
+
machine,
|
|
185
|
+
context,
|
|
186
|
+
callbacks,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Create a circuit breaker with custom context and callbacks (used by builder)
|
|
191
|
+
pub(crate) fn with_context_and_callbacks(
|
|
192
|
+
context: CircuitContext,
|
|
193
|
+
callbacks: Callbacks,
|
|
194
|
+
) -> Self {
|
|
195
|
+
let machine = DynamicCircuit::new(context.clone());
|
|
196
|
+
|
|
197
|
+
Self {
|
|
198
|
+
machine,
|
|
199
|
+
context,
|
|
200
|
+
callbacks,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Create a new circuit breaker builder
|
|
205
|
+
pub fn builder(name: impl Into<String>) -> crate::builder::CircuitBuilder {
|
|
206
|
+
crate::builder::CircuitBuilder::new(name)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Execute a fallible operation with circuit breaker protection
|
|
210
|
+
pub fn call<F, T, E>(&mut self, f: F) -> Result<T, CircuitError<E>>
|
|
211
|
+
where
|
|
212
|
+
F: FnOnce() -> Result<T, E>,
|
|
213
|
+
{
|
|
214
|
+
// Check for timeout-based Open -> HalfOpen transition
|
|
215
|
+
if self.machine.current_state() == "Open" {
|
|
216
|
+
let _ = self.machine.handle(CircuitEvent::AttemptReset);
|
|
217
|
+
if self.machine.current_state() == "HalfOpen" {
|
|
218
|
+
self.callbacks.trigger_half_open(&self.context.name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Handle based on current state
|
|
223
|
+
match self.machine.current_state() {
|
|
224
|
+
"Open" => {
|
|
225
|
+
let opened_at = self.machine.open_data().map(|d| d.opened_at).unwrap_or(0.0);
|
|
226
|
+
Err(CircuitError::Open {
|
|
227
|
+
circuit: self.context.name.clone(),
|
|
228
|
+
opened_at,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
"HalfOpen" => {
|
|
232
|
+
// Check if we've reached the success threshold
|
|
233
|
+
if let Some(data) = self.machine.half_open_data() {
|
|
234
|
+
if data.consecutive_successes >= self.context.config.success_threshold {
|
|
235
|
+
return Err(CircuitError::HalfOpenLimitReached {
|
|
236
|
+
circuit: self.context.name.clone(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
self.execute_call(f)
|
|
241
|
+
}
|
|
242
|
+
_ => self.execute_call(f),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fn execute_call<F, T, E>(&mut self, f: F) -> Result<T, CircuitError<E>>
|
|
247
|
+
where
|
|
248
|
+
F: FnOnce() -> Result<T, E>,
|
|
249
|
+
{
|
|
250
|
+
let start = self.context.storage.monotonic_time();
|
|
251
|
+
|
|
252
|
+
match f() {
|
|
253
|
+
Ok(val) => {
|
|
254
|
+
let duration = self.context.storage.monotonic_time() - start;
|
|
255
|
+
self.context
|
|
256
|
+
.storage
|
|
257
|
+
.record_success(&self.context.name, duration);
|
|
258
|
+
|
|
259
|
+
// Handle success in HalfOpen state
|
|
260
|
+
if self.machine.current_state() == "HalfOpen" {
|
|
261
|
+
if let Some(data) = self.machine.half_open_data_mut() {
|
|
262
|
+
data.consecutive_successes += 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Try to close the circuit
|
|
266
|
+
if self.machine.handle(CircuitEvent::Close).is_ok() {
|
|
267
|
+
self.callbacks.trigger_close(&self.context.name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
Ok(val)
|
|
272
|
+
}
|
|
273
|
+
Err(e) => {
|
|
274
|
+
let duration = self.context.storage.monotonic_time() - start;
|
|
275
|
+
self.context
|
|
276
|
+
.storage
|
|
277
|
+
.record_failure(&self.context.name, duration);
|
|
278
|
+
|
|
279
|
+
// Try to trip the circuit
|
|
280
|
+
let result = self.machine.handle(CircuitEvent::Trip);
|
|
281
|
+
if result.is_ok() {
|
|
282
|
+
// Transition succeeded - update opened_at timestamp
|
|
283
|
+
if let Some(data) = self.machine.open_data_mut() {
|
|
284
|
+
data.opened_at = self.context.storage.monotonic_time();
|
|
285
|
+
}
|
|
286
|
+
self.callbacks.trigger_open(&self.context.name);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
Err(CircuitError::Execution(e))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Record a successful operation (for manual tracking)
|
|
295
|
+
pub fn record_success(&self, duration: f64) {
|
|
296
|
+
self.context
|
|
297
|
+
.storage
|
|
298
|
+
.record_success(&self.context.name, duration);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Record a failed operation (for manual tracking)
|
|
302
|
+
pub fn record_failure(&self, duration: f64) {
|
|
303
|
+
self.context
|
|
304
|
+
.storage
|
|
305
|
+
.record_failure(&self.context.name, duration);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Check if circuit is open
|
|
309
|
+
pub fn is_open(&self) -> bool {
|
|
310
|
+
self.machine.current_state() == "Open"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Check if circuit is closed
|
|
314
|
+
pub fn is_closed(&self) -> bool {
|
|
315
|
+
self.machine.current_state() == "Closed"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Get current state name
|
|
319
|
+
pub fn state_name(&self) -> &'static str {
|
|
320
|
+
self.machine.current_state()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Clear all events and reset circuit to Closed state
|
|
324
|
+
pub fn reset(&mut self) {
|
|
325
|
+
self.context.storage.clear(&self.context.name);
|
|
326
|
+
// Recreate machine in Closed state
|
|
327
|
+
self.machine = DynamicCircuit::new(self.context.clone());
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[cfg(test)]
|
|
332
|
+
mod tests {
|
|
333
|
+
use super::*;
|
|
334
|
+
|
|
335
|
+
#[test]
|
|
336
|
+
fn test_circuit_breaker_creation() {
|
|
337
|
+
let config = Config::default();
|
|
338
|
+
let circuit = CircuitBreaker::new("test".to_string(), config);
|
|
339
|
+
|
|
340
|
+
assert!(circuit.is_closed());
|
|
341
|
+
assert!(!circuit.is_open());
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn test_circuit_opens_after_threshold() {
|
|
346
|
+
let config = Config {
|
|
347
|
+
failure_threshold: 3,
|
|
348
|
+
failure_window_secs: 60.0,
|
|
349
|
+
half_open_timeout_secs: 30.0,
|
|
350
|
+
success_threshold: 2,
|
|
351
|
+
jitter_factor: 0.0,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
let mut circuit = CircuitBreaker::new("test".to_string(), config);
|
|
355
|
+
|
|
356
|
+
// Trigger failures via call() method
|
|
357
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
358
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
359
|
+
assert!(circuit.is_closed());
|
|
360
|
+
|
|
361
|
+
let _ = circuit.call(|| Err::<(), _>("error 3"));
|
|
362
|
+
assert!(circuit.is_open());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn test_reset_clears_state() {
|
|
367
|
+
let config = Config {
|
|
368
|
+
failure_threshold: 2,
|
|
369
|
+
..Default::default()
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
let mut circuit = CircuitBreaker::new("test".to_string(), config);
|
|
373
|
+
|
|
374
|
+
// Trigger failures
|
|
375
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
376
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
377
|
+
assert!(circuit.is_open());
|
|
378
|
+
|
|
379
|
+
circuit.reset();
|
|
380
|
+
assert!(circuit.is_closed());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn test_state_machine_closed_to_open_transition() {
|
|
385
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
386
|
+
let config = Config {
|
|
387
|
+
failure_threshold: 3,
|
|
388
|
+
failure_window_secs: 60.0,
|
|
389
|
+
half_open_timeout_secs: 30.0,
|
|
390
|
+
success_threshold: 2,
|
|
391
|
+
jitter_factor: 0.0,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let ctx = CircuitContext {
|
|
395
|
+
name: "test_circuit".to_string(),
|
|
396
|
+
config,
|
|
397
|
+
storage: storage.clone(),
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
401
|
+
|
|
402
|
+
// Initially closed - trip should fail guard
|
|
403
|
+
let result = circuit.handle(CircuitEvent::Trip);
|
|
404
|
+
assert!(result.is_err(), "Should fail guard when below threshold");
|
|
405
|
+
|
|
406
|
+
// Record failures to exceed threshold
|
|
407
|
+
storage.record_failure("test_circuit", 0.1);
|
|
408
|
+
storage.record_failure("test_circuit", 0.1);
|
|
409
|
+
storage.record_failure("test_circuit", 0.1);
|
|
410
|
+
|
|
411
|
+
// Now trip should succeed - guards pass
|
|
412
|
+
circuit
|
|
413
|
+
.handle(CircuitEvent::Trip)
|
|
414
|
+
.expect("Should open after reaching threshold");
|
|
415
|
+
|
|
416
|
+
assert_eq!(circuit.current_state(), "Open");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn test_state_machine_open_to_half_open_transition() {
|
|
421
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
422
|
+
let config = Config {
|
|
423
|
+
failure_threshold: 2,
|
|
424
|
+
failure_window_secs: 60.0,
|
|
425
|
+
half_open_timeout_secs: 0.001, // Very short timeout for testing
|
|
426
|
+
success_threshold: 2,
|
|
427
|
+
jitter_factor: 0.0,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
let ctx = CircuitContext {
|
|
431
|
+
name: "test_circuit".to_string(),
|
|
432
|
+
config,
|
|
433
|
+
storage: storage.clone(),
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Record failures and open circuit
|
|
437
|
+
storage.record_failure("test_circuit", 0.1);
|
|
438
|
+
storage.record_failure("test_circuit", 0.1);
|
|
439
|
+
|
|
440
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
441
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
442
|
+
|
|
443
|
+
// Set opened_at timestamp
|
|
444
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
445
|
+
data.opened_at = storage.monotonic_time();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Immediately try to reset - should fail guard (timeout not elapsed)
|
|
449
|
+
let result = circuit.handle(CircuitEvent::AttemptReset);
|
|
450
|
+
assert!(
|
|
451
|
+
result.is_err(),
|
|
452
|
+
"Should fail guard when timeout not elapsed"
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// Wait for timeout
|
|
456
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
457
|
+
|
|
458
|
+
circuit
|
|
459
|
+
.handle(CircuitEvent::AttemptReset)
|
|
460
|
+
.expect("Should reset after timeout");
|
|
461
|
+
|
|
462
|
+
// Verify we're in HalfOpen state
|
|
463
|
+
assert_eq!(circuit.current_state(), "HalfOpen");
|
|
464
|
+
let data = circuit.half_open_data().expect("Should have HalfOpen data");
|
|
465
|
+
assert_eq!(data.consecutive_successes, 0);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[test]
|
|
469
|
+
fn test_state_machine_half_open_to_closed_guard() {
|
|
470
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
471
|
+
let config = Config {
|
|
472
|
+
failure_threshold: 2,
|
|
473
|
+
failure_window_secs: 60.0,
|
|
474
|
+
half_open_timeout_secs: 0.001,
|
|
475
|
+
success_threshold: 2,
|
|
476
|
+
jitter_factor: 0.0,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
let ctx = CircuitContext {
|
|
480
|
+
name: "test_circuit".to_string(),
|
|
481
|
+
config,
|
|
482
|
+
storage: storage.clone(),
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Get to HalfOpen state
|
|
486
|
+
storage.record_failure("test_circuit", 0.1);
|
|
487
|
+
storage.record_failure("test_circuit", 0.1);
|
|
488
|
+
|
|
489
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
490
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
491
|
+
|
|
492
|
+
// Set opened_at and wait for timeout
|
|
493
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
494
|
+
data.opened_at = storage.monotonic_time();
|
|
495
|
+
}
|
|
496
|
+
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
497
|
+
|
|
498
|
+
circuit
|
|
499
|
+
.handle(CircuitEvent::AttemptReset)
|
|
500
|
+
.expect("Should reset");
|
|
501
|
+
|
|
502
|
+
// Try to close - should fail guard (not enough successes)
|
|
503
|
+
let result = circuit.handle(CircuitEvent::Close);
|
|
504
|
+
assert!(result.is_err(), "Should fail guard without successes");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#[test]
|
|
508
|
+
fn test_jitter_disabled() {
|
|
509
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
510
|
+
let config = Config {
|
|
511
|
+
failure_threshold: 1,
|
|
512
|
+
failure_window_secs: 60.0,
|
|
513
|
+
half_open_timeout_secs: 1.0, // 1 second timeout
|
|
514
|
+
success_threshold: 1,
|
|
515
|
+
jitter_factor: 0.0, // No jitter
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
let ctx = CircuitContext {
|
|
519
|
+
name: "test_circuit".to_string(),
|
|
520
|
+
config,
|
|
521
|
+
storage: storage.clone(),
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Open circuit
|
|
525
|
+
storage.record_failure("test_circuit", 0.1);
|
|
526
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
527
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
528
|
+
|
|
529
|
+
// Set opened_at
|
|
530
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
531
|
+
data.opened_at = storage.monotonic_time();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Wait exactly 1 second
|
|
535
|
+
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
536
|
+
|
|
537
|
+
// Should transition to HalfOpen (no jitter = exact timeout)
|
|
538
|
+
circuit
|
|
539
|
+
.handle(CircuitEvent::AttemptReset)
|
|
540
|
+
.expect("Should reset after exact timeout");
|
|
541
|
+
assert_eq!(circuit.current_state(), "HalfOpen");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
#[test]
|
|
545
|
+
fn test_jitter_enabled() {
|
|
546
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
547
|
+
let config = Config {
|
|
548
|
+
failure_threshold: 1,
|
|
549
|
+
failure_window_secs: 60.0,
|
|
550
|
+
half_open_timeout_secs: 1.0,
|
|
551
|
+
success_threshold: 1,
|
|
552
|
+
jitter_factor: 0.1, // 10% jitter = 90-100% of timeout
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
let ctx = CircuitContext {
|
|
556
|
+
name: "test_circuit".to_string(),
|
|
557
|
+
config,
|
|
558
|
+
storage: storage.clone(),
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// Test multiple times to verify jitter reduces timeout
|
|
562
|
+
let mut found_early_reset = false;
|
|
563
|
+
for _ in 0..10 {
|
|
564
|
+
// Open circuit
|
|
565
|
+
storage.record_failure("test_circuit", 0.1);
|
|
566
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
567
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
568
|
+
|
|
569
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
570
|
+
data.opened_at = storage.monotonic_time();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// With 10% jitter, timeout should be 900-1000ms
|
|
574
|
+
// Try at 950ms - should sometimes succeed (jitter applied)
|
|
575
|
+
std::thread::sleep(std::time::Duration::from_millis(950));
|
|
576
|
+
|
|
577
|
+
if circuit.handle(CircuitEvent::AttemptReset).is_ok() {
|
|
578
|
+
found_early_reset = true;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
storage.clear("test_circuit");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
assert!(
|
|
586
|
+
found_early_reset,
|
|
587
|
+
"Jitter should occasionally allow reset before full timeout"
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
#[test]
|
|
592
|
+
fn test_builder_with_jitter() {
|
|
593
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
594
|
+
.failure_threshold(2)
|
|
595
|
+
.half_open_timeout_secs(1.0)
|
|
596
|
+
.jitter_factor(0.5) // 50% jitter
|
|
597
|
+
.build();
|
|
598
|
+
|
|
599
|
+
// Trigger failures
|
|
600
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
601
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
602
|
+
assert!(circuit.is_open());
|
|
603
|
+
|
|
604
|
+
// Verify jitter_factor was set
|
|
605
|
+
assert_eq!(circuit.context.config.jitter_factor, 0.5);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//! Error types for circuit breaker operations
|
|
2
|
+
|
|
3
|
+
use std::error::Error;
|
|
4
|
+
use std::fmt;
|
|
5
|
+
|
|
6
|
+
/// Errors that can occur during circuit breaker operations
|
|
7
|
+
#[derive(Debug)]
|
|
8
|
+
pub enum CircuitError<E = Box<dyn Error + Send + Sync>> {
|
|
9
|
+
/// Circuit is open, calls are being rejected
|
|
10
|
+
Open { circuit: String, opened_at: f64 },
|
|
11
|
+
/// Half-open request limit has been reached
|
|
12
|
+
HalfOpenLimitReached { circuit: String },
|
|
13
|
+
/// The wrapped operation failed
|
|
14
|
+
Execution(E),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
impl<E: fmt::Display> fmt::Display for CircuitError<E> {
|
|
18
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
19
|
+
match self {
|
|
20
|
+
CircuitError::Open { circuit, opened_at } => {
|
|
21
|
+
write!(f, "Circuit '{}' is open (opened at {})", circuit, opened_at)
|
|
22
|
+
}
|
|
23
|
+
CircuitError::HalfOpenLimitReached { circuit } => {
|
|
24
|
+
write!(f, "Circuit '{}' half-open request limit reached", circuit)
|
|
25
|
+
}
|
|
26
|
+
CircuitError::Execution(e) => write!(f, "Circuit execution failed: {}", e),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl<E: Error + 'static> Error for CircuitError<E> {
|
|
32
|
+
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
33
|
+
match self {
|
|
34
|
+
CircuitError::Execution(e) => Some(e),
|
|
35
|
+
_ => None,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//! BreakerMachines - High-performance circuit breaker implementation
|
|
2
|
+
//!
|
|
3
|
+
//! This crate provides a complete circuit breaker implementation with:
|
|
4
|
+
//! - Thread-safe event storage with sliding window calculations
|
|
5
|
+
//! - State machine for circuit breaker lifecycle (Closed → Open → HalfOpen)
|
|
6
|
+
//! - Monotonic time tracking to prevent NTP clock skew issues
|
|
7
|
+
//! - Configurable failure thresholds and timeouts
|
|
8
|
+
//!
|
|
9
|
+
//! # Example
|
|
10
|
+
//!
|
|
11
|
+
//! ```rust
|
|
12
|
+
//! use breaker_machines::CircuitBreaker;
|
|
13
|
+
//!
|
|
14
|
+
//! let mut circuit = CircuitBreaker::builder("my_service")
|
|
15
|
+
//! .failure_threshold(5)
|
|
16
|
+
//! .failure_window_secs(60.0)
|
|
17
|
+
//! .half_open_timeout_secs(30.0)
|
|
18
|
+
//! .success_threshold(2)
|
|
19
|
+
//! .on_open(|name| println!("Circuit {} opened!", name))
|
|
20
|
+
//! .build();
|
|
21
|
+
//!
|
|
22
|
+
//! // Execute with circuit protection
|
|
23
|
+
//! let result = circuit.call(|| {
|
|
24
|
+
//! // Your service call here
|
|
25
|
+
//! Ok::<_, String>("success")
|
|
26
|
+
//! });
|
|
27
|
+
//!
|
|
28
|
+
//! // Check circuit state
|
|
29
|
+
//! if circuit.is_open() {
|
|
30
|
+
//! println!("Circuit is open, skipping call");
|
|
31
|
+
//! }
|
|
32
|
+
//! ```
|
|
33
|
+
|
|
34
|
+
pub mod builder;
|
|
35
|
+
pub mod callbacks;
|
|
36
|
+
pub mod circuit;
|
|
37
|
+
pub mod errors;
|
|
38
|
+
pub mod storage;
|
|
39
|
+
|
|
40
|
+
pub use builder::CircuitBuilder;
|
|
41
|
+
pub use circuit::{CircuitBreaker, Config};
|
|
42
|
+
pub use errors::CircuitError;
|
|
43
|
+
pub use storage::{MemoryStorage, NullStorage, StorageBackend};
|
|
44
|
+
|
|
45
|
+
/// Event type for circuit breaker operations
|
|
46
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
47
|
+
pub enum EventKind {
|
|
48
|
+
Success,
|
|
49
|
+
Failure,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// A single event recorded by the circuit breaker
|
|
53
|
+
#[derive(Debug, Clone)]
|
|
54
|
+
pub struct Event {
|
|
55
|
+
pub kind: EventKind,
|
|
56
|
+
pub timestamp: f64,
|
|
57
|
+
pub duration: f64,
|
|
58
|
+
}
|