breaker_machines 0.9.2 → 0.10.1
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/core/Cargo.toml +1 -1
- data/ext/breaker_machines_native/core/src/callbacks.rs +92 -3
- data/ext/breaker_machines_native/core/src/circuit.rs +285 -5
- data/ext/breaker_machines_native/ffi/Cargo.toml +2 -2
- data/ext/breaker_machines_native/ffi/src/lib.rs +21 -23
- data/lib/breaker_machines/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf8ad6f74f26b0e16224162a56d939fe9d74dff96b7d027310df893a4e8a7597
|
|
4
|
+
data.tar.gz: 36980ead62ae1571972b02f5d32daed76a040c5db26c2199d472482d2b82f553
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e719c01623af883b96dba7041b713e281ea9cc0212574eb0f924cac49e91a136112116ee0fd6930839c123b7822808617381c1b9b4fc7e1b012b6c88dd01bafd
|
|
7
|
+
data.tar.gz: 07eee7d0b5bbe8ba00abbfac9ce9849bf205b695ba4602c9a98bab477cb56ae9b558b9f1ba168d87cce35ff74d43a6931bfb7eba457ab00c998831fbb7a5376b
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//! Callback system for circuit breaker state transitions
|
|
2
2
|
|
|
3
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
3
4
|
use std::sync::Arc;
|
|
4
5
|
|
|
5
6
|
/// Type alias for circuit breaker callback functions
|
|
@@ -22,21 +23,30 @@ impl Callbacks {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/// Trigger the on_open callback safely, catching any panics to prevent
|
|
27
|
+
/// unwinding across FFI boundaries.
|
|
25
28
|
pub fn trigger_open(&self, circuit: &str) {
|
|
26
29
|
if let Some(ref callback) = self.on_open {
|
|
27
|
-
callback
|
|
30
|
+
let cb = AssertUnwindSafe(callback);
|
|
31
|
+
let _ = catch_unwind(|| cb(circuit));
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/// Trigger the on_close callback safely, catching any panics to prevent
|
|
36
|
+
/// unwinding across FFI boundaries.
|
|
31
37
|
pub fn trigger_close(&self, circuit: &str) {
|
|
32
38
|
if let Some(ref callback) = self.on_close {
|
|
33
|
-
callback
|
|
39
|
+
let cb = AssertUnwindSafe(callback);
|
|
40
|
+
let _ = catch_unwind(|| cb(circuit));
|
|
34
41
|
}
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
/// Trigger the on_half_open callback safely, catching any panics to prevent
|
|
45
|
+
/// unwinding across FFI boundaries.
|
|
37
46
|
pub fn trigger_half_open(&self, circuit: &str) {
|
|
38
47
|
if let Some(ref callback) = self.on_half_open {
|
|
39
|
-
callback
|
|
48
|
+
let cb = AssertUnwindSafe(callback);
|
|
49
|
+
let _ = catch_unwind(|| cb(circuit));
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
52
|
}
|
|
@@ -56,3 +66,82 @@ impl std::fmt::Debug for Callbacks {
|
|
|
56
66
|
.finish()
|
|
57
67
|
}
|
|
58
68
|
}
|
|
69
|
+
|
|
70
|
+
#[cfg(test)]
|
|
71
|
+
mod tests {
|
|
72
|
+
use super::*;
|
|
73
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn test_callback_panic_safety() {
|
|
77
|
+
// Callbacks that panic should not crash the program
|
|
78
|
+
let callbacks = Callbacks {
|
|
79
|
+
on_open: Some(Arc::new(|_| panic!("intentional panic in on_open"))),
|
|
80
|
+
on_close: Some(Arc::new(|_| panic!("intentional panic in on_close"))),
|
|
81
|
+
on_half_open: Some(Arc::new(|_| panic!("intentional panic in on_half_open"))),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// These should not panic - the panics are caught internally
|
|
85
|
+
callbacks.trigger_open("test");
|
|
86
|
+
callbacks.trigger_close("test");
|
|
87
|
+
callbacks.trigger_half_open("test");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[test]
|
|
91
|
+
fn test_callback_executes_successfully() {
|
|
92
|
+
let open_called = Arc::new(AtomicBool::new(false));
|
|
93
|
+
let close_called = Arc::new(AtomicBool::new(false));
|
|
94
|
+
let half_open_called = Arc::new(AtomicBool::new(false));
|
|
95
|
+
|
|
96
|
+
let open_clone = open_called.clone();
|
|
97
|
+
let close_clone = close_called.clone();
|
|
98
|
+
let half_open_clone = half_open_called.clone();
|
|
99
|
+
|
|
100
|
+
let callbacks = Callbacks {
|
|
101
|
+
on_open: Some(Arc::new(move |_| {
|
|
102
|
+
open_clone.store(true, Ordering::SeqCst);
|
|
103
|
+
})),
|
|
104
|
+
on_close: Some(Arc::new(move |_| {
|
|
105
|
+
close_clone.store(true, Ordering::SeqCst);
|
|
106
|
+
})),
|
|
107
|
+
on_half_open: Some(Arc::new(move |_| {
|
|
108
|
+
half_open_clone.store(true, Ordering::SeqCst);
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
callbacks.trigger_open("test");
|
|
113
|
+
callbacks.trigger_close("test");
|
|
114
|
+
callbacks.trigger_half_open("test");
|
|
115
|
+
|
|
116
|
+
assert!(
|
|
117
|
+
open_called.load(Ordering::SeqCst),
|
|
118
|
+
"on_open should be called"
|
|
119
|
+
);
|
|
120
|
+
assert!(
|
|
121
|
+
close_called.load(Ordering::SeqCst),
|
|
122
|
+
"on_close should be called"
|
|
123
|
+
);
|
|
124
|
+
assert!(
|
|
125
|
+
half_open_called.load(Ordering::SeqCst),
|
|
126
|
+
"on_half_open should be called"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[test]
|
|
131
|
+
fn test_callback_receives_circuit_name() {
|
|
132
|
+
let received_name = Arc::new(std::sync::Mutex::new(String::new()));
|
|
133
|
+
let name_clone = received_name.clone();
|
|
134
|
+
|
|
135
|
+
let callbacks = Callbacks {
|
|
136
|
+
on_open: Some(Arc::new(move |name| {
|
|
137
|
+
*name_clone.lock().unwrap() = name.to_string();
|
|
138
|
+
})),
|
|
139
|
+
on_close: None,
|
|
140
|
+
on_half_open: None,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
callbacks.trigger_open("my_circuit");
|
|
144
|
+
|
|
145
|
+
assert_eq!(*received_name.lock().unwrap(), "my_circuit");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -482,11 +482,12 @@ impl CircuitBreaker {
|
|
|
482
482
|
// Try to trip the circuit
|
|
483
483
|
let result = self.machine.handle(CircuitEvent::Trip);
|
|
484
484
|
if result.is_ok() {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
485
|
+
self.mark_open();
|
|
486
|
+
} else if self.machine.current_state() == "HalfOpen" {
|
|
487
|
+
// Failure did not reopen the circuit; reset consecutive successes
|
|
488
|
+
if let Some(data) = self.machine.half_open_data_mut() {
|
|
489
|
+
data.consecutive_successes = 0;
|
|
488
490
|
}
|
|
489
|
-
self.callbacks.trigger_open(&self.context.name);
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
|
|
@@ -495,6 +496,39 @@ impl CircuitBreaker {
|
|
|
495
496
|
}
|
|
496
497
|
}
|
|
497
498
|
|
|
499
|
+
/// Record a successful operation and drive HalfOpen -> Closed transitions
|
|
500
|
+
pub fn record_success_and_maybe_close(&mut self, duration: f64) {
|
|
501
|
+
self.context
|
|
502
|
+
.storage
|
|
503
|
+
.record_success(&self.context.name, duration);
|
|
504
|
+
|
|
505
|
+
if self.machine.current_state() == "HalfOpen" {
|
|
506
|
+
if let Some(data) = self.machine.half_open_data_mut() {
|
|
507
|
+
data.consecutive_successes += 1;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if self.machine.handle(CircuitEvent::Close).is_ok() {
|
|
511
|
+
self.callbacks.trigger_close(&self.context.name);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/// Record a failed operation and attempt to trip the circuit
|
|
517
|
+
pub fn record_failure_and_maybe_trip(&mut self, duration: f64) {
|
|
518
|
+
self.context
|
|
519
|
+
.storage
|
|
520
|
+
.record_failure(&self.context.name, duration);
|
|
521
|
+
|
|
522
|
+
let result = self.machine.handle(CircuitEvent::Trip);
|
|
523
|
+
if result.is_ok() {
|
|
524
|
+
self.mark_open();
|
|
525
|
+
} else if self.machine.current_state() == "HalfOpen"
|
|
526
|
+
&& let Some(data) = self.machine.half_open_data_mut()
|
|
527
|
+
{
|
|
528
|
+
data.consecutive_successes = 0;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
498
532
|
/// Record a successful operation (for manual tracking)
|
|
499
533
|
pub fn record_success(&self, duration: f64) {
|
|
500
534
|
self.context
|
|
@@ -512,7 +546,12 @@ impl CircuitBreaker {
|
|
|
512
546
|
/// Check failure threshold and attempt to trip the circuit
|
|
513
547
|
/// This should be called after record_failure() when not using call()
|
|
514
548
|
pub fn check_and_trip(&mut self) -> bool {
|
|
515
|
-
self.machine.handle(CircuitEvent::Trip).is_ok()
|
|
549
|
+
if self.machine.handle(CircuitEvent::Trip).is_ok() {
|
|
550
|
+
self.mark_open();
|
|
551
|
+
true
|
|
552
|
+
} else {
|
|
553
|
+
false
|
|
554
|
+
}
|
|
516
555
|
}
|
|
517
556
|
|
|
518
557
|
/// Check if circuit is open
|
|
@@ -536,6 +575,14 @@ impl CircuitBreaker {
|
|
|
536
575
|
// Recreate machine in Closed state
|
|
537
576
|
self.machine = DynamicCircuit::new(self.context.clone());
|
|
538
577
|
}
|
|
578
|
+
|
|
579
|
+
/// Apply Open-state bookkeeping (timestamp + callback)
|
|
580
|
+
fn mark_open(&mut self) {
|
|
581
|
+
if let Some(data) = self.machine.open_data_mut() {
|
|
582
|
+
data.opened_at = self.context.storage.monotonic_time();
|
|
583
|
+
}
|
|
584
|
+
self.callbacks.trigger_open(&self.context.name);
|
|
585
|
+
}
|
|
539
586
|
}
|
|
540
587
|
|
|
541
588
|
#[cfg(test)]
|
|
@@ -1153,4 +1200,237 @@ mod tests {
|
|
|
1153
1200
|
let result = circuit.call(|| Ok::<_, String>("should fail"));
|
|
1154
1201
|
assert!(matches!(result, Err(CircuitError::Open { .. })));
|
|
1155
1202
|
}
|
|
1203
|
+
|
|
1204
|
+
#[test]
|
|
1205
|
+
fn test_check_and_trip_sets_opened_at_and_callback() {
|
|
1206
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
1207
|
+
|
|
1208
|
+
let opened = Arc::new(AtomicBool::new(false));
|
|
1209
|
+
let opened_clone = opened.clone();
|
|
1210
|
+
|
|
1211
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
1212
|
+
.failure_threshold(1)
|
|
1213
|
+
.on_open(move |_name| {
|
|
1214
|
+
opened_clone.store(true, Ordering::SeqCst);
|
|
1215
|
+
})
|
|
1216
|
+
.build();
|
|
1217
|
+
|
|
1218
|
+
circuit.record_failure(0.1);
|
|
1219
|
+
let tripped = circuit.check_and_trip();
|
|
1220
|
+
|
|
1221
|
+
assert!(tripped, "Trip should succeed");
|
|
1222
|
+
assert!(circuit.is_open(), "Circuit should be open after trip");
|
|
1223
|
+
|
|
1224
|
+
let opened_at = circuit
|
|
1225
|
+
.machine
|
|
1226
|
+
.open_data()
|
|
1227
|
+
.expect("Open data should be present")
|
|
1228
|
+
.opened_at;
|
|
1229
|
+
|
|
1230
|
+
assert!(opened_at > 0.0, "opened_at should be set");
|
|
1231
|
+
assert!(
|
|
1232
|
+
opened.load(Ordering::SeqCst),
|
|
1233
|
+
"on_open callback should fire"
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
#[test]
|
|
1238
|
+
fn test_half_open_failure_resets_consecutive_successes() {
|
|
1239
|
+
let mut circuit = CircuitBreaker::builder("test")
|
|
1240
|
+
.failure_threshold(2)
|
|
1241
|
+
.half_open_timeout_secs(0.001)
|
|
1242
|
+
.success_threshold(2)
|
|
1243
|
+
.build();
|
|
1244
|
+
|
|
1245
|
+
// Open the circuit
|
|
1246
|
+
let _ = circuit.call(|| Err::<(), _>("error 1"));
|
|
1247
|
+
let _ = circuit.call(|| Err::<(), _>("error 2"));
|
|
1248
|
+
assert!(circuit.is_open());
|
|
1249
|
+
|
|
1250
|
+
// Move to HalfOpen
|
|
1251
|
+
if let Some(data) = circuit.machine.open_data_mut() {
|
|
1252
|
+
data.opened_at = circuit.context.storage.monotonic_time();
|
|
1253
|
+
}
|
|
1254
|
+
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
1255
|
+
circuit
|
|
1256
|
+
.machine
|
|
1257
|
+
.handle(CircuitEvent::AttemptReset)
|
|
1258
|
+
.expect("Should transition to HalfOpen");
|
|
1259
|
+
assert_eq!(circuit.machine.current_state(), "HalfOpen");
|
|
1260
|
+
|
|
1261
|
+
// Clear counts to simulate expired failure window
|
|
1262
|
+
circuit.context.storage.clear("test");
|
|
1263
|
+
|
|
1264
|
+
// First success increments consecutive count
|
|
1265
|
+
let _ = circuit.call(|| Ok::<_, String>("ok"));
|
|
1266
|
+
assert_eq!(
|
|
1267
|
+
circuit
|
|
1268
|
+
.machine
|
|
1269
|
+
.half_open_data()
|
|
1270
|
+
.expect("HalfOpen data")
|
|
1271
|
+
.consecutive_successes,
|
|
1272
|
+
1
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
// Failure below threshold should not reopen circuit but should reset counter
|
|
1276
|
+
let _ = circuit.call(|| Err::<(), _>("fail"));
|
|
1277
|
+
assert_eq!(circuit.machine.current_state(), "HalfOpen");
|
|
1278
|
+
assert_eq!(
|
|
1279
|
+
circuit
|
|
1280
|
+
.machine
|
|
1281
|
+
.half_open_data()
|
|
1282
|
+
.expect("HalfOpen data")
|
|
1283
|
+
.consecutive_successes,
|
|
1284
|
+
0
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
// Next success starts count from 1 again
|
|
1288
|
+
let _ = circuit.call(|| Ok::<_, String>("ok2"));
|
|
1289
|
+
assert_eq!(
|
|
1290
|
+
circuit
|
|
1291
|
+
.machine
|
|
1292
|
+
.half_open_data()
|
|
1293
|
+
.expect("HalfOpen data")
|
|
1294
|
+
.consecutive_successes,
|
|
1295
|
+
1
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
#[test]
|
|
1300
|
+
fn test_jitter_distribution_within_bounds() {
|
|
1301
|
+
// Test that jitter produces values within expected bounds
|
|
1302
|
+
// With 25% jitter on 1000ms base, expect 750-1000ms range
|
|
1303
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
1304
|
+
let base_timeout = 1.0; // 1 second
|
|
1305
|
+
let jitter_factor = 0.25;
|
|
1306
|
+
|
|
1307
|
+
let config = Config {
|
|
1308
|
+
failure_threshold: Some(1),
|
|
1309
|
+
half_open_timeout_secs: base_timeout,
|
|
1310
|
+
jitter_factor,
|
|
1311
|
+
..Default::default()
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
let ctx = CircuitContext {
|
|
1315
|
+
failure_classifier: None,
|
|
1316
|
+
bulkhead: None,
|
|
1317
|
+
name: "jitter_test".to_string(),
|
|
1318
|
+
config,
|
|
1319
|
+
storage: storage.clone(),
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
// Run 50 iterations and collect timeout values
|
|
1323
|
+
let mut min_seen = f64::MAX;
|
|
1324
|
+
let mut max_seen = f64::MIN;
|
|
1325
|
+
|
|
1326
|
+
for _ in 0..50 {
|
|
1327
|
+
storage.record_failure("jitter_test", 0.1);
|
|
1328
|
+
let mut circuit = DynamicCircuit::new(ctx.clone());
|
|
1329
|
+
circuit.handle(CircuitEvent::Trip).expect("Should open");
|
|
1330
|
+
|
|
1331
|
+
if let Some(data) = circuit.open_data_mut() {
|
|
1332
|
+
data.opened_at = storage.monotonic_time();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Calculate what the jittered timeout would be
|
|
1336
|
+
let policy = chrono_machines::Policy {
|
|
1337
|
+
max_attempts: 1,
|
|
1338
|
+
base_delay_ms: (base_timeout * 1000.0) as u64,
|
|
1339
|
+
multiplier: 1.0,
|
|
1340
|
+
max_delay_ms: (base_timeout * 1000.0) as u64,
|
|
1341
|
+
};
|
|
1342
|
+
let timeout_ms = policy.calculate_delay(1, jitter_factor);
|
|
1343
|
+
let timeout_secs = (timeout_ms as f64) / 1000.0;
|
|
1344
|
+
|
|
1345
|
+
min_seen = min_seen.min(timeout_secs);
|
|
1346
|
+
max_seen = max_seen.max(timeout_secs);
|
|
1347
|
+
|
|
1348
|
+
storage.clear("jitter_test");
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// With 25% jitter, minimum should be ~0.75s (75% of base)
|
|
1352
|
+
// Maximum should be ~1.0s (100% of base)
|
|
1353
|
+
let min_expected = base_timeout * (1.0 - jitter_factor);
|
|
1354
|
+
let max_expected = base_timeout;
|
|
1355
|
+
|
|
1356
|
+
assert!(
|
|
1357
|
+
min_seen >= min_expected - 0.01,
|
|
1358
|
+
"Minimum jittered timeout {} should be >= {}",
|
|
1359
|
+
min_seen,
|
|
1360
|
+
min_expected
|
|
1361
|
+
);
|
|
1362
|
+
assert!(
|
|
1363
|
+
max_seen <= max_expected + 0.01,
|
|
1364
|
+
"Maximum jittered timeout {} should be <= {}",
|
|
1365
|
+
max_seen,
|
|
1366
|
+
max_expected
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
#[test]
|
|
1371
|
+
fn test_jitter_produces_variance() {
|
|
1372
|
+
// Test that jitter actually produces different values (not all same)
|
|
1373
|
+
let storage = Arc::new(crate::MemoryStorage::new());
|
|
1374
|
+
|
|
1375
|
+
let config = Config {
|
|
1376
|
+
failure_threshold: Some(1),
|
|
1377
|
+
half_open_timeout_secs: 1.0,
|
|
1378
|
+
jitter_factor: 0.5, // 50% jitter for more variance
|
|
1379
|
+
..Default::default()
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
let ctx = CircuitContext {
|
|
1383
|
+
failure_classifier: None,
|
|
1384
|
+
bulkhead: None,
|
|
1385
|
+
name: "jitter_variance".to_string(),
|
|
1386
|
+
config,
|
|
1387
|
+
storage: storage.clone(),
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
let mut values = std::collections::HashSet::new();
|
|
1391
|
+
|
|
1392
|
+
for _ in 0..20 {
|
|
1393
|
+
let policy = chrono_machines::Policy {
|
|
1394
|
+
max_attempts: 1,
|
|
1395
|
+
base_delay_ms: 1000,
|
|
1396
|
+
multiplier: 1.0,
|
|
1397
|
+
max_delay_ms: 1000,
|
|
1398
|
+
};
|
|
1399
|
+
let timeout_ms = policy.calculate_delay(1, 0.5);
|
|
1400
|
+
values.insert(timeout_ms);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// With 50% jitter over 20 iterations, we should see at least 2 different values
|
|
1404
|
+
// (statistically, seeing all same values is extremely unlikely)
|
|
1405
|
+
assert!(
|
|
1406
|
+
values.len() >= 2,
|
|
1407
|
+
"Jitter should produce variance, got {} unique values",
|
|
1408
|
+
values.len()
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
#[test]
|
|
1413
|
+
fn test_zero_jitter_produces_constant_timeout() {
|
|
1414
|
+
// Test that 0% jitter always produces the same timeout
|
|
1415
|
+
let policy = chrono_machines::Policy {
|
|
1416
|
+
max_attempts: 1,
|
|
1417
|
+
base_delay_ms: 1000,
|
|
1418
|
+
multiplier: 1.0,
|
|
1419
|
+
max_delay_ms: 1000,
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
let mut values = std::collections::HashSet::new();
|
|
1423
|
+
|
|
1424
|
+
for _ in 0..10 {
|
|
1425
|
+
let timeout_ms = policy.calculate_delay(1, 0.0);
|
|
1426
|
+
values.insert(timeout_ms);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
assert_eq!(
|
|
1430
|
+
values.len(),
|
|
1431
|
+
1,
|
|
1432
|
+
"Zero jitter should produce constant timeout"
|
|
1433
|
+
);
|
|
1434
|
+
assert!(values.contains(&1000), "Timeout should be exactly 1000ms");
|
|
1435
|
+
}
|
|
1156
1436
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "breaker_machines_native"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.3"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
authors = ["Abdelkader Boudih <terminale@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -10,7 +10,7 @@ crate-type = ["cdylib"]
|
|
|
10
10
|
|
|
11
11
|
[dependencies]
|
|
12
12
|
breaker-machines = { workspace = true }
|
|
13
|
-
magnus = { version = "0.
|
|
13
|
+
magnus = { version = "0.8.2", features = ["embed"] }
|
|
14
14
|
|
|
15
15
|
[build-dependencies]
|
|
16
16
|
rb-sys = "0.9"
|
|
@@ -54,27 +54,23 @@ impl RubyStorage {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/// Get event log for a circuit (returns array of hashes)
|
|
57
|
-
fn event_log(&
|
|
58
|
-
let events =
|
|
59
|
-
let array =
|
|
57
|
+
fn event_log(ruby: &Ruby, storage: &RubyStorage, circuit_name: String, limit: usize) -> RArray {
|
|
58
|
+
let events = storage.inner.event_log(&circuit_name, limit);
|
|
59
|
+
let array = ruby.ary_new();
|
|
60
60
|
|
|
61
61
|
for event in events {
|
|
62
|
-
|
|
63
|
-
let hash = RHash::new();
|
|
64
|
-
|
|
65
|
-
// Set event type
|
|
62
|
+
let hash = ruby.hash_new();
|
|
66
63
|
let type_sym = match event.kind {
|
|
67
64
|
EventKind::Success => "success",
|
|
68
65
|
EventKind::Failure => "failure",
|
|
69
66
|
};
|
|
70
67
|
|
|
71
|
-
let _ = hash.aset(
|
|
72
|
-
let _ = hash.aset(
|
|
68
|
+
let _ = hash.aset(ruby.to_symbol("type"), type_sym);
|
|
69
|
+
let _ = hash.aset(ruby.to_symbol("timestamp"), event.timestamp);
|
|
73
70
|
let _ = hash.aset(
|
|
74
|
-
|
|
71
|
+
ruby.to_symbol("duration_ms"),
|
|
75
72
|
(event.duration * 1000.0).round(),
|
|
76
73
|
);
|
|
77
|
-
|
|
78
74
|
let _ = array.push(hash);
|
|
79
75
|
}
|
|
80
76
|
|
|
@@ -97,41 +93,41 @@ impl RubyCircuit {
|
|
|
97
93
|
/// - failure_window_secs: Time window for counting failures (default: 60.0)
|
|
98
94
|
/// - half_open_timeout_secs: Timeout before attempting reset (default: 30.0)
|
|
99
95
|
/// - success_threshold: Successes needed to close from half-open (default: 2)
|
|
100
|
-
fn new(name: String, config_hash: RHash) -> Result<Self, Error> {
|
|
96
|
+
fn new(ruby: &Ruby, name: String, config_hash: RHash) -> Result<Self, Error> {
|
|
101
97
|
use magnus::TryConvert;
|
|
102
98
|
|
|
103
99
|
// Extract config values with proper type conversion
|
|
104
100
|
let failure_threshold: usize = config_hash
|
|
105
|
-
.get(
|
|
101
|
+
.get(ruby.to_symbol("failure_threshold"))
|
|
106
102
|
.and_then(|v| usize::try_convert(v).ok())
|
|
107
103
|
.unwrap_or(5);
|
|
108
104
|
|
|
109
105
|
let failure_window_secs: f64 = config_hash
|
|
110
|
-
.get(
|
|
106
|
+
.get(ruby.to_symbol("failure_window_secs"))
|
|
111
107
|
.and_then(|v| f64::try_convert(v).ok())
|
|
112
108
|
.unwrap_or(60.0);
|
|
113
109
|
|
|
114
110
|
let half_open_timeout_secs: f64 = config_hash
|
|
115
|
-
.get(
|
|
111
|
+
.get(ruby.to_symbol("half_open_timeout_secs"))
|
|
116
112
|
.and_then(|v| f64::try_convert(v).ok())
|
|
117
113
|
.unwrap_or(30.0);
|
|
118
114
|
|
|
119
115
|
let success_threshold: usize = config_hash
|
|
120
|
-
.get(
|
|
116
|
+
.get(ruby.to_symbol("success_threshold"))
|
|
121
117
|
.and_then(|v| usize::try_convert(v).ok())
|
|
122
118
|
.unwrap_or(2);
|
|
123
119
|
|
|
124
120
|
let jitter_factor: f64 = config_hash
|
|
125
|
-
.get(
|
|
121
|
+
.get(ruby.to_symbol("jitter_factor"))
|
|
126
122
|
.and_then(|v| f64::try_convert(v).ok())
|
|
127
123
|
.unwrap_or(0.0);
|
|
128
124
|
|
|
129
125
|
let failure_rate_threshold: Option<f64> = config_hash
|
|
130
|
-
.get(
|
|
126
|
+
.get(ruby.to_symbol("failure_rate_threshold"))
|
|
131
127
|
.and_then(|v| f64::try_convert(v).ok());
|
|
132
128
|
|
|
133
129
|
let minimum_calls: usize = config_hash
|
|
134
|
-
.get(
|
|
130
|
+
.get(ruby.to_symbol("minimum_calls"))
|
|
135
131
|
.and_then(|v| usize::try_convert(v).ok())
|
|
136
132
|
.unwrap_or(20);
|
|
137
133
|
|
|
@@ -152,14 +148,16 @@ impl RubyCircuit {
|
|
|
152
148
|
|
|
153
149
|
/// Record a successful operation
|
|
154
150
|
fn record_success(&self, duration: f64) {
|
|
155
|
-
self.inner
|
|
151
|
+
self.inner
|
|
152
|
+
.borrow_mut()
|
|
153
|
+
.record_success_and_maybe_close(duration);
|
|
156
154
|
}
|
|
157
155
|
|
|
158
156
|
/// Record a failed operation and attempt to trip the circuit
|
|
159
157
|
fn record_failure(&self, duration: f64) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
self.inner
|
|
159
|
+
.borrow_mut()
|
|
160
|
+
.record_failure_and_maybe_trip(duration);
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/// Check if circuit is open
|