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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/ext/breaker_machines_native/Cargo.toml +8 -0
  3. data/ext/breaker_machines_native/core/Cargo.toml +18 -0
  4. data/ext/breaker_machines_native/core/examples/basic.rs +61 -0
  5. data/ext/breaker_machines_native/core/src/builder.rs +232 -0
  6. data/ext/breaker_machines_native/core/src/bulkhead.rs +223 -0
  7. data/ext/breaker_machines_native/core/src/callbacks.rs +58 -0
  8. data/ext/breaker_machines_native/core/src/circuit.rs +1156 -0
  9. data/ext/breaker_machines_native/core/src/classifier.rs +177 -0
  10. data/ext/breaker_machines_native/core/src/errors.rs +47 -0
  11. data/ext/breaker_machines_native/core/src/lib.rs +62 -0
  12. data/ext/breaker_machines_native/core/src/storage.rs +377 -0
  13. data/ext/breaker_machines_native/extconf.rb +40 -0
  14. data/ext/breaker_machines_native/ffi/Cargo.toml +16 -0
  15. data/ext/breaker_machines_native/ffi/src/lib.rs +218 -0
  16. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/common.rs +355 -0
  17. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/dynamic.rs +276 -0
  18. data/ext/breaker_machines_native/target/debug/build/clang-sys-d961dfabd5f43fba/out/macros.rs +49 -0
  19. 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
  20. 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
  21. 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
  22. 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
  23. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/Cargo.toml +48 -0
  24. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/examples/basic.rs +61 -0
  25. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/builder.rs +154 -0
  26. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/callbacks.rs +55 -0
  27. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/circuit.rs +607 -0
  28. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/errors.rs +38 -0
  29. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/lib.rs +58 -0
  30. data/ext/breaker_machines_native/target/package/breaker-machines-0.1.0/src/storage.rs +377 -0
  31. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/Cargo.toml +48 -0
  32. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/examples/basic.rs +61 -0
  33. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/builder.rs +173 -0
  34. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/callbacks.rs +55 -0
  35. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/circuit.rs +855 -0
  36. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/errors.rs +38 -0
  37. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/lib.rs +58 -0
  38. data/ext/breaker_machines_native/target/package/breaker-machines-0.2.0/src/storage.rs +377 -0
  39. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/Cargo.toml +48 -0
  40. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/examples/basic.rs +61 -0
  41. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/builder.rs +154 -0
  42. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/callbacks.rs +55 -0
  43. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/circuit.rs +607 -0
  44. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/errors.rs +38 -0
  45. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/lib.rs +58 -0
  46. data/ext/breaker_machines_native/target/package/breaker-machines-0.5.0/src/storage.rs +377 -0
  47. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/Cargo.toml +48 -0
  48. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/examples/basic.rs +61 -0
  49. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/builder.rs +232 -0
  50. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/bulkhead.rs +223 -0
  51. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/callbacks.rs +58 -0
  52. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/circuit.rs +1156 -0
  53. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/classifier.rs +177 -0
  54. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/errors.rs +47 -0
  55. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/lib.rs +62 -0
  56. data/ext/breaker_machines_native/target/package/breaker-machines-0.6.0/src/storage.rs +377 -0
  57. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/common.rs +355 -0
  58. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/dynamic.rs +276 -0
  59. data/ext/breaker_machines_native/target/release/build/clang-sys-ef8ad8b846ac8b75/out/macros.rs +49 -0
  60. 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
  61. data/lib/breaker_machines/circuit/async_state_management.rb +1 -1
  62. data/lib/breaker_machines/circuit/base.rb +2 -1
  63. data/lib/breaker_machines/circuit/coordinated_state_management.rb +3 -3
  64. data/lib/breaker_machines/circuit/native.rb +127 -0
  65. data/lib/breaker_machines/circuit/state_callbacks.rb +17 -5
  66. data/lib/breaker_machines/circuit/state_management.rb +1 -1
  67. data/lib/breaker_machines/native_extension.rb +36 -0
  68. data/lib/breaker_machines/native_speedup.rb +6 -0
  69. data/lib/breaker_machines/storage/bucket_memory.rb +4 -1
  70. data/lib/breaker_machines/storage/memory.rb +4 -1
  71. data/lib/breaker_machines/storage/native.rb +90 -0
  72. data/lib/breaker_machines/version.rb +1 -1
  73. data/lib/breaker_machines.rb +98 -11
  74. data/lib/breaker_machines_native/breaker_machines_native.bundle +0 -0
  75. data/sig/breaker_machines.rbs +20 -8
  76. 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
+ }